← Wei Desk / API
Tokens

Drive Wei Desk from your own code

A review, not an action. The model reads your code; it never runs it, never calls a chain and never signs anything. Your code travels in code (up to 30,000 characters of numbered lines; longer files keep the head and the tail and drop the middle with a marker), so strip private keys, RPC credentials and API secrets before you send it.

Everything the web page does is available over HTTP. Send one file or module of off-chain EVM code (a bot, a backend, a dashboard, an indexer, a relayer, a script) and get the same review back: a verdict (broken, at_risk, safe), a one-sentence headline, a short TL;DR, findings that each cite a line, quote the code on it, say what the bug does to a real amount or hash and how to fix it, the lane's body (amount paths and cache keying for decimals, hash sites and a signature table for hashing, the rewritten code for a patch), concrete tests, and one answer per browser flag. The natural use is a CI or pre-merge hook: run the files that touch token amounts or hashes through here and fail the build on broken.

The model does not do the reading alone. Hardcoded 18/6/8 decimals, ether helpers on ERC-20 amounts, float math on balances, symbol-keyed decimal tables, address-only caches, silent fallbacks to 18, NIST SHA3-256 where Ethereum needs Keccak-256, packed encodings of two dynamic values and non-canonical signatures are all found first by the page's free prescan in weikit.js, and every signature fed to a hash is canonicalised and hashed there by keccak.js (the model is told never to compute a hash itself). The result is sent as facts, a JSON string. See the facts string.

Three lanes: the task field

Every request names its lane in task. Wei Desk has three, with three different reply bodies.

taskwhat it is forlane body in the reply
decimalsAudit how the code reads, scales, compares and prices token amounts: hardcoded decimals, parseEther on tokens, float math, symbol-keyed tables, caches keyed without the chain, silent fallbacks, bridged stablecoins whose decimals differ by chain. Only D flags are sent; a hashing bug may appear only as a low finding.amount_paths, cache.
hashingAudit every place the code hashes Ethereum data: Keccak-256 against NIST SHA3-256 and SHA-256, canonical signatures, hex hashed as text, packed collisions. Only H flags are sent; a decimals bug may appear only as a low finding.hash_sites, signatures.
patchRewrite the code to fix the findings of an earlier decimals or hashing run (sent in findings), keeping the public interface. Both D and H flags are sent. The verdict describes the PATCHED code, and the envelope's findings lists only what is still wrong, usually [].patched_scope, patched_code, changes, helpers.

All three lanes share one envelope (lane, verdict, headline, tldr, findings, tests, prescan_responses); see the output contract. If task is missing or unrecognised, the model chooses the closest lane, answers with that lane's contract (never a blend) and names it in lane. Do not rely on that: always send "decimals", "hashing" or "patch". The page's own guard (WeiKit.mustBeObject) refuses any other value, and its checks flag a reply whose lane differs from the task asked.

The prescan is lane-aware too, so compute facts for the same lane you send in task (facts.lane says which): the flags list carries the D flags for decimals, the H flags for hashing and both for patch. The same code run in two lanes is two different runs, with two different Idempotency-Keys. The page's own flow is audit first, then patch: its "Patch from the audit" button sends the audit's findings as plain text (Recon.findingsText, one line per finding) in findings.

Input fields

The body is one flat JSON object, built in the page by WeiKit.buildInput. Every value is a string. Required: task, code, facts.

fieldtyperequiredwhat it holds
taskstringyes"decimals", "hashing" or "patch"; see the lanes.
namestringnoThe file name ("portfolio.ts"). Context only. Cut at 160 characters.
languagestringnotypescript, javascript, python, solidity or other. The page detects it when the form says auto.
chainsstringnoThe chains the code runs on, as written ("Ethereum, BNB Chain"). Cut at 300 characters. The prescan uses it: BNB Chain named here, plus USDC/USDT scaled by 6, raises bridged_decimal_drift.
codestringyesThe code with every line prefixed by its number and "| " ("12| const x = 1;"), so each finding can cite a real line. At most 30,000 characters: a longer file keeps about half the budget from the head and the rest from the tail, on whole lines, with a marker line in between: [... lines A-B (N lines) not sent; their prescan flags are in facts ...].
factsstringyesA JSON string (the output of JSON.stringify), never an object. In the page it is the browser's free prescan of the WHOLE file, including any clipped middle; its keys are listed below.
findingsstringpatch onlyThe findings to fix, as plain text, one per line, produced from an earlier decimals or hashing reply. Cut at 9,000 characters. May be "": the model then fixes what the flags in facts confirm. The page sends this key only in the patch lane.
questionstringnoYour own question, answered inside tldr as a bullet starting "Answer:". Cut at 1,200 characters on a word (the cut is marked [question cut]); the page sends "" when empty.
retry_notestringnoOnly on a reformat retry, after a reply that could not be parsed: say what was wrong. Never on a first run.

The facts string

In the web page, facts is computed by the browser before you pay for anything: the free prescan (weikit.js, with keccak.js for the hashes) reads the whole file, raises the flags you see on the page, canonicalises and hashes every signature it finds next to a hash call, and serializes the result. An API caller has two options: build the same object with weikit.js, which runs unchanged in Node (see building the body), or send a minimal one yourself.

keywhat it holds
lane, language, line_count, chainsThe lane the prescan ran for (match it to task), the language, the number of lines in the whole file, and the chains as sent.
flagsEvery flag for this lane: id (D1.. for decimals, H1.. for hashing), severity (high, medium, low), category, message and lines (up to 12 line numbers). The categories are in the next table.
browser_verdictThe prescan's hint: broken (any high flag), at_risk (any medium), else safe. The model's verdict is never looser unless it dismissed the flags that set it.
decimals_callsHow many lines read decimals().
hardcoded_decimal_linesUp to 20 line numbers with a literal 18, 6 or 8 decimals scale.
symbols_seenWell-known token symbols in the code (USDC, USDT, DAI, WBTC, WETH, BUSD, USDC.e, USDbC).
known_decimals, known_decimals_as_ofFor the symbols seen, the decimals the page read on-chain, as {symbol, chain, chain_id, decimals}, and the date they were read ("2026-09-26"). The model may state a token's decimals on a chain only from this list or from the code; otherwise it says "read decimals() on that chain". See the reference decimals.
signaturesUp to 30 signature strings found next to a hash call: line, given (as written), canonical (no names, no spaces, uint256 not uint, no keywords), canonical_ok, problems, and the hashes computed in the browser: selector (4 bytes of Keccak-256 of the canonical form), topic (all 32 bytes), nist_sha3_of_canonical (what NIST SHA3-256 gives instead) and selector_of_given (the selector of the string as written, only when it is not canonical). These are the ONLY digests the model may quote besides those already in the code.
hash_sitesUp to 40 lines that look like a hash call, as {line, algorithm_guess} (keccak256, nist_sha3_256, sha256, unknown).
clippednull, or {total_lines, dropped_lines, first_dropped, last_dropped} when the middle of the file was cut from code.
flag categoryseveritywhat the prescan matched
hardcoded_decimalshigh, or medium when the code also reads decimals()A fixed 18, 6 or 8 decimals scale (1e18, 10 ** 6, parseUnits(x, 18), decimals = 6).
ether_helper_on_tokensmedium with ERC-20 work, else lowparseEther, formatEther, toWei, fromWei and their Python forms.
float_mathhighNumber, parseFloat, float, toFixed or Math.* on an amount.
float_to_integerhighAn integer built from a float times 1e.. (BigInt(Math.round(x * 1e6))).
decimal_from_floatmediumDecimal(0.1) or Decimal(float(..)).
symbol_keyed_decimalshighA decimals table keyed by token symbol.
address_only_cachemediumA decimals cache keyed by token address without the chain.
silent_fallbackmedium, or low when the fallback is loggedDecimals falling back to 18 (?? 18, return 18).
no_runtime_lookuphigh with hardcoded decimals or ether helpers, else mediumERC-20 work (balanceOf, transfer, approve) with no decimals() call anywhere.
bridged_decimal_drifthighBNB Chain in play and USDC/USDT scaled by 6.
downscale_truncationlowInteger division down to fewer decimals, dropping dust.
divide_before_multiplymediumDivision by 10^decimals before a multiplication by a price.
nist_sha3highcreateHash('sha3-256'), hashlib.sha3_256, sha3_256(..).
sha256_in_ethereum_contextmediumSHA-256 within three lines of selectors, topics, slots, addresses, EIP-712 or Merkle work.
web3_sha3_namelowweb3.utils.sha3 (Keccak-256, but undefined for empty input).
hex_hashed_as_textmediumA hex string or address passed through toUtf8Bytes (or similar) next to a hash.
packed_dynamic_collisionmediumA packed encoding of two or more string or bytes values.
non_canonical_signaturehighA signature string fed to a hash that is not canonical.

The flags drive the reply. Every flag must come back exactly once in prescan_responses (confirmed or dismissed, with the reason and the line), and every confirmed high or medium flag must be carried by a finding with that flag's id in ref. They are pattern matches and can be wrong: the model is told to dismiss honestly (the decimals example dismisses formatEther on a native balance).

Sending facts without the prescan. An empty flags list is allowed. A minimal facts, as the JSON string you put in the body:

"facts": "{\"flags\":[],\"signatures\":[]}"

It costs you the reconciliation the page does. With no flags there is nothing to confirm or dismiss, prescan_responses comes back [], the verdict has no floor, and the findings rest on the model's own reading of the code. With no signatures, the model has no browser-computed selector, topic or SHA3 digest to quote, and it is told never to compute one, so a hashing reply can say a signature is not canonical but cannot give you the right selector. With no known_decimals, it will tell you to read decimals() on the chain rather than state a figure. Your own checks lose their reference too. Running weikit.js is free and gets you all of it.

The reference decimals

The page's known_decimals table was read on-chain with decimals() (eth_call, selector 0x313ce567) against public RPC endpoints on 2026-09-26. It is a dated snapshot, used only to explain a flag and to put a number on an impact; the rule stays "read decimals() at runtime and cache by (chain id, token address)". facts.known_decimals carries only the rows whose symbol appears in your code.

symbolchainchain idaddressdecimals
USDCEthereum10xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB486
USDTEthereum10xdAC17F958D2ee523a2206206994597C13D831ec76
WBTCEthereum10x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C5998
DAIEthereum10x6B175474E89094C44Da98b954EedeAC495271d0F18
USDCBNB Chain (Binance-Peg)560x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d18
USDTBNB Chain (BSC-USD)560x55d398326f99059fF775485246999027B319795518
USDCArbitrum One (native)421610xaf88d065e77c8cC2239327C5EDb3A432268e58316
USDCPolygon PoS (native)1370x3c499c542cEF5E3811e1192ce70d8cC03d5c33596

Building the body

The surest way to match the page is to run the page's own engine. Save weikit.js and keccak.js from this site (both run unchanged in Node via require()) and give analyze the same fields the page's form has; buildInput then numbers and clips the code, cuts the text fields and serializes the facts:

set fieldwhat it holds
lanedecimals, hashing or patch (anything else is analyzed as decimals). Becomes task, and picks which flags go into facts.flags.
nameThe file name.
languageA language, or auto (or empty) to detect it from the code.
chainsThe chains the code runs on, as written.
codeThe raw code, without line numbers.
// make-body.js - build the run body with the SAME engine the web page uses.
// Save weikit.js and keccak.js from https://wei-desk.skillsafe.ai/ next to this file
// (weikit.js loads ./keccak.js to canonicalise and hash signatures).
// Usage: node make-body.js portfolio.ts decimals "Ethereum, BNB Chain"
//        node make-body.js hashing.js hashing Ethereum
//        node make-body.js portfolio.ts patch "Ethereum, BNB Chain" findings.txt
const fs = require("fs");
const K = require("./weikit.js");

const file = process.argv[2] || "portfolio.ts";
const lane = process.argv[3] || "decimals";          // decimals, hashing or patch
const code = fs.readFileSync(file, "utf8");
const A = K.analyze({
  lane: lane,
  name: file.split("/").pop(),
  language: "auto",                   // or typescript, javascript, python, solidity, other
  chains: process.argv[4] || "",      // the chains the code runs on, as written
  code: code
});
const findings = lane === "patch" && process.argv[5] ? fs.readFileSync(process.argv[5], "utf8") : "";
// Pass the raw code as opts.code, as the page does; buildInput numbers the lines and clips.
const body = K.buildInput(A, { code: code, question: "", findings: findings });
fs.writeFileSync("body.json", JSON.stringify(K.mustBeObject(body)));
console.log(A.flags.length, "flags; browser verdict", A.hint);
console.log("Idempotency-Key: wei-desk:" + body.task + ":" + K.hashInput(body) + ":a1");

Run on the files from the page's examples, this produces the worked requests below, byte for byte: the portfolio module (decimals, hash 67sp4nnbh4op), the Node hashing helpers (hashing, hash nqstaf1ymhavd) and the portfolio patch (patch, hash 4z6mkpt6yoez). Those hashes are for the files exactly as the page holds them, with no trailing newline; a trailing newline is one more (empty) numbered line, a different body and a different hash. mustBeObject is the page's own guard: it throws unless the body is a plain object with task decimals, hashing or patch, non-empty code and a facts string. From another language, send the same field names, number the lines yourself ("1| ", "2| ", ...) and build facts with the keys above, or the minimal one.

For the patch lane, turn the audit's reply into the findings text the same way the page's "Patch from the audit" button does:

// findings.js - turn a decimals or hashing reply into the patch lane's findings text, as the page does.
// Save recon.js next to weikit.js and keccak.js. Usage: node findings.js reply.json decimals
const fs = require("fs");
const R = require("./recon.js");

const reply = R.normalize(R.parseResult(fs.readFileSync(process.argv[2] || "reply.json", "utf8")), process.argv[3] || "decimals");
fs.writeFileSync("findings.txt", R.findingsText(reply));
console.log(reply.findings.length, "findings written to findings.txt");

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}

The token is minted for this app (the guest endpoint takes {"slug":"wei-desk"} in its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ….

The input object IS the request body. There is no {"input": …} wrapper. A wrapped body is answered with an unknown field 'input' warning, and the model never sees your code.

Error codes

statuscodewhat to do
400validation_errorA field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that sends the token, unwraps data and raises on ok: false. The token comes from the token page (Copy token or Copy shell export); step 2 covers the kinds of token and minting one from code.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="wei-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://wei-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

2. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. A guest token, minted with POST /guest and {"slug":"wei-desk"}, can call /me and /estimate; the run is metered, so /run and /run-stream need a personal token.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://wei-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" -d '{"slug":"wei-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run (free)

/estimate returns the model binding and the credits a run would reserve. It creates no job and charges nothing. Expect model_alias gpt-terra and markup_bps 1000 (a 10% markup). hold_credits is a reservation, not the price: it is held against your balance while the run executes and released afterwards. min_credits is the least balance that can start a run. What you actually pay is charged_credits, reported on the finished job and in the done event, and it is usually far lower than the hold. The body is the input object itself, with no {"input": …} wrapper. /estimate does not validate the body, so check the shape yourself: an object whose every value is a string, task equal to decimals, hashing or patch, code and facts non-empty, and facts a JSON string that parses to an object (this is what the page's own guard, WeiKit.mustBeObject, refuses to spend without).

# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or by hand. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("decimals","hashing","patch") and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("code","facts")) and isinstance(json.loads(b["facts"]),dict)'
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
#   "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is the actual cost, usually far lower.

5. Run it, then poll

POST /run returns a job_id; poll GET /jobs/{id} until it is terminal. The reply is a string at data.output.output: JSON.parse it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the attempt number, wei-desk:<lane>:<hash>:a<attempt> (for example wei-desk:decimals:67sp4nnbh4op:a1), so a retried request returns the same job instead of billing a second run. Use one key per distinct input: changed code, changed facts or changed findings are a new hash, the same code in another lane is a new key, and replaying an old key with a different body is a 409. The page uses WeiKit.hashInput(body) for the hash (it covers task, name, language, chains, code, facts, findings and question; make-body.js prints the key); any stable digest of the body works from other languages. Leave retry_note out of the hash and bump the attempt instead.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')   # decimals, hashing or patch
KEY="wei-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"decimals\",\"verdict\":\"broken\",\"headline\":\"...\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

POST /run-stream takes the same body and headers and answers with server-sent events: job (the job id), delta (chunks of the reply) and done (the status, charged_credits, truncated and, when present, the full output). A browser page may receive only tick heartbeats and then done, never a delta, so take the reply from done.output.output when it is there, fall back to the concatenated deltas, and fall back again to GET /jobs/{id}.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"lane\":\"decimals\",\"verdict\":\"broken\",\"headline\":\"portfolio.ts"}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

The reply is one JSON object, delivered as a string in data.output.output; you must JSON.parse it. The model is told to send no code fences, but tolerate them: strip a leading ```json and a trailing ```, keep everything from the first { to the last }, and parse that outer object. Then branch on lane: the envelope keys are the same for all three lanes, the body keys are not.

# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json, re
t = open("reply.json").read().strip()
t = re.sub(r"^```(?:json)?\s*", "", t, flags=re.I)
t = re.sub(r"\s*```\s*$", "", t)
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r["lane"], r["verdict"], "-", r["headline"])
for f in r["findings"]:
    print(f["id"], f["severity"], f["ref"] or "-", "L%s" % f["line"], f["category"], "|", f["impact"])
if r["lane"] == "decimals":
    for p in r["amount_paths"]:
        print("PATH", p["name"], p["decimals_source"], p["math"], "chain-aware:", p["chain_aware"])
    print("CACHE", r["cache"]["keyed_by"])
elif r["lane"] == "hashing":
    for h in r["hash_sites"]:
        print("HASH L%s" % h["line"], h["algorithm"], h["purpose"], "ok:", h["ok"])
    for s in r["signatures"]:
        print("SIG", s["ok"], s["given"], "->", s["canonical"])
else:
    open("patched.txt", "w").write(r["patched_code"])
    for c in r["changes"]:
        print("CHANGE", c["ref"], c["what"])
EOF

Invariants worth asserting

The web page holds every reply to the browser's facts and to your code before it shows it (recon.js: normalize(), then reconcile()). Do the same before you fail a build on a verdict or apply a patch:

The page's checks are plain JavaScript, so the exact same reconciliation runs in Node:

// check-reply.js - hold a reply to the same checks the web page runs (recon.js reconcile()).
// Save recon.js, weikit.js and keccak.js next to this file.
// Usage: node check-reply.js body.json reply.json      (reply.json = data.output.output)
const fs = require("fs");
const K = require("./weikit.js");
const R = require("./recon.js");

const body = JSON.parse(fs.readFileSync(process.argv[2] || "body.json", "utf8"));
const facts = JSON.parse(body.facts);
// Rebuild the prescan from the numbered code the model saw: quotes are checked against these lines.
// (With a clipped file, run this on the original file instead so line numbers stay exact.)
const code = body.code.split("\n").map((l) => l.replace(/^\d+\| /, "")).join("\n");
const A = K.analyze({ lane: body.task, name: body.name, language: body.language, chains: body.chains, code: code });
if (JSON.stringify(A.flags.map((f) => f.id)) !== JSON.stringify(facts.flags.map((f) => f.id)))
  console.warn("note: facts.flags differ from a fresh prescan of this code; the checks use the fresh one");

const reply = R.normalize(R.parseResult(fs.readFileSync(process.argv[3] || "reply.json", "utf8")), body.task);
const missing = R.SECTIONS[reply.lane].filter((k) => reply.present.indexOf(k) === -1);
if (missing.length) console.log("BAD  keys missing: " + missing.join(", "));
const rec = R.reconcile(reply, { analysis: A, input: body });
for (const i of rec.items) console.log((i.bad ? "BAD  " : i.review ? "LOOK " : "ok   ") + i.kind + ": " + i.text);
const look = rec.items.filter((i) => i.review).length;   // patch re-scan leftovers: review by hand
console.log(reply.lane, reply.verdict, "-", rec.disagreements + missing.length, "disagreement(s),", look, "to review");
process.exit(rec.disagreements + missing.length ? 1 : 0);

Run on the worked examples below, the decimals and hashing replies report 0 disagreements; the patch reply reports 0 disagreements and one item to review, from the re-scan, explained in its example.

The output contract

Every key of the lane's contract is always present. Arrays may be empty ([], never a filler such as "None"). An enum is written "a|b|c": the reply carries exactly one of the values. Text is plain: no Markdown, no emoji, each string under 400 characters except patched_code and a helper's code. The reply is only the JSON object, with no prose before or after it.

The common envelope (every lane)

{"lane":"decimals|hashing|patch",
 "verdict":"broken|at_risk|safe",
 "headline":"...",
 "tldr":["...","..."],
 "findings":[{"id":"F1","ref":"D2","severity":"critical|high|medium|low","category":"...","line":12,"code":"...","problem":"...","impact":"...","fix":"..."}],
 ...the lane body...,
 "tests":[{"name":"...","given":"...","expect":"..."}],
 "prescan_responses":[{"ref":"D1","verdict":"confirmed|dismissed","note":"..."}]}
keyshapewhat it holds
lanestringThe lane answered: your task, or the closest lane when task was missing or unknown.
verdictenumSee the next table. In the patch lane it describes the patched code.
headlinestringOne sentence on the state of the code.
tldrarray of strings2-5 bullets. When you sent a question, one bullet starts "Answer:".
findingsarray of objectsIds F1, F2, ... worst first. ref: the flag ids the finding answers, comma-separated, or "" for one the browser missed. category: snake_case, the flag's category when it answers one. line and code: one input line and a verbatim part of it (without the 12| prefix), or 0 and "". impact: what the bug does to a concrete amount or hash, with decimals from the code or facts.known_decimals and digests from facts.signatures. In the patch lane: only what is STILL wrong in the patched code.
testsarray of {name, given, expect}2-6 concrete checks with exact values: amounts as base-unit integers and decimal strings, selectors only from facts.signatures.
prescan_responsesarray of {ref, verdict, note}Exactly one per flag in facts.flags. In the patch lane they answer the flags against the ORIGINAL code. Empty when no flags were sent.

The decimals body

{"amount_paths":[{"name":"usdValue","decimals_source":"runtime|hardcoded|symbol_table|fallback|none|unknown","math":"exact|float|mixed|unknown","chain_aware":"yes|no|unknown","note":"..."}],
 "cache":{"keyed_by":"chain_and_address|address_only|symbol|none|not_applicable","note":"..."}}
keywhat it holds
amount_pathsOne entry per function or flow that turns a raw amount into a human or USD value or back. decimals_source: read from decimals() at runtime, hardcoded, a symbol_table, a fallback, none (no scaling) or unknown. math: exact (BigInt, parseUnits, Decimal from int or str), float, mixed. chain_aware: whether the decimals used depend on the chain.
cacheHow decimals are cached: by chain_and_address (right), address_only, symbol, none (no cache) or not_applicable.

The hashing body

{"hash_sites":[{"line":6,"code":"...","algorithm":"keccak256|nist_sha3_256|sha256|other|unknown","purpose":"selector|event_topic|typehash|storage_slot|address|merkle|signature|id|other","ok":"yes|no|unclear","note":"..."}],
 "signatures":[{"given":"...","canonical":"...","ok":"yes|no","note":"..."}]}
keywhat it holds
hash_sitesOne entry per hashing call in the input, with the line, the verbatim code, the algorithm, what the hash is for, and whether it is right.
signaturesOne entry per facts.signatures entry, in the same order, given and canonical copied exactly; ok agrees with canonical_ok; note says what the non-canonical form costs, quoting selector and selector_of_given from facts.

The patch body

{"patched_scope":"file|excerpts",
 "patched_code":"...the rewritten code, no line-number prefixes...",
 "changes":[{"ref":"F1","what":"...","why":"..."}],
 "helpers":[{"name":"...","code":"..."}]}
keywhat it holds
patched_scopefile: patched_code is the whole file (the input was under about 250 lines and not clipped). excerpts: only the changed functions, each preceded by a comment naming it.
patched_codeThe rewritten code as one string, in the input's language, runnable as written apart from imports the file already had. The public interface is kept unless a fix needs a new parameter (a chain id, say), and then changes says so.
changesOne per fix; ref is the finding id (F2) or flag id it resolves.
helpersSmall reusable functions the patch introduced (a chain-keyed decimals cache, a WAD normaliser), also present inside patched_code; [] if none.

Verdict and severity

valuemeaning
brokenAny finding is critical or high.
at_riskThe worst finding is medium.
safeNothing worse than low.
critical findingFunds are sent, signed or priced wrong: a transfer amount off by a power of ten, a signature or selector that will never match.
high findingA value shown or stored wrong by orders of magnitude, or a hash that is always wrong.
medium findingWrong only for some tokens, chains or inputs, or precision silently lost.
low findingFragile or unclear but correct today.

The model may be stricter than facts.browser_verdict, never looser, unless it dismissed the flags that set it.

Enums

wherevaluesthe page's fallback
lanedecimals, hashing, patchthe lane asked
verdictbroken, at_risk, safederived from the worst finding
findings[].severitycritical, high, medium, lowmedium
prescan_responses[].verdictconfirmed, dismissedconfirmed
amount_paths[].decimals_sourceruntime, hardcoded, symbol_table, fallback, none, unknownunknown
amount_paths[].mathexact, float, mixed, unknownunknown
amount_paths[].chain_awareyes, no, unknownunknown
cache.keyed_bychain_and_address, address_only, symbol, none, not_applicablenot_applicable
hash_sites[].algorithmkeccak256, nist_sha3_256, sha256, other, unknownunknown
hash_sites[].purposeselector, event_topic, typehash, storage_slot, address, merkle, signature, id, otherother
hash_sites[].okyes, no, unclearunclear
signatures[].okyes, nono
patched_scopefile, excerptsfile

Worked example: decimals

The page's first example, "TypeScript portfolio and payout module" (portfolio.ts, chains Ethereum, BNB Chain). It reads balances and prices them, sends stablecoins, and has a correct on-chain decimals() reader and a WAD helper that nothing calls. The prescan raised eight flags: D3 float_math, D4 float_to_integer, D5 symbol_keyed_decimals and D8 bridged_decimal_drift at high; D1 hardcoded_decimals, D2 ether_helper_on_tokens, D6 address_only_cache and D7 silent_fallback at medium, so browser_verdict is broken. Because the code names USDC, USDT and DAI, known_decimals carries their rows from the 2026-09-26 table. This request is complete and sendable as make-body.js builds it; its Idempotency-Key from the page is wei-desk:decimals:67sp4nnbh4op:a1.

The request body, with code and facts abridged:

{
 "task": "decimals",
 "name": "portfolio.ts",
 "language": "typescript",
 "chains": "Ethereum, BNB Chain",
 "code": "1| import { Contract, JsonRpcProvider, formatUnits, formatEther } from \"ethers\";\n2| import { CONFIG } from \"./config\";\n3| \n4| const ERC20_ABI = [\n... [abridged here: all 47 lines are shown decoded below]",
 "facts": "{\"lane\":\"decimals\",\"language\":\"typescript\",\"line_count\":47,\"chains\":\"Ethereum, BNB Chain\",\"flags\":[{\"id\":\"D1\",... [abridged here: decoded below]",
 "question": ""
}

Its code, decoded (the N| prefixes are part of the string):

1| import { Contract, JsonRpcProvider, formatUnits, formatEther } from "ethers";
2| import { CONFIG } from "./config";
3| 
4| const ERC20_ABI = [
5|   "function balanceOf(address) view returns (uint256)",
6|   "function transfer(address to, uint256 amount) returns (bool)",
7| ];
8| 
9| // decimals per token - stablecoins are 6, everything else 18
10| const DECIMALS: Record<string, number> = { "USDC": 6, "USDT": 6, "WETH": 18, "DAI": 18 };
11| 
12| const decimalsCache: Record<string, number> = {};
13| 
14| export async function tokenDecimals(provider: JsonRpcProvider, token: string): Promise<number> {
15|   if (decimalsCache[token]) return decimalsCache[token];
16|   try {
17|     const c = new Contract(token, ["function decimals() view returns (uint8)"], provider);
18|     decimalsCache[token] = Number(await c.decimals());
19|   } catch {
20|     decimalsCache[token] = 18;
21|   }
22|   return decimalsCache[token];
23| }
24| 
25| // chainId 1 = Ethereum, 56 = BNB Chain
26| export async function usdValue(chainId: number, symbol: string, token: string, wallet: string, price: number): Promise<number> {
27|   const provider = new JsonRpcProvider(CONFIG.rpc[chainId]);
28|   const erc20 = new Contract(token, ERC20_ABI, provider);
29|   const raw: bigint = await erc20.balanceOf(wallet);
30|   const decimals = DECIMALS[symbol] ?? 18;
31|   return parseFloat(formatUnits(raw, decimals)) * price;
32| }
33| 
34| export async function sendStable(token: string, to: string, amountUsd: number, signer: any) {
35|   const erc20 = new Contract(token, ERC20_ABI, signer);
36|   const amount = BigInt(Math.round(amountUsd * 1e6)); // USDC/USDT have 6 decimals
37|   return erc20.transfer(to, amount);
38| }
39| 
40| export function toWad(raw: bigint, decimals: number): bigint {
41|   if (decimals > 18) return raw / 10n ** BigInt(decimals - 18);
42|   return raw * 10n ** BigInt(18 - decimals);
43| }
44| 
45| export function fmtNative(raw: bigint): string {
46|   return Number(formatEther(raw)).toFixed(4);
47| }

Its facts, decoded:

{
 "lane": "decimals",
 "language": "typescript",
 "line_count": 47,
 "chains": "Ethereum, BNB Chain",
 "flags": [
  {"id":"D1","severity":"medium","category":"hardcoded_decimals","message":"1 line(s) scale amounts by a fixed 18, 6 or 8 decimals even though the code also reads decimals() elsewhere - check each one uses the looked-up value.","lines":[36]},
  {"id":"D2","severity":"medium","category":"ether_helper_on_tokens","message":"parseEther / formatEther / toWei / fromWei assume 18 decimals. Right for ETH, WETH and DAI; wrong for USDC and USDT (6 on Ethereum) or WBTC (8).","lines":[46]},
  {"id":"D3","severity":"high","category":"float_math","message":"3 line(s) push token amounts through floating point (Number, parseFloat, float, toFixed, Math.*). Doubles hold about 15-17 significant digits; an 18-decimal balance has more.","lines":[31,36,46]},
  {"id":"D4","severity":"high","category":"float_to_integer","message":"An integer amount is built from a float multiplied by 1e-something. 0.1 * 1e18 is not exactly 100000000000000000; parse the decimal string with parseUnits / Decimal instead.","lines":[36]},
  {"id":"D5","severity":"high","category":"symbol_keyed_decimals","message":"Decimals come from a table keyed by token symbol. The same symbol has different decimals on different chains (USDC is 6 on Ethereum and 18 as Binance-Peg USDC on BNB Chain); key by (chain id, token address) and read decimals() on-chain.","lines":[10,30]},
  {"id":"D6","severity":"medium","category":"address_only_cache","message":"A decimals cache is keyed by token address alone. The same address can hold a different token on another chain; key the cache by (chain id, address).","lines":[15,18,20,22]},
  {"id":"D7","severity":"medium","category":"silent_fallback","message":"A failed or missing decimals() quietly becomes 18 with nothing logged. A 6-decimal token then reads 10^12 times too small with no error anywhere.","lines":[20,30]},
  {"id":"D8","severity":"high","category":"bridged_decimal_drift","message":"BNB Chain is in play and USDC/USDT are scaled by 6 decimals. On BNB Chain, Binance-Peg USDC and BSC-USD (USDT) use 18 decimals (read on-chain 2026-09-26); a 6-decimal assumption there is off by 10^12.","lines":[10,36]}
 ],
 "browser_verdict": "broken",
 "decimals_calls": 2,
 "hardcoded_decimal_lines": [36],
 "symbols_seen": ["USDC","USDT","WETH","DAI"],
 "known_decimals": [
  {"symbol":"USDC","chain":"Ethereum","chain_id":1,"decimals":6},
  {"symbol":"USDT","chain":"Ethereum","chain_id":1,"decimals":6},
  {"symbol":"DAI","chain":"Ethereum","chain_id":1,"decimals":18},
  {"symbol":"USDC","chain":"BNB Chain (Binance-Peg)","chain_id":56,"decimals":18},
  {"symbol":"USDT","chain":"BNB Chain (BSC-USD)","chain_id":56,"decimals":18},
  {"symbol":"USDC","chain":"Arbitrum One (native)","chain_id":42161,"decimals":6},
  {"symbol":"USDC","chain":"Polygon PoS (native)","chain_id":137,"decimals":6}
 ],
 "known_decimals_as_of": "2026-09-26",
 "signatures": [],
 "hash_sites": [],
 "clipped": null
}

The reply, parsed from data.output.output, complete. The model confirmed seven flags and dismissed D2 (the formatEther in fmtNative formats a native ETH or BNB balance, 18 decimals on both chains), carried every confirmed high and medium flag in three findings (F1 critical, F2 high, F3 medium), put numbers on each impact from known_decimals, and kept broken. check-reply.js reports 0 disagreements for it.

{
 "lane": "decimals",
 "verdict": "broken",
 "headline": "portfolio.ts hardcodes and symbol-keys token decimals in usdValue and sendStable, so BNB Chain USDC/USDT balances and transfers are wrong by a factor of 10^12 while a correct on-chain decimals() reader and WAD helper sit unused.",
 "tldr": [
  "Verdict: broken - sendStable hardcodes 6-decimal USDC/USDT scaling (line 36); on BNB Chain that sends 10^12x too little.",
  "usdValue prices tokens from a chain-blind DECIMALS[symbol] table (line 30); a BNB Chain USDC balance shows as 10^12x its real value.",
  "tokenDecimals correctly reads decimals() on-chain but is never called, caches by address only (no chain id), and silently defaults to 18 on failure.",
  "toWad is a correct WAD-normalisation helper but is also unused; wiring both into usdValue/sendStable fixes the chain-dependent bugs."
 ],
 "findings": [
  {
   "id": "F1",
   "ref": "D1,D3,D4,D8",
   "severity": "critical",
   "category": "bridged_decimal_drift",
   "line": 36,
   "code": "const amount = BigInt(Math.round(amountUsd * 1e6));",
   "problem": "sendStable always assumes 6 decimals via 1e6 and Math.round on a float, ignoring chainId/token entirely; tokenDecimals() (lines 17-18) reads the real decimals() on-chain but is never called from here.",
   "impact": "On BNB Chain, Binance-Peg USDC/BSC-USD are 18 decimals (facts.known_decimals, read 2026-09-26). amountUsd=100 builds amount=100,000,000 raw units; against an 18-decimal contract that transfers 0.0000000001 USDC instead of 100 - short by 10^12x. The float multiply also risks rounding above ~9,007,199,254.",
   "fix": "Call tokenDecimals(provider, token) keyed by (chainId, token) to get the real decimals, then build the amount with parseUnits(amountUsd.toString(), decimals) instead of Math.round(amountUsd*1e6); never assume 6."
  },
  {
   "id": "F2",
   "ref": "D3,D5,D7",
   "severity": "high",
   "category": "symbol_keyed_decimals",
   "line": 30,
   "code": "const decimals = DECIMALS[symbol] ?? 18;",
   "problem": "usdValue looks up decimals purely by symbol, never by chainId, and silently falls back to 18 if the symbol is missing; the result is then run through parseFloat/formatUnits and multiplied by price as a float (line 31).",
   "impact": "Binance-Peg USDC on BNB Chain is 18 decimals, not the 6 the table assumes (facts.known_decimals, read 2026-09-26). A 1 USDC balance (raw=1,000,000,000,000,000,000, 10^18 base units) read with formatUnits(raw,6) shows as 1,000,000,000,000 USDC - 10^12x too high.",
   "fix": "Key decimals by (chainId, token address) via tokenDecimals(provider, token) instead of DECIMALS[symbol]; log when falling back to a default; avoid parseFloat on large raw balances - compare in BigInt/WAD before converting to a display float."
  },
  {
   "id": "F3",
   "ref": "D6,D7",
   "severity": "medium",
   "category": "address_only_cache",
   "line": 15,
   "code": "if (decimalsCache[token]) return decimalsCache[token];",
   "problem": "decimalsCache is keyed by token address alone (lines 15,18,20,22); the same address can be a different token with different decimals on another chain. On failure (line 20) it silently caches 18 with nothing logged.",
   "impact": "If reused across chains, an address wrongly cached at 18 decimals but actually 6 decimals would read a 1,000,000-unit (1 token) balance as 0.000000000001 instead of 1 - undercounted 10^12x. tokenDecimals is currently unused by usdValue/sendStable in this file.",
   "fix": "Key decimalsCache by a composite chainId:token string; log a warning whenever the catch block defaults to 18; wire this function into usdValue and sendStable instead of the static DECIMALS table."
  }
 ],
 "amount_paths": [
  {
   "name": "tokenDecimals",
   "decimals_source": "runtime",
   "math": "exact",
   "chain_aware": "no",
   "note": "Reads decimals() on-chain (line 18) but caches by token address only (D6) and defaults to 18 unlogged on failure (D7); never called elsewhere in this file."
  },
  {
   "name": "usdValue",
   "decimals_source": "symbol_table",
   "math": "float",
   "chain_aware": "no",
   "note": "chainId only selects the RPC provider (line 27); decimals come from DECIMALS[symbol] (line 30), so the same symbol gets the same decimals on every chain (D5/D8)."
  },
  {
   "name": "sendStable",
   "decimals_source": "hardcoded",
   "math": "float",
   "chain_aware": "no",
   "note": "Hardcodes 6 decimals via 1e6 on line 36 regardless of chain or token; never reads tokenDecimals()."
  },
  {
   "name": "toWad",
   "decimals_source": "unknown",
   "math": "exact",
   "chain_aware": "unknown",
   "note": "Correct BigInt WAD (18-decimal) normalisation given a decimals count, matching the fix pattern in the skill's rules; unused elsewhere in the file."
  },
  {
   "name": "fmtNative",
   "decimals_source": "hardcoded",
   "math": "float",
   "chain_aware": "no",
   "note": "formatEther assumes 18 decimals; correct here since it formats native ETH/BNB balances, not an ERC-20 amount (D2 dismissed)."
  }
 ],
 "cache": {
  "keyed_by": "address_only",
  "note": "decimalsCache (line 12, used lines 15/18/20/22) keys by token address only, ignoring chainId (D6); the DECIMALS table (line 10) is a separate static symbol-keyed lookup with no chain dimension either (D5/D8)."
 },
 "tests": [
  {
   "name": "sendStable on BNB Chain USDC",
   "given": "sendStable(bnbUsdc, to, 100, signer) where bnbUsdc is Binance-Peg USDC on BNB Chain (18 decimals)",
   "expect": "amount = 100000000000000000000n (100 * 10^18), not BigInt(Math.round(100*1e6)) = 100000000n"
  },
  {
   "name": "usdValue on BNB Chain USDC",
   "given": "usdValue(56, 'USDC', bnbUsdc, wallet, 1) with erc20.balanceOf returning 1000000000000000000n (1 USDC, 18 decimals) and price=1",
   "expect": "return value 1, not parseFloat(formatUnits(1000000000000000000n,6))*1 = 1000000000000"
  },
  {
   "name": "tokenDecimals cache is chain-scoped",
   "given": "tokenDecimals(providerChain1, addr) then tokenDecimals(providerChain56, addr) where addr has different decimals per chain",
   "expect": "two independent cache entries, not one decimalsCache[addr] shared across both chains"
  },
  {
   "name": "tokenDecimals logs on fallback",
   "given": "c.decimals() rejects (RPC error)",
   "expect": "a warning/error is logged before decimalsCache[token] is set to 18"
  },
  {
   "name": "sendStable large amount precision",
   "given": "amountUsd = 9500000000 (9.5 billion), above Number.MAX_SAFE_INTEGER/1e6 (~9,007,199,254), on a 6-decimal token",
   "expect": "parseUnits('9500000000',6) = 9500000000000000n exactly; Math.round(amountUsd*1e6) is not guaranteed to equal this once amountUsd*1e6 exceeds 9007199254740991"
  }
 ],
 "prescan_responses": [
  {
   "ref": "D1",
   "verdict": "confirmed",
   "note": "Line 36 hardcodes USDC/USDT to 6 decimals even though tokenDecimals() (lines 17-18) reads decimals() on-chain elsewhere in the file; sendStable never calls it."
  },
  {
   "ref": "D2",
   "verdict": "dismissed",
   "note": "Line 46's formatEther is on a native-chain balance in fmtNative, not an ERC-20 token; ETH (Ethereum) and BNB (BNB Chain) are both 18 decimals, so formatEther is correct here."
  },
  {
   "ref": "D3",
   "verdict": "confirmed",
   "note": "Real precision loss at line 31 (parseFloat(formatUnits(...))*price, portfolio value) and line 36 (Math.round(amountUsd*1e6), transfer amount); line 46 is a display-only toFixed(4) of a native balance and is lower risk."
  },
  {
   "ref": "D4",
   "verdict": "confirmed",
   "note": "Line 36 builds a transfer amount from a float (amountUsd*1e6) rounded to integer; loses guaranteed precision once amountUsd*1e6 exceeds Number.MAX_SAFE_INTEGER (9007199254740991), and pairs with the wrong decimal count from D1/D8."
  },
  {
   "ref": "D5",
   "verdict": "confirmed",
   "note": "Line 30 keys decimals by symbol (DECIMALS[symbol]); the same symbol USDC is 6 decimals on Ethereum but 18 as Binance-Peg USDC on BNB Chain (facts.known_decimals), so usdValue mis-scales by chain."
  },
  {
   "ref": "D6",
   "verdict": "confirmed",
   "note": "decimalsCache (lines 15,18,20,22) is keyed by token address only; the same address on a different chain would silently reuse the wrong cached decimals count."
  },
  {
   "ref": "D7",
   "verdict": "confirmed",
   "note": "Line 20 defaults to 18 on any decimals() failure with no log (tokenDecimals); line 30 defaults to 18 with ?? when a symbol is missing from DECIMALS, also unlogged."
  },
  {
   "ref": "D8",
   "verdict": "confirmed",
   "note": "Line 36's hardcoded 6-decimal assumption (from the line 10 table) is wrong on BNB Chain, where Binance-Peg USDC and BSC-USD are 18 decimals (facts.known_decimals, read 2026-09-26); sendStable ignores chainId entirely."
  }
 ]
}

Worked example: hashing

The page's "Node hashing helpers" example (hashing.js, chain Ethereum): a selector helper, a swap selector, the ERC-20 Transfer topic, an order id, a mapping storage slot, an address from a public key and a referral id. The prescan raised four flags: H1 nist_sha3 (high, lines 6 and 25), H2 hex_hashed_as_text (medium, line 31), H3 packed_dynamic_collision (medium, line 14) and H4 non_canonical_signature (high, line 9), so browser_verdict is broken. It also found two signatures and hashed them: the swap signature is not canonical (its real selector is 0x38ed1739; the string as written hashes to 0x2c23add9) and Transfer(address,address,uint256) is (selector 0xddf252ad). Its Idempotency-Key from the page is wei-desk:hashing:nqstaf1ymhavd:a1.

The request body, with code and facts abridged:

{
 "task": "hashing",
 "name": "hashing.js",
 "language": "javascript",
 "chains": "Ethereum",
 "code": "1| const crypto = require(\"crypto\");\n2| const { AbiCoder, getAddress, keccak256, solidityPacked, toUtf8Bytes } = require(\"ethers\");\n3| \n4| // 4-byte selector for a function signature\n5| function selector(signature) {\n6|   return \"0x\" + crypto.createHash(\"sha3-256\").update(signature).digest(\"hex\").slice(0, 8);\n... [abridged here: all 34 lines are shown decoded below]",
 "facts": "{\"lane\":\"hashing\",\"language\":\"javascript\",\"line_count\":34,\"chains\":\"Ethereum\",\"flags\":[{\"id\":\"H1\",... [abridged here: decoded below]",
 "question": ""
}

Its code, decoded:

1| const crypto = require("crypto");
2| const { AbiCoder, getAddress, keccak256, solidityPacked, toUtf8Bytes } = require("ethers");
3| 
4| // 4-byte selector for a function signature
5| function selector(signature) {
6|   return "0x" + crypto.createHash("sha3-256").update(signature).digest("hex").slice(0, 8);
7| }
8| 
9| const SWAP_SELECTOR = selector("swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] path, address to, uint deadline)");
10| const TRANSFER_TOPIC = keccak256(toUtf8Bytes("Transfer(address,address,uint256)"));
11| 
12| // off-chain order id, checked by the relayer
13| function orderHash(makerNote, routeBytes, amount) {
14|   return keccak256(solidityPacked(["string", "bytes", "uint256"], [makerNote, routeBytes, amount]));
15| }
16| 
17| // storage slot of balances[holder] where balances is at slot 0
18| function balanceSlot(holder) {
19|   const enc = AbiCoder.defaultAbiCoder().encode(["address", "uint256"], [holder, 0]);
20|   return keccak256(enc);
21| }
22| 
23| // uncompressed secp256k1 public key (0x04 + 64 bytes) -> address
24| function addressFromPubkey(pubkeyHex) {
25|   const hash = crypto.createHash("sha3-256").update(Buffer.from(pubkeyHex.slice(4), "hex")).digest("hex");
26|   return getAddress("0x" + hash.slice(-40));
27| }
28| 
29| // referral id for a wallet
30| function referralId(walletAddress) {
31|   return keccak256(toUtf8Bytes(walletAddress));
32| }
33| 
34| module.exports = { selector, SWAP_SELECTOR, TRANSFER_TOPIC, orderHash, balanceSlot, addressFromPubkey, referralId };

Its facts, decoded:

{
 "lane": "hashing",
 "language": "javascript",
 "line_count": 34,
 "chains": "Ethereum",
 "flags": [
  {"id":"H1","severity":"high","category":"nist_sha3","message":"NIST SHA3-256 is used where Ethereum expects Keccak-256. createHash('sha3-256') in Node, hashlib.sha3_256 in Python and sha3_256 in js-sha3 all give a different digest; selectors, topics, storage slots and addresses built on it are silently wrong.","lines":[6,25]},
  {"id":"H2","severity":"medium","category":"hex_hashed_as_text","message":"A hex string or address appears to be hashed as UTF-8 text. keccak256(toUtf8Bytes('0xab..')) hashes the characters, not the bytes; use getBytes / hexToBytes / Web3.keccak(hexstr=...).","lines":[31]},
  {"id":"H3","severity":"medium","category":"packed_dynamic_collision","message":"A packed encoding hashes two or more dynamic values (string, bytes). abi.encodePacked('a','bc') equals ('ab','c'): different inputs, same hash. Use the non-packed encoding or add a length or separator.","lines":[14]},
  {"id":"H4","severity":"high","category":"non_canonical_signature","message":"1 signature string(s) fed to a hash are not canonical (parameter names, spaces, uint for uint256, keywords). The hash of a non-canonical string is a different selector or topic - see the signature table for the correct one.","lines":[9]}
 ],
 "browser_verdict": "broken",
 "decimals_calls": 0,
 "hardcoded_decimal_lines": [],
 "symbols_seen": [],
 "known_decimals": [],
 "known_decimals_as_of": "2026-09-26",
 "signatures": [
  {"line":9,"given":"swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] path, address to, uint deadline)","canonical":"swapExactTokensForTokens(uint256,uint256,address[],address,uint256)","canonical_ok":false,"problems":["parameter name 'amountIn' is not part of the signature","'uint' is written 'uint256' in a canonical signature","parameter name 'amountOutMin' is not part of the signature","parameter name 'path' is not part of the signature","parameter name 'to' is not part of the signature","parameter name 'deadline' is not part of the signature","spaces are not allowed inside a canonical signature"],"selector":"0x38ed1739","topic":"0x38ed1739ee07daf49933f1800d1a9bc8d39a6876ea11e643f9c4c39c66df0ee8","nist_sha3_of_canonical":"0xbc36746667565667354e10cc7596129619dfa0844a4a2b16d1176b1e6f1566aa","selector_of_given":"0x2c23add9"},
  {"line":10,"given":"Transfer(address,address,uint256)","canonical":"Transfer(address,address,uint256)","canonical_ok":true,"problems":[],"selector":"0xddf252ad","topic":"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","nist_sha3_of_canonical":"0xc17a9d92b89f27cb79cc390f23a1a5d302fefab8c7911075ede952ac2b5607a1","selector_of_given":""}
 ],
 "hash_sites": [
  {"line":2,"algorithm_guess":"keccak256"},
  {"line":6,"algorithm_guess":"nist_sha3_256"},
  {"line":10,"algorithm_guess":"keccak256"},
  {"line":14,"algorithm_guess":"keccak256"},
  {"line":20,"algorithm_guess":"keccak256"},
  {"line":25,"algorithm_guess":"nist_sha3_256"},
  {"line":31,"algorithm_guess":"keccak256"}
 ],
 "clipped": null
}

The reply, parsed from data.output.output, complete. All four flags are confirmed and carried by five findings (F1 critical, F2 and F3 high, F4 and F5 medium); the six hash sites include the two correct ones (the Transfer topic and the storage slot, ok yes); both signatures are copied exactly from facts; and every selector and digest in the reply is one the browser computed. check-reply.js reports 0 disagreements for it.

{
 "lane": "hashing",
 "verdict": "broken",
 "headline": "Two functions hash with NIST SHA3-256 instead of Keccak-256, the swap selector is also built from a non-canonical signature, orderHash can collide across different inputs, and the referral id hashes address text instead of bytes.",
 "tldr": [
  "SWAP_SELECTOR (line 9, via selector() at line 6) will never equal the router's real selector 0x38ed1739: the signature text is non-canonical AND the hash algorithm is NIST SHA3-256, not Keccak-256.",
  "addressFromPubkey (line 25) derives addresses with NIST SHA3-256 instead of Keccak-256, so every returned address is wrong.",
  "orderHash (line 14) packs a string and bytes value together with no separator, so different (makerNote, routeBytes) pairs can hash identically.",
  "referralId (line 31) hashes the wallet address as text, not its 20 raw bytes, and without case normalization first."
 ],
 "findings": [
  {
   "id": "F1",
   "ref": "H4",
   "severity": "critical",
   "category": "non_canonical_signature",
   "line": 9,
   "code": "const SWAP_SELECTOR = selector(\"swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] path, address to, uint deadline)\");",
   "problem": "The signature text keeps parameter names and writes uint instead of uint256 (facts.signatures problems), so it is not the canonical swapExactTokensForTokens(uint256,uint256,address[],address,uint256).",
   "impact": "Hashing this given text with keccak256 alone would already yield selector_of_given 0x2c23add9, never the router's real selector 0x38ed1739 (both from facts.signatures) - every call using SWAP_SELECTOR sends a selector the router does not recognize and the swap reverts.",
   "fix": "Build the string from parameter types only, no names, uint256 not uint: selector(\"swapExactTokensForTokens(uint256,uint256,address[],address,uint256)\")."
  },
  {
   "id": "F2",
   "ref": "H1",
   "severity": "high",
   "category": "nist_sha3",
   "line": 6,
   "code": "return \"0x\" + crypto.createHash(\"sha3-256\").update(signature).digest(\"hex\").slice(0, 8);",
   "problem": "selector() hashes with Node's crypto sha3-256, which is NIST SHA3-256, not Ethereum's Keccak-256; the two give different digests for the same input.",
   "impact": "Even with a canonical signature, selector() returns the first 4 bytes of a NIST SHA3-256 digest: facts.signatures shows the canonical signature's NIST SHA3-256 digest is 0xbc36746667565667354e10cc7596129619dfa0844a4a2b16d1176b1e6f1566aa, unrelated to the real selector 0x38ed1739. selector is also exported directly, so every other caller inherits this.",
   "fix": "Replace crypto.createHash('sha3-256') with ethers' keccak256, e.g. keccak256(toUtf8Bytes(signature)).slice(0, 10)."
  },
  {
   "id": "F3",
   "ref": "H1",
   "severity": "high",
   "category": "nist_sha3",
   "line": 25,
   "code": "const hash = crypto.createHash(\"sha3-256\").update(Buffer.from(pubkeyHex.slice(4), \"hex\")).digest(\"hex\");",
   "problem": "addressFromPubkey hashes the uncompressed public key bytes with NIST SHA3-256 instead of Keccak-256.",
   "impact": "An Ethereum address is the last 20 bytes of keccak256(pubkey); sha3-256 produces a different 32-byte digest, so getAddress() returns a validly checksummed but entirely wrong address for every pubkey passed in.",
   "fix": "Hash with ethers keccak256(Buffer.from(pubkeyHex.slice(4), 'hex')) and take the last 20 bytes before getAddress()."
  },
  {
   "id": "F4",
   "ref": "H3",
   "severity": "medium",
   "category": "packed_dynamic_collision",
   "line": 14,
   "code": "return keccak256(solidityPacked([\"string\", \"bytes\", \"uint256\"], [makerNote, routeBytes, amount]));",
   "problem": "orderHash packs makerNote (string) directly against routeBytes (bytes) with solidityPacked, which writes both with no length prefix or separator.",
   "impact": "orderHash('AB', '0x43', amount) and orderHash('A', '0x4243', amount) pack to the identical bytes 0x414243...; a relayer trusting orderHash as a unique order id can receive two different (makerNote, routeBytes) pairs that hash the same.",
   "fix": "Use AbiCoder.defaultAbiCoder().encode(['string','bytes','uint256'], [...]) instead of solidityPacked, or insert a fixed-length prefix/separator between makerNote and routeBytes."
  },
  {
   "id": "F5",
   "ref": "H2",
   "severity": "medium",
   "category": "hex_hashed_as_text",
   "line": 31,
   "code": "return keccak256(toUtf8Bytes(walletAddress));",
   "problem": "referralId hashes walletAddress as UTF-8 text (its ASCII characters), not its 20 raw address bytes, and does not normalize the string's case first.",
   "impact": "The same wallet passed as a checksummed string and as an all-lowercase string produces two different referral ids; if a referral id is ever meant to equal an on-chain keccak256(abi.encodePacked(address)), it never will, since this hashes 42 text characters instead of 20 bytes.",
   "fix": "Normalize first, e.g. keccak256(getBytes(getAddress(walletAddress))), or at minimum call getAddress(walletAddress) before hashing its text so the case is fixed."
  }
 ],
 "hash_sites": [
  {
   "line": 6,
   "code": "return \"0x\" + crypto.createHash(\"sha3-256\").update(signature).digest(\"hex\").slice(0, 8);",
   "algorithm": "nist_sha3_256",
   "purpose": "selector",
   "ok": "no",
   "note": "NIST SHA3-256, not keccak256; breaks every selector this exported helper builds (see F2)."
  },
  {
   "line": 10,
   "code": "const TRANSFER_TOPIC = keccak256(toUtf8Bytes(\"Transfer(address,address,uint256)\"));",
   "algorithm": "keccak256",
   "purpose": "event_topic",
   "ok": "yes",
   "note": "Canonical signature (facts.signatures canonical_ok true) hashed with ethers keccak256; matches topic 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef from facts."
  },
  {
   "line": 14,
   "code": "return keccak256(solidityPacked([\"string\", \"bytes\", \"uint256\"], [makerNote, routeBytes, amount]));",
   "algorithm": "keccak256",
   "purpose": "id",
   "ok": "no",
   "note": "Correct algorithm, unsafe packed encoding of two dynamic types back to back (see F4)."
  },
  {
   "line": 20,
   "code": "return keccak256(enc);",
   "algorithm": "keccak256",
   "purpose": "storage_slot",
   "ok": "yes",
   "note": "keccak256 of AbiCoder.encode(['address','uint256'],[holder,0]) is the standard mapping-slot derivation for balances[holder] at slot 0; not flagged."
  },
  {
   "line": 25,
   "code": "const hash = crypto.createHash(\"sha3-256\").update(Buffer.from(pubkeyHex.slice(4), \"hex\")).digest(\"hex\");",
   "algorithm": "nist_sha3_256",
   "purpose": "address",
   "ok": "no",
   "note": "NIST SHA3-256 instead of keccak256 (see F3); every derived address is wrong."
  },
  {
   "line": 31,
   "code": "return keccak256(toUtf8Bytes(walletAddress));",
   "algorithm": "keccak256",
   "purpose": "id",
   "ok": "unclear",
   "note": "Correct algorithm but hashes address text, not bytes, without case normalization first (see F5)."
  }
 ],
 "signatures": [
  {
   "given": "swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] path, address to, uint deadline)",
   "canonical": "swapExactTokensForTokens(uint256,uint256,address[],address,uint256)",
   "ok": "no",
   "note": "Hashing this given text yields selector_of_given 0x2c23add9, not the real selector 0x38ed1739; see F1."
  },
  {
   "given": "Transfer(address,address,uint256)",
   "canonical": "Transfer(address,address,uint256)",
   "ok": "yes",
   "note": "Already canonical; selector 0xddf252ad, no cost."
  }
 ],
 "tests": [
  {
   "name": "swap selector matches the real function after the fix",
   "given": "selector(\"swapExactTokensForTokens(uint256,uint256,address[],address,uint256)\") computed with ethers keccak256",
   "expect": "0x38ed1739"
  },
  {
   "name": "transfer topic unchanged",
   "given": "TRANSFER_TOPIC as currently computed at line 10",
   "expect": "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
  },
  {
   "name": "addressFromPubkey uses keccak256, not sha3-256",
   "given": "a known secp256k1 pubkey/address test vector passed to addressFromPubkey",
   "expect": "getAddress result equals the vector's known Ethereum address, not today's sha3-256-derived value"
  },
  {
   "name": "orderHash no longer collides across a string/bytes split",
   "given": "orderHash('AB', '0x43', 1) and orderHash('A', '0x4243', 1)",
   "expect": "different order hashes (today both pack to 0x414243... and collide)"
  },
  {
   "name": "referralId stable across address letter case",
   "given": "referralId('0xAbCdEf0123456789abcdef0123456789ABCDEF01') and referralId('0xabcdef0123456789abcdef0123456789abcdef01') for the same wallet",
   "expect": "same referral id (today they differ because the raw text, not normalized bytes, is hashed)"
  }
 ],
 "prescan_responses": [
  {
   "ref": "H1",
   "verdict": "confirmed",
   "note": "crypto.createHash('sha3-256') at line 6 (selector()) and line 25 (addressFromPubkey) both compute NIST SHA3-256, not Keccak-256; every selector and address built on them is wrong."
  },
  {
   "ref": "H2",
   "verdict": "confirmed",
   "note": "Line 31 hashes walletAddress's UTF-8 text, not its bytes, with no case normalization; the id depends on letter case and would never equal an on-chain keccak256(abi.encodePacked(address))."
  },
  {
   "ref": "H3",
   "verdict": "confirmed",
   "note": "Line 14 packs makerNote (string) directly against routeBytes (bytes) via solidityPacked with no separator; different (makerNote, routeBytes) pairs whose concatenated bytes match produce the same orderHash."
  },
  {
   "ref": "H4",
   "verdict": "confirmed",
   "note": "The signature hashed at line 9 keeps parameter names and 'uint' instead of 'uint256' (facts.signatures problems); keccak256 of this text is selector_of_given 0x2c23add9, not the real selector 0x38ed1739."
  }
 ]
}

Worked example: patch

The page's "Patch from the audit" flow on the same portfolio.ts: the same numbered code as the decimals example, facts computed for the patch lane (identical to the decimals example's apart from "lane":"patch": the same eight D flags, and no H flag matched this file), and in findings the decimals reply above turned into text by Recon.findingsText (findings.js produces the same three lines). Its Idempotency-Key from the page is wei-desk:patch:4z6mkpt6yoez:a1.

The request body, with code, facts and findings abridged:

{
 "task": "patch",
 "name": "portfolio.ts",
 "language": "typescript",
 "chains": "Ethereum, BNB Chain",
 "code": "1| import { Contract, JsonRpcProvider, formatUnits, formatEther } from \"ethers\";\n2| import { CONFIG } from \"./config\";\n3| \n4| const ERC20_ABI = [\n... [abridged here: all 47 lines are shown decoded below]",
 "facts": "{\"lane\":\"patch\",\"language\":\"typescript\",\"line_count\":47,\"chains\":\"Ethereum, BNB Chain\",\"flags\":[{\"id\":\"D1\",... [abridged here: decoded below]",
 "question": "",
 "findings": "F1 [D1,D3,D4,D8] critical bridged_decimal_drift at line 36: sendStable always assumes 6 decimals via 1e6 and Math.round ... [abridged here: all 3 lines are shown decoded below]"
}

Its findings, decoded:

F1 [D1,D3,D4,D8] critical bridged_decimal_drift at line 36: sendStable always assumes 6 decimals via 1e6 and Math.round on a float, ignoring chainId/token entirely; tokenDecimals() (lines 17-18) reads the real decimals() on-chain but is never called from here. Fix: Call tokenDecimals(provider, token) keyed by (chainId, token) to get the real decimals, then build the amount with parseUnits(amountUsd.toString(), decimals) instead of Math.round(amountUsd*1e6); never assume 6.
F2 [D3,D5,D7] high symbol_keyed_decimals at line 30: usdValue looks up decimals purely by symbol, never by chainId, and silently falls back to 18 if the symbol is missing; the result is then run through parseFloat/formatUnits and multiplied by price as a float (line 31). Fix: Key decimals by (chainId, token address) via tokenDecimals(provider, token) instead of DECIMALS[symbol]; log when falling back to a default; avoid parseFloat on large raw balances - compare in BigInt/WAD before converting to a display float.
F3 [D6,D7] medium address_only_cache at line 15: decimalsCache is keyed by token address alone (lines 15,18,20,22); the same address can be a different token with different decimals on another chain. On failure (line 20) it silently caches 18 with nothing logged. Fix: Key decimalsCache by a composite chainId:token string; log a warning whenever the catch block defaults to 18; wire this function into usdValue and sendStable instead of the static DECIMALS table.

The reply, parsed from data.output.output, complete apart from patched_code, which is shown decoded after it. The verdict is safe with findings [], because in this lane both describe the PATCHED code; prescan_responses answer the flags against the original (D2 dismissed again). There are five changes (F1-F3, plus D3 and D5) and two helpers, and the one interface change, tokenDecimals gaining a chainId parameter, is stated in changes.

{
 "lane": "patch",
 "verdict": "safe",
 "headline": "Patched portfolio.ts: decimals are now read on-chain and cached per (chainId, token), amounts stay exact BigInt math end to end, and every fallback is logged, fixing the sends and USD values that were wrong by up to 10^12x on BNB Chain.",
 "tldr": [
  "sendStable no longer hardcodes 6 decimals: it reads tokenDecimals(provider, chainId, token) and builds the transfer amount with parseUnits on the exact decimal string, so BNB Chain's 18-decimal USDC/USDT no longer sends 10^12x too little.",
  "usdValue drops the symbol-keyed DECIMALS table; decimals come from tokenDecimals, the raw balance is normalised to an exact 18-decimal WAD via toWad, and only becomes a float once, at the final USD multiplication.",
  "decimalsCache is now keyed by chainId:tokenAddress instead of address alone, and a failed decimals() call logs a visible console.warn naming the chain and token before it falls back to 18.",
  "fmtNative keeps formatEther's 18-decimal assumption (correct for native ETH/BNB) but rounds to 4 display decimals with exact BigInt arithmetic instead of Number(...).toFixed(4)."
 ],
 "findings": [],
 "patched_scope": "file",
 "patched_code": ... [106 lines, shown decoded below] ...,
 "changes": [
  {
   "ref": "F1",
   "what": "sendStable reads real decimals via tokenDecimals(provider, chainId, token), with chainId taken from signer.provider.getNetwork() (no new parameter needed), and builds the transfer amount with the new usdToBaseUnits helper (parseUnits on the amount's decimal string) instead of BigInt(Math.round(amountUsd * 1e6)).",
   "why": "The old code always assumed 6 decimals and float-rounded the amount first; on BNB Chain, Binance-Peg USDC/BSC-USD are 18 decimals, so every send was off by 10^12x, and Math.round(amountUsd*1e6) itself is not exact for arbitrary floats."
  },
  {
   "ref": "F2",
   "what": "usdValue looks up decimals via tokenDecimals(provider, chainId, token) instead of the symbol-keyed DECIMALS table, normalises the raw balance to an exact 18-decimal WAD with toWad before any float appears, and converts to a float only once at the final USD multiplication.",
   "why": "DECIMALS[symbol] ignored chainId (same symbol, different decimals per chain) and silently defaulted to 18; parseFloat(formatUnits(raw, decimals)) also pushed a potentially large raw balance through a float before the fallback-corrupted decimals were even applied."
  },
  {
   "ref": "F3",
   "what": "decimalsCache is now keyed by decimalsCacheKey(chainId, token), a composite chainId:tokenAddress-lowercased string, instead of token address alone; the catch branch logs a console.warn naming the chainId and token before defaulting to 18; the lookup uses the in operator instead of a truthy check so a legitimate 0-decimal token cannot look uncached forever. tokenDecimals's public signature gains one new required parameter, chainId, to support this - the only interface change in this patch; usdValue and sendStable's own exported signatures are unchanged.",
   "why": "The old cache was keyed by address alone, so the same address on a different chain (a different token) would wrongly reuse a cached decimals value; the fallback to 18 was silent, so a broken decimals() call was invisible until amounts were already wrong."
  },
  {
   "ref": "D3",
   "what": "fmtNative still uses formatEther's 18-decimal assumption (correct for native ETH/BNB) but rounds to 4 decimals with exact BigInt arithmetic (scale by 10**14, round-half-up, split into whole and fractional parts) instead of Number(formatEther(raw)).toFixed(4).",
   "why": "Not part of findings F1-F3, but the browser's own D3 float_math flag lists this line, and the task warns the same patterns get re-scanned; Number(...).toFixed(4) round-trips an exact wei value through a float for no reason, so it is fixed too even though D2 (the 18-decimal assumption itself) was dismissed as correct."
  },
  {
   "ref": "D5",
   "what": "Removed the symbol-keyed DECIMALS lookup table entirely; nothing in the patched file selects decimals by symbol anymore.",
   "why": "D5/F2 flagged this table specifically: the same symbol has different decimals on different chains (USDC is 6 on Ethereum, 18 as Binance-Peg USDC on BNB Chain), so a symbol-keyed table can never be chain-correct."
  }
 ],
 "helpers": [
  {
   "name": "decimalsCacheKey",
   "code": "function decimalsCacheKey(chainId: number, token: string): string {\n  return `${chainId}:${token.toLowerCase()}`;\n}"
  },
  {
   "name": "usdToBaseUnits",
   "code": "function usdToBaseUnits(amountUsd: number, decimals: number): bigint {\n  const [whole, frac = \"\"] = amountUsd.toString().split(\".\");\n  const safeFrac = frac.length > decimals ? frac.slice(0, decimals) : frac;\n  const decimalString = safeFrac.length > 0 ? `${whole}.${safeFrac}` : whole;\n  return parseUnits(decimalString, decimals);\n}"
  }
 ],
 "tests": [
  {
   "name": "usdToBaseUnits builds an exact amount for a 6-decimal token",
   "given": "amountUsd=12.5, decimals=6 (e.g. Ethereum USDC) passed to sendStable/usdToBaseUnits",
   "expect": "base-unit amount 12500000n, i.e. parseUnits(12.5, 6), not BigInt(Math.round(12.5*1e6))"
  },
  {
   "name": "tokenDecimals cache is chain-aware",
   "given": "tokenDecimals(provider, 1, 0xTOKEN) then tokenDecimals(provider, 56, 0xTOKEN) for the same address on Ethereum and BNB Chain",
   "expect": "two separate on-chain decimals() calls and two cache entries, keyed 1:0xtoken and 56:0xtoken; the BNB Chain call never reuses Ethereum's cached decimals"
  },
  {
   "name": "usdToBaseUnits truncates instead of throwing on excess precision",
   "given": "amountUsd=12.3456789, decimals=6",
   "expect": "base-unit amount 12345678n (fractional part truncated to 6 digits, 12.345678), no fractional-component-exceeds-decimals error"
  },
  {
   "name": "usdValue prices a 6-decimal token correctly",
   "given": "chainId=1, raw balance=1000000n (1 USDC, decimals()=6), price=2.5",
   "expect": "usdValue returns 2.5, not 2.5e-12 or 2.5e12 from a wrong decimals guess"
  },
  {
   "name": "usdValue prices a bridged 18-decimal token correctly",
   "given": "chainId=56, raw balance=2000000000000000000n (2 Binance-Peg USDC, decimals()=18), price=1.0",
   "expect": "usdValue returns 2.0, because tokenDecimals reads 18 on-chain instead of assuming the Ethereum USDC value of 6"
  },
  {
   "name": "fmtNative rounds exactly with no float",
   "given": "raw=1234567890123456789n wei",
   "expect": "1.2346 (round-half-up to 4 decimals via BigInt, matching the intent of Number(formatEther(raw)).toFixed(4) without the float round-trip)"
  }
 ],
 "prescan_responses": [
  {
   "ref": "D1",
   "verdict": "confirmed",
   "note": "Line 36 builds the transfer amount from a hardcoded 1e6, ignoring tokenDecimals() (lines 17-18), which is defined but never called from sendStable."
  },
  {
   "ref": "D2",
   "verdict": "dismissed",
   "note": "Line 46's formatEther assumes 18 decimals, which is correct: fmtNative formats native ETH/BNB balances, not an ERC-20 token, and both chains in facts.chains use 18-decimal native coins. The real problem at line 46 is the trailing Number(...).toFixed(4), covered under D3."
  },
  {
   "ref": "D3",
   "verdict": "confirmed",
   "note": "Line 31 (parseFloat(formatUnits(...))*price), line 36 (Math.round(amountUsd*1e6)) and line 46 (Number(formatEther(raw)).toFixed(4)) all push amounts through float math; lines 31 and 36 are genuine amount-corrupting bugs, line 46 is lower-risk display formatting but still rewritten to avoid the pattern."
  },
  {
   "ref": "D4",
   "verdict": "confirmed",
   "note": "BigInt(Math.round(amountUsd * 1e6)) at line 36 multiplies a float by 1e6 before rounding, which is not exact for arbitrary decimal inputs; the decimal string should be parsed with parseUnits instead."
  },
  {
   "ref": "D5",
   "verdict": "confirmed",
   "note": "DECIMALS[symbol] at line 30 (table defined at line 10) picks decimals by symbol only; USDC is 6 decimals on Ethereum but 18 as Binance-Peg USDC on BNB Chain, so the same symbol string resolves to the wrong decimals depending on chain."
  },
  {
   "ref": "D6",
   "verdict": "confirmed",
   "note": "decimalsCache (lines 12, 15, 18, 20, 22) is keyed by token address alone; the same address can be a different token with different decimals on another chain, so a cached value would be wrongly reused across chains."
  },
  {
   "ref": "D7",
   "verdict": "confirmed",
   "note": "The catch block at line 20 and the ?? 18 at line 30 both default to 18 decimals with nothing logged; a 6-decimal token misread this way would show balances 10^12 times too small with no trace of why."
  },
  {
   "ref": "D8",
   "verdict": "confirmed",
   "note": "The DECIMALS table (line 10) and sendStable's hardcoded 1e6 (line 36) both assume USDC/USDT are 6 decimals everywhere; on BNB Chain (named in facts.chains), Binance-Peg USDC and BSC-USD are 18 decimals as of 2026-09-26 per facts.known_decimals, so both sites are off by 10^12 there."
  }
 ]
}

Its patched_code, decoded (106 lines):

import { Contract, JsonRpcProvider, formatUnits, parseUnits } from "ethers";
import { CONFIG } from "./config";

const ERC20_ABI = [
  "function balanceOf(address) view returns (uint256)",
  "function transfer(address to, uint256 amount) returns (bool)",
];

// Decimals cache keyed by "chainId:tokenAddress" (address lowercased). The
// same address can be a completely different token with different decimals
// on another chain, so the chain id must be part of the key - never cache,
// or look up, by symbol or by address alone.
const decimalsCache: Record<string, number> = {};

function decimalsCacheKey(chainId: number, token: string): string {
  return `${chainId}:${token.toLowerCase()}`;
}

export async function tokenDecimals(provider: JsonRpcProvider, chainId: number, token: string): Promise<number> {
  const key = decimalsCacheKey(chainId, token);
  if (key in decimalsCache) return decimalsCache[key];
  try {
    const c = new Contract(token, ["function decimals() view returns (uint8)"], provider);
    const d = Number(await c.decimals());
    decimalsCache[key] = d;
    return d;
  } catch (err) {
    // Visible fallback: a failed decimals() call must never silently become
    // 18 with no trace. Log the chain and token so a 6-decimal token that
    // fell back to 18 (or vice versa) is discoverable, not just wrong.
    console.warn(
      `tokenDecimals: decimals() failed for chainId=${chainId} token=${token}; falling back to 18 decimals`,
      err,
    );
    decimalsCache[key] = 18;
    return 18;
  }
}

// chainId 1 = Ethereum, 56 = BNB Chain
export async function usdValue(chainId: number, symbol: string, token: string, wallet: string, price: number): Promise<number> {
  const provider = new JsonRpcProvider(CONFIG.rpc[chainId]);
  const erc20 = new Contract(token, ERC20_ABI, provider);
  const raw: bigint = await erc20.balanceOf(wallet);
  // `symbol` is kept for interface compatibility (and is useful for callers'
  // own logging) but is no longer used to pick decimals: the same symbol can
  // mean different decimals on different chains (e.g. USDC is 6 on Ethereum
  // but 18 as Binance-Peg USDC on BNB Chain). Decimals always come from an
  // on-chain decimals() call keyed by (chainId, token address).
  const decimals = await tokenDecimals(provider, chainId, token);
  // Normalise to an exact 18-decimal (WAD) BigInt first - no floats yet.
  const wad = toWad(raw, decimals);
  // Convert to a display number only here, at the very end, to produce the
  // USD amount the caller asked for. This is the single unavoidable float
  // step (the return type is `number` and `price` is itself an approximate
  // market price); the token amount itself stayed exact all the way through.
  const humanAmount = Number(formatUnits(wad, 18));
  return humanAmount * price;
}

// Converts a human amount (a float, as callers naturally provide "12.5
// dollars") into an exact base-unit BigInt for the token's real decimals.
// Works on the decimal STRING, never by multiplying the float by 10**decimals
// (that is what produced the old Math.round(amountUsd * 1e6) rounding bugs).
// Excess fractional digits beyond `decimals` are truncated (not rounded) so a
// caller-supplied float with more precision than the token supports can never
// throw or silently overcharge.
function usdToBaseUnits(amountUsd: number, decimals: number): bigint {
  const [whole, frac = ""] = amountUsd.toString().split(".");
  const safeFrac = frac.length > decimals ? frac.slice(0, decimals) : frac;
  const decimalString = safeFrac.length > 0 ? `${whole}.${safeFrac}` : whole;
  return parseUnits(decimalString, decimals);
}

export async function sendStable(token: string, to: string, amountUsd: number, signer: any) {
  const provider = signer.provider;
  if (!provider) {
    throw new Error("sendStable: signer must be connected to a provider so real decimals can be read");
  }
  const { chainId } = await provider.getNetwork();
  // Never assume stablecoins are 6 decimals: BNB Chain's Binance-Peg USDC and
  // BSC-USD (USDT) are 18 decimals, so a hardcoded 6 is off by 10^12 there.
  const decimals = await tokenDecimals(provider, Number(chainId), token);
  const erc20 = new Contract(token, ERC20_ABI, signer);
  const amount = usdToBaseUnits(amountUsd, decimals);
  return erc20.transfer(to, amount);
}

export function toWad(raw: bigint, decimals: number): bigint {
  if (decimals > 18) return raw / 10n ** BigInt(decimals - 18);
  return raw * 10n ** BigInt(18 - decimals);
}

export function fmtNative(raw: bigint): string {
  // ETH and BNB (the only native coins in play here) are both 18 decimals, so
  // the 18-decimal assumption itself is correct. The bug was the trailing
  // Number(...).toFixed(4), which round-tripped an exact wei value through a
  // float. Round to 4 display decimals with exact BigInt arithmetic instead.
  const displayDecimals = 4;
  const scale = 10n ** BigInt(18 - displayDecimals);
  const roundedUnits = (raw + scale / 2n) / scale; // round-half-up, exact
  const base = 10n ** BigInt(displayDecimals);
  const whole = roundedUnits / base;
  const frac = roundedUnits % base;
  return `${whole}.${frac.toString().padStart(displayDecimals, "0")}`;
}

Holding it to the page's checks is instructive. check-reply.js reports 0 disagreements and one item to review, from the re-scan: 4 of the 7 confirmed patterns are gone (float_to_integer, symbol_keyed_decimals, address_only_cache, bridged_decimal_drift), but hardcoded_decimals and float_math still match line 57, const humanAmount = Number(formatUnits(wad, 18)); (the 18 is the WAD scale toWad just produced, and the comment above it calls this the single float step before the number return), and silent_fallback still matches line 35, decimalsCache[key] = 18;, whose console.warn starts four lines above it, outside the two-line window the prescan looks in. The page shows the same note and asks you to decide whether each match is a real leftover or a pattern the rewrite now uses deliberately. That is the point of the re-scan: a patch is a proposal, and you review it before you apply it.

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and reports truncated: true, in the done event of /run-stream and on the job from GET /jobs/{id}. What you hold is then a prefix of the reply, and it will not parse as it stands. The web page closes the cut-off JSON (Recon.closeJson in recon.js: close an open string, drop a dangling comma, give a dangling key null, close every open array and object, and if that still does not parse, cut back to the previous comma and try again), parses what is left, and shows the sections that arrived as "N of M sections recovered", out of the lane's 9 keys (11 for patch). A stream ended early error gets the same treatment on the deltas received so far.

The keys arrive in contract order, so a cut usually costs the tail: tests and prescan_responses go first, and in the patch lane a cut inside patched_code leaves you half a file. A truncated audit can therefore look complete while flags are unanswered, and a truncated patch must never be applied: check the flag, top up, and resubmit with the attempt suffix on the Idempotency-Key incremented (wei-desk:decimals:<hash>:a2).

If a complete reply will not parse as one JSON object, the page retries once, as the next attempt, with a retry_note saying what was wrong: "Your previous reply was not the single valid JSON object the instructions require (the parse error). Reply again with ONLY the JSON object for task 'lane' - no prose, no code fences; every key present (empty arrays where there is nothing to say)." Do the same: keep the hash, bump the attempt, add retry_note. It never retries a truncated reply that way; that one needs credits, not a reformat.