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.
| task | what it is for | lane body in the reply |
|---|---|---|
decimals | Audit 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. |
hashing | Audit 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. |
patch | Rewrite 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.
| field | type | required | what it holds |
|---|---|---|---|
task | string | yes | "decimals", "hashing" or "patch"; see the lanes. |
name | string | no | The file name ("portfolio.ts"). Context only. Cut at 160 characters. |
language | string | no | typescript, javascript, python, solidity or other. The page detects it when the form says auto. |
chains | string | no | The 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. |
code | string | yes | The 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 ...]. |
facts | string | yes | A 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. |
findings | string | patch only | The 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. |
question | string | no | Your 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_note | string | no | Only 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.
| key | what it holds |
|---|---|
lane, language, line_count, chains | The lane the prescan ran for (match it to task), the language, the number of lines in the whole file, and the chains as sent. |
flags | Every 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_verdict | The 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_calls | How many lines read decimals(). |
hardcoded_decimal_lines | Up to 20 line numbers with a literal 18, 6 or 8 decimals scale. |
symbols_seen | Well-known token symbols in the code (USDC, USDT, DAI, WBTC, WETH, BUSD, USDC.e, USDbC). |
known_decimals, known_decimals_as_of | For 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. |
signatures | Up 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_sites | Up to 40 lines that look like a hash call, as {line, algorithm_guess} (keccak256, nist_sha3_256, sha256, unknown). |
clipped | null, or {total_lines, dropped_lines, first_dropped, last_dropped} when the middle of the file was cut from code. |
| flag category | severity | what the prescan matched |
|---|---|---|
hardcoded_decimals | high, 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_tokens | medium with ERC-20 work, else low | parseEther, formatEther, toWei, fromWei and their Python forms. |
float_math | high | Number, parseFloat, float, toFixed or Math.* on an amount. |
float_to_integer | high | An integer built from a float times 1e.. (BigInt(Math.round(x * 1e6))). |
decimal_from_float | medium | Decimal(0.1) or Decimal(float(..)). |
symbol_keyed_decimals | high | A decimals table keyed by token symbol. |
address_only_cache | medium | A decimals cache keyed by token address without the chain. |
silent_fallback | medium, or low when the fallback is logged | Decimals falling back to 18 (?? 18, return 18). |
no_runtime_lookup | high with hardcoded decimals or ether helpers, else medium | ERC-20 work (balanceOf, transfer, approve) with no decimals() call anywhere. |
bridged_decimal_drift | high | BNB Chain in play and USDC/USDT scaled by 6. |
downscale_truncation | low | Integer division down to fewer decimals, dropping dust. |
divide_before_multiply | medium | Division by 10^decimals before a multiplication by a price. |
nist_sha3 | high | createHash('sha3-256'), hashlib.sha3_256, sha3_256(..). |
sha256_in_ethereum_context | medium | SHA-256 within three lines of selectors, topics, slots, addresses, EIP-712 or Merkle work. |
web3_sha3_name | low | web3.utils.sha3 (Keccak-256, but undefined for empty input). |
hex_hashed_as_text | medium | A hex string or address passed through toUtf8Bytes (or similar) next to a hash. |
packed_dynamic_collision | medium | A packed encoding of two or more string or bytes values. |
non_canonical_signature | high | A 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.
| symbol | chain | chain id | address | decimals |
|---|---|---|---|---|
| USDC | Ethereum | 1 | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | 6 |
| USDT | Ethereum | 1 | 0xdAC17F958D2ee523a2206206994597C13D831ec7 | 6 |
| WBTC | Ethereum | 1 | 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 | 8 |
| DAI | Ethereum | 1 | 0x6B175474E89094C44Da98b954EedeAC495271d0F | 18 |
| USDC | BNB Chain (Binance-Peg) | 56 | 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d | 18 |
| USDT | BNB Chain (BSC-USD) | 56 | 0x55d398326f99059fF775485246999027B3197955 | 18 |
| USDC | Arbitrum One (native) | 42161 | 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 | 6 |
| USDC | Polygon PoS (native) | 137 | 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 | 6 |
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 field | what it holds |
|---|---|
lane | decimals, hashing or patch (anything else is analyzed as decimals). Becomes task, and picks which flags go into facts.flags. |
name | The file name. |
language | A language, or auto (or empty) to detect it from the code. |
chains | The chains the code runs on, as written. |
code | The 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
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The 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. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A 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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "wei-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://wei-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "wei-desk";
// Paste the token from https://wei-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "wei-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://wei-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class WeiDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "wei-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "wei-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://wei-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "wei-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class WeiDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "wei-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
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"}}
# Open https://wei-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "wei-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://wei-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "wei-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://wei-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"wei-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://wei-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"wei-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://wei-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "wei-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://wei-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "wei-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://wei-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"wei-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await WeiDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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.
INPUT = json.load(open("body.json")) # built by make-body.js above, or by hand
assert isinstance(INPUT, dict) and INPUT.get("task") in ("decimals", "hashing", "patch")
assert all(isinstance(v, str) for v in INPUT.values())
assert all(INPUT.get(k, "").strip() for k in ("code", "facts"))
assert isinstance(json.loads(INPUT["facts"]), dict) # facts is a JSON STRING
est = call("estimate", INPUT)
print(est["model_alias"], est["markup_bps"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // built by make-body.js above
if (!INPUT || typeof INPUT !== "object" || !["decimals", "hashing", "patch"].includes(INPUT.task)) throw new Error("task must be decimals, hashing or patch");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
for (const k of ["code", "facts"]) if (!(INPUT[k] || "").trim()) throw new Error(k + " is required");
JSON.parse(INPUT.facts); // throws unless facts is a JSON string
const est = await call("estimate", INPUT);
console.log(est.model_alias, est.markup_bps, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // built by make-body.js above
var input map[string]string // every field is a string, facts included
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
if input["task"] != "decimals" && input["task"] != "hashing" && input["task"] != "patch" {
panic("task must be decimals, hashing or patch")
}
for _, k := range []string{"code", "facts"} {
if strings.TrimSpace(input[k]) == "" {
panic(k + " is required")
}
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string holding an object")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.js above
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(decimals|hashing|patch)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task decimals, hashing or patch");
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(decimals|hashing|patch)\".*", "$1");
for (String k : new String[] {"code", "facts"})
if (!input.contains("\"" + k + "\"")) throw new IllegalStateException(k + " is required");
String est = call("estimate", input);
System.out.println(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.js above
raise "task must be decimals, hashing or patch" unless %w[decimals hashing patch].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
%w[code facts].each { |k| raise "#{k} is required" if INPUT[k].to_s.strip.empty? }
raise "facts must hold an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
est = call("estimate", INPUT)
puts est["model_alias"], est["markup_bps"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.js above
if (!is_array($input) || !in_array($input["task"] ?? "", ["decimals", "hashing", "patch"], true)) { throw new Exception("task must be decimals, hashing or patch"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
foreach (["code", "facts"] as $k) { if (trim($input[$k] ?? "") === "") { throw new Exception("$k is required"); } }
if (!is_array(json_decode($input["facts"], true))) { throw new Exception("facts must be a JSON string"); }
$est = call("estimate", $input);
echo $est["model_alias"], " ", $est["markup_bps"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.js above
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "decimals" && lane != "hashing" && lane != "patch") throw new Exception("task must be decimals, hashing or patch");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception($"{p.Name} must be a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // facts is a JSON string
var est = await WeiDesk.Call("estimate", root);
Console.WriteLine(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
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
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"wei-desk:{INPUT['task']}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `wei-desk:${INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("wei-desk:%s:%x:a1", input["task"], sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "wei-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "wei-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "wei-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"wei-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await WeiDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
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}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
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
import re
t = re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=re.I)
t = re.sub(r"\s*```\s*$", "", t)
reply = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(reply["lane"], reply["verdict"], reply["headline"])
print([(f["id"], f["severity"], f["ref"], f["line"]) for f in reply["findings"]])
if reply["lane"] == "decimals":
print([(p["name"], p["decimals_source"], p["math"]) for p in reply["amount_paths"]], reply["cache"]["keyed_by"])
elif reply["lane"] == "hashing":
print([(h["line"], h["algorithm"], h["ok"]) for h in reply["hash_sites"]])
print([(s["canonical"], s["ok"]) for s in reply["signatures"]])
else:
print(reply["patched_scope"], len(reply["patched_code"].splitlines()), "lines")
print([(c["ref"], c["what"]) for c in reply["changes"]])
let t = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");
const reply = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
console.log(reply.lane, reply.verdict, reply.headline);
console.log(reply.findings.map((f) => `${f.id} ${f.severity} [${f.ref}] L${f.line}: ${f.impact}`));
if (reply.lane === "decimals") console.log(reply.amount_paths.map((p) => [p.name, p.decimals_source, p.math]), reply.cache.keyed_by);
else if (reply.lane === "hashing") console.log(reply.hash_sites.map((h) => [h.line, h.algorithm, h.ok]), reply.signatures.map((s) => [s.canonical, s.ok]));
else console.log(reply.patched_scope, reply.patched_code.split("\n").length, reply.changes.map((c) => [c.ref, c.what]));
t := strings.TrimSpace(jobOutput)
t = strings.TrimPrefix(strings.TrimPrefix(t, "```json"), "```")
t = strings.TrimSuffix(strings.TrimSpace(t), "```")
start, end := strings.Index(t, "{"), strings.LastIndex(t, "}")
var reply struct {
Lane string `json:"lane"`
Verdict string `json:"verdict"`
Headline string `json:"headline"`
TLDR []string `json:"tldr"`
Findings []struct {
ID, Ref, Severity, Category string
Line int
Code, Problem, Impact, Fix string
} `json:"findings"`
// decimals lane
AmountPaths []struct {
Name string `json:"name"`
DecimalsSource string `json:"decimals_source"`
Math string `json:"math"`
ChainAware string `json:"chain_aware"`
} `json:"amount_paths"`
Cache struct {
KeyedBy string `json:"keyed_by"`
} `json:"cache"`
// hashing lane
HashSites []struct {
Line int
Algorithm, Purpose, OK string
} `json:"hash_sites"`
Signatures []struct {
Given, Canonical, OK string
} `json:"signatures"`
// patch lane
PatchedScope string `json:"patched_scope"`
PatchedCode string `json:"patched_code"`
Changes []struct {
Ref, What, Why string
} `json:"changes"`
}
if err := json.Unmarshal([]byte(t[start:end+1]), &reply); err != nil {
panic(err)
}
fmt.Println(reply.Lane, reply.Verdict, reply.Headline)
for _, f := range reply.Findings {
fmt.Println(f.ID, f.Severity, f.Ref, f.Line, f.Impact)
}
switch reply.Lane {
case "decimals":
fmt.Println(len(reply.AmountPaths), "amount paths; cache keyed by", reply.Cache.KeyedBy)
case "hashing":
fmt.Println(len(reply.HashSites), "hash sites,", len(reply.Signatures), "signatures")
case "patch":
fmt.Println(reply.PatchedScope, len(reply.Changes), "changes")
}
// output is data.output.output from step 5: a string holding the reply JSON.
String t = output.strip().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("\\s*```\\s*$", "");
String json = t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1);
// With Jackson: JsonNode r = new ObjectMapper().readTree(json);
// r.get("lane"): decimals | hashing | patch; r.get("verdict"): broken | at_risk | safe
// r.get("findings"), r.get("tests"), r.get("prescan_responses")
// decimals: r.get("amount_paths"), r.get("cache") hashing: r.get("hash_sites"), r.get("signatures")
// patch: r.get("patched_scope"), r.get("patched_code"), r.get("changes"), r.get("helpers")
System.out.println(json);
t = text.strip.sub(/\A```(?:json)?\s*/i, "").sub(/\s*```\s*\z/, "")
reply = JSON.parse(t[t.index("{")..t.rindex("}")])
puts reply["lane"], reply["verdict"], reply["headline"]
reply["findings"].each { |f| puts "#{f['id']} #{f['severity']} [#{f['ref']}] L#{f['line']}: #{f['impact']}" }
case reply["lane"]
when "decimals"
reply["amount_paths"].each { |p| puts "#{p['name']} #{p['decimals_source']} #{p['math']}" }
puts "cache: #{reply['cache']['keyed_by']}"
when "hashing"
reply["hash_sites"].each { |h| puts "L#{h['line']} #{h['algorithm']} #{h['purpose']} ok=#{h['ok']}" }
reply["signatures"].each { |s| puts "#{s['ok']} #{s['canonical']}" }
else
File.write("patched.txt", reply["patched_code"])
reply["changes"].each { |c| puts "#{c['ref']} #{c['what']}" }
end
<?php
$t = preg_replace('/\s*```\s*$/', "", preg_replace('/^```(?:json)?\s*/i', "", trim($text)));
$reply = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
echo $reply["lane"], " ", $reply["verdict"], " ", $reply["headline"], PHP_EOL;
foreach ($reply["findings"] as $f) { echo $f["id"], " ", $f["severity"], " [", $f["ref"], "] L", $f["line"], ": ", $f["impact"], PHP_EOL; }
if ($reply["lane"] === "decimals") {
foreach ($reply["amount_paths"] as $p) { echo $p["name"], " ", $p["decimals_source"], " ", $p["math"], PHP_EOL; }
echo "cache: ", $reply["cache"]["keyed_by"], PHP_EOL;
} elseif ($reply["lane"] === "hashing") {
foreach ($reply["hash_sites"] as $h) { echo "L", $h["line"], " ", $h["algorithm"], " ok=", $h["ok"], PHP_EOL; }
foreach ($reply["signatures"] as $s) { echo $s["ok"], " ", $s["canonical"], PHP_EOL; }
} else {
file_put_contents("patched.txt", $reply["patched_code"]);
foreach ($reply["changes"] as $c) { echo $c["ref"], " ", $c["what"], PHP_EOL; }
}
var t = System.Text.RegularExpressions.Regex.Replace(output.Trim(), @"^```(?:json)?\s*", "");
t = System.Text.RegularExpressions.Regex.Replace(t, @"\s*```\s*$", "");
var json2 = t.Substring(t.IndexOf('{'), t.LastIndexOf('}') - t.IndexOf('{') + 1);
using var parsed = JsonDocument.Parse(json2);
var r = parsed.RootElement;
Console.WriteLine($"{r.GetProperty("lane")} {r.GetProperty("verdict")} {r.GetProperty("headline")}");
foreach (var f in r.GetProperty("findings").EnumerateArray())
Console.WriteLine($"{f.GetProperty("id")} {f.GetProperty("severity")} [{f.GetProperty("ref")}] L{f.GetProperty("line")}: {f.GetProperty("impact")}");
switch (r.GetProperty("lane").GetString())
{
case "decimals": Console.WriteLine(r.GetProperty("cache").GetProperty("keyed_by")); break;
case "hashing": Console.WriteLine(r.GetProperty("signatures").GetArrayLength() + " signatures"); break;
case "patch": File.WriteAllText("patched.txt", r.GetProperty("patched_code").GetString()); break;
}
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:
- It parses: the reply is one JSON object (after stripping any fence), and every key of the lane's contract is present: 9 keys for
decimalsandhashing, 11 forpatch. - Lane:
laneequals thetaskyou sent. A different lane means the model answered another contract. - Every flag answered once: every flag in
facts.flagshas exactly oneprescan_responsesentry (arefmay list several ids, comma-separated); no response names a flag that was not sent; everyverdictisconfirmedordismissed, and a dismissal cites the line that shows the flag is wrong (read it and decide whether you agree). - Confirmed high/medium flags are carried: in
decimalsandhashing, everyhighormediumflag left standing (not dismissed) appears in therefof a finding. Inpatch, in therefof a change or of a remaining finding. - Verdict floor: one of
broken,at_risk,safe; indecimalsandhashingnever looser than the flags left standing (anyhighmeansbroken, else anymediummeans at leastat_risk). In every lane it matches the worst finding:criticalorhighmeansbroken,mediummeansat_risk, otherwisesafe. A patch withfindings[]is thereforesafe. - Quotes are on the cited lines: every non-empty
codeinfindingsandhash_sitesappears, whitespace ignored, on itsline(or the line next to it) of your original code, and nolineis past the end of the file.line: 0withcode: ""is how the model says it cannot point at a line. - No invented digests: every 8- or 64-hex-digit
0xvalue in the prose (anything butcodeandpatched_code) is infacts.signatures(selector,topic,nist_sha3_of_canonical,selector_of_given) or in your code. The model cannot run Keccak; a digest from anywhere else is made up. - Signatures copied exactly (hashing): one
signaturesentry perfacts.signaturesentry, withgivenandcanonicalcopied character for character andok"yes"exactly whencanonical_okis true. A hash site on a line the prescan saw calling SHA3-256 must be reported asnist_sha3_256. - Patch re-scan:
patched_codeis not empty andchangesis not empty. Run the prescan again onpatched_code: no new category may appear, and apatched_scopeoffileshould not be less than half the original's lines (the page counts both as disagreements). A confirmedhighormediumcategory that still matches is shown as an item to review, not a disagreement: it is either a real leftover or a pattern the rewrite now uses deliberately, and only a reader can tell which.
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":"..."}]}
| key | shape | what it holds |
|---|---|---|
lane | string | The lane answered: your task, or the closest lane when task was missing or unknown. |
verdict | enum | See the next table. In the patch lane it describes the patched code. |
headline | string | One sentence on the state of the code. |
tldr | array of strings | 2-5 bullets. When you sent a question, one bullet starts "Answer:". |
findings | array of objects | Ids 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. |
tests | array 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_responses | array 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":"..."}}
| key | what it holds |
|---|---|
amount_paths | One 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. |
cache | How 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":"..."}]}
| key | what it holds |
|---|---|
hash_sites | One 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. |
signatures | One 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":"..."}]}
| key | what it holds |
|---|---|
patched_scope | file: 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_code | The 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. |
changes | One per fix; ref is the finding id (F2) or flag id it resolves. |
helpers | Small reusable functions the patch introduced (a chain-keyed decimals cache, a WAD normaliser), also present inside patched_code; [] if none. |
Verdict and severity
| value | meaning |
|---|---|
broken | Any finding is critical or high. |
at_risk | The worst finding is medium. |
safe | Nothing worse than low. |
critical finding | Funds are sent, signed or priced wrong: a transfer amount off by a power of ten, a signature or selector that will never match. |
high finding | A value shown or stored wrong by orders of magnitude, or a hash that is always wrong. |
medium finding | Wrong only for some tokens, chains or inputs, or precision silently lost. |
low finding | Fragile 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
| where | values | the page's fallback |
|---|---|---|
lane | decimals, hashing, patch | the lane asked |
verdict | broken, at_risk, safe | derived from the worst finding |
findings[].severity | critical, high, medium, low | medium |
prescan_responses[].verdict | confirmed, dismissed | confirmed |
amount_paths[].decimals_source | runtime, hardcoded, symbol_table, fallback, none, unknown | unknown |
amount_paths[].math | exact, float, mixed, unknown | unknown |
amount_paths[].chain_aware | yes, no, unknown | unknown |
cache.keyed_by | chain_and_address, address_only, symbol, none, not_applicable | not_applicable |
hash_sites[].algorithm | keccak256, nist_sha3_256, sha256, other, unknown | unknown |
hash_sites[].purpose | selector, event_topic, typehash, storage_slot, address, merkle, signature, id, other | other |
hash_sites[].ok | yes, no, unclear | unclear |
signatures[].ok | yes, no | no |
patched_scope | file, excerpts | file |
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.