From 7186d71821da8f4a83f222c9b93cbcf6668166e9 Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Fri, 6 Nov 2026 12:00:03 +0530 Subject: [PATCH] fix(arkiv): exact u64s on chain, panel shows only the latest draw, sidecar accepts keys without 0x Found by running the full loop on the live site for the first time: - Seeds landed on Rabdi rounded: 1065256257932064655 (Postgres) became 1065256257932064640 on chain, because the Node sidecar's JSON.parse rounds integers past 2^53. That breaks replaying the draw from the Arkiv copy. decision_id, seed and budget_micros now go to the sidecar as decimal strings, and the sidecar refuses a u64 that arrives as a JSON number. - The panel compared every draw on a plan with the picks on screen, so each older explore draw showed "does not match the picks above", which reads as tampering. It now shows only the newest draw (one draw's rows share one Postgres transaction, so one decided_at), in task order, counts the rest, and only checks matches when that draw's seed is the one on screen. - The first live deploy crash-looped because MetaMask exports private keys without 0x. The sidecar now accepts either form and names the problem when the key is invalid. Verified: the sidecar against the real Rabdi RPC (no-0x key starts; numeric seed refused; string seed reaches gas estimation); 557 + 450 control-plane tests, ruff/pyright; 510 vitest, tsc, eslint, prettier, build. --- chain/arkiv/src/server.js | 61 ++++++++++++------- control-plane/mergit_cp/arkiv_mirror.py | 9 ++- control-plane/tests/test_arkiv_mirror.py | 18 +++++- control-plane/tests/test_routes_routing.py | 9 ++- .../src/screens/live/run/SuggestAgents.tsx | 19 +++++- .../src/screens/live/run/arkivDraw.test.ts | 38 ++++++++++++ frontend/src/screens/live/run/arkivDraw.ts | 19 ++++++ 7 files changed, 140 insertions(+), 33 deletions(-) create mode 100644 frontend/src/screens/live/run/arkivDraw.test.ts create mode 100644 frontend/src/screens/live/run/arkivDraw.ts diff --git a/chain/arkiv/src/server.js b/chain/arkiv/src/server.js index a7506239..6e4cc60c 100644 --- a/chain/arkiv/src/server.js +++ b/chain/arkiv/src/server.js @@ -22,7 +22,24 @@ const TOKEN = process.env.ARKIV_SIDECAR_TOKEN; if (!PRIVATE_KEY) throw new Error("ARKIV_PRIVATE_KEY is required"); if (!TOKEN) throw new Error("ARKIV_SIDECAR_TOKEN is required"); -const account = privateKeyToAccount(PRIVATE_KEY); +// MetaMask's "Show private key" exports 64 hex characters without the 0x prefix viem requires; the +// first live deploy crash-looped on exactly that. Accept either form, and say clearly what is wrong +// with anything else instead of dying on viem's own error. +const key = PRIVATE_KEY.trim().startsWith("0x") ? PRIVATE_KEY.trim() : `0x${PRIVATE_KEY.trim()}`; +if (!/^0x[0-9a-fA-F]{64}$/.test(key)) { + throw new Error("ARKIV_PRIVATE_KEY must be 64 hex characters, with or without a 0x prefix"); +} +const account = privateKeyToAccount(key); + +// u64 fields must arrive as decimal strings. A JSON number past 2^53 has already been rounded by +// JSON.parse before this code sees it (a 62-bit seed landed on chain 15 off), and a wrong seed makes +// the draw impossible to replay, so refuse it rather than store it. +function u64Of(value, name) { + if (typeof value !== "string" || !/^\d+$/.test(value)) { + throw new TypeError(`${name} must be a decimal string, got ${JSON.stringify(value)}`); + } + return u64(BigInt(value)); +} const walletClient = createWalletClient({ account, chain: rabdi({ accessKey: ACCESS_KEY }), @@ -80,27 +97,29 @@ const server = createServer(async (req, res) => { return; } + let creates; + try { + creates = decisions.map((d) => ({ + schema: RoutingDecision, + values: { + planId: d.plan_id, + decisionId: u64Of(d.decision_id, "decision_id"), + taskIndex: d.task_index, + category: d.category, + seed: u64Of(d.seed, "seed"), + budgetMicros: u64Of(d.budget_micros, "budget_micros"), + decidedAt: d.decided_at, + body: new TextEncoder().encode(JSON.stringify({ candidates: d.candidates, picks: d.picks })), + }, + expiration: ExpirationTime.fromDays(ttlDays ?? 30), + })); + } catch (err) { + send(res, 400, { error: String(err?.message ?? err) }); + return; + } + try { - const { txHash, createdEntities } = await enqueue(() => - walletClient.executeBatch({ - creates: decisions.map((d) => ({ - schema: RoutingDecision, - values: { - planId: d.plan_id, - decisionId: u64(BigInt(d.decision_id)), - taskIndex: d.task_index, - category: d.category, - seed: u64(BigInt(d.seed)), - budgetMicros: u64(BigInt(d.budget_micros)), - decidedAt: d.decided_at, - body: new TextEncoder().encode( - JSON.stringify({ candidates: d.candidates, picks: d.picks }), - ), - }, - expiration: ExpirationTime.fromDays(ttlDays ?? 30), - })), - }), - ); + const { txHash, createdEntities } = await enqueue(() => walletClient.executeBatch({ creates })); // `createdEntities` is already `Hex[]` (the SDK's own type), not objects with a `.key` — an // earlier version mapped `.key` off each string here, which silently produced `[null, ...]`. send(res, 200, { tx_hash: txHash, entity_keys: createdEntities }); diff --git a/control-plane/mergit_cp/arkiv_mirror.py b/control-plane/mergit_cp/arkiv_mirror.py index f3eebb86..0a0bb374 100644 --- a/control-plane/mergit_cp/arkiv_mirror.py +++ b/control-plane/mergit_cp/arkiv_mirror.py @@ -109,11 +109,14 @@ async def write_decisions(self, plan_id: str, decisions: list[Decision]) -> None "decisions": [ { "plan_id": plan_id, - "decision_id": d.decision_id, + # u64s go as decimal strings: the sidecar is Node, whose JSON.parse rounds integers past + # 2^53 to the nearest double. A 62-bit seed came out of it 15 off on the live chain, + # which breaks replaying the draw from the Arkiv copy. + "decision_id": str(d.decision_id), "task_index": d.task_index, "category": d.category, - "seed": d.seed, - "budget_micros": d.budget_micros, + "seed": str(d.seed), + "budget_micros": str(d.budget_micros), "decided_at": d.decided_at, "candidates": d.candidates, "picks": d.picks, diff --git a/control-plane/tests/test_arkiv_mirror.py b/control-plane/tests/test_arkiv_mirror.py index 0740b0e4..24c1a406 100644 --- a/control-plane/tests/test_arkiv_mirror.py +++ b/control-plane/tests/test_arkiv_mirror.py @@ -76,14 +76,26 @@ def test_write_decisions_sends_one_batched_call(mirror: ArkivMirror): sent = json.loads(route.calls[0].request.content) assert sent["ttl_days"] == 30 assert [d["task_index"] for d in sent["decisions"]] == [0, 1] - assert [d["decision_id"] for d in sent["decisions"]] == [100, 101] + assert [d["decision_id"] for d in sent["decisions"]] == ["100", "101"] assert all(d["plan_id"] == "plan-1" for d in sent["decisions"]) - assert all(d["seed"] == 123456789 for d in sent["decisions"]) - assert all(d["budget_micros"] == 500_000 for d in sent["decisions"]) + assert all(d["seed"] == "123456789" for d in sent["decisions"]) + assert all(d["budget_micros"] == "500000" for d in sent["decisions"]) assert all("requester" not in d for d in sent["decisions"]) assert route.calls[0].request.headers["authorization"] == "Bearer sidecar-secret" +def test_write_decisions_sends_a_62_bit_seed_exactly(mirror: ArkivMirror): + """The live failure: seed 1065256257932064655 landed on Rabdi as 1065256257932064640, because the + Node sidecar's JSON.parse rounds integers past 2^53. Sent as a string, nothing on the way can.""" + seed = 1065256257932064655 + assert int(float(seed)) != seed # a JSON number really would lose it + with respx.mock(assert_all_called=True) as mock: + route = mock.post(f"{SIDECAR}/internal/mirror").respond(json={"tx_hash": "0x", "entity_keys": []}) + asyncio.run(mirror.write_decisions("plan-1", [_decision(0, seed=seed)])) + raw = route.calls[0].request.content.decode() + assert '"seed": "1065256257932064655"' in raw or '"seed":"1065256257932064655"' in raw + + def test_write_decisions_raises_when_no_sidecar_is_configured(): arkiv = ArkivMirror(RPC, None, OWNER, None, None, 30) with pytest.raises(RuntimeError): diff --git a/control-plane/tests/test_routes_routing.py b/control-plane/tests/test_routes_routing.py index 5b205dec..c57ecf7c 100644 --- a/control-plane/tests/test_routes_routing.py +++ b/control-plane/tests/test_routes_routing.py @@ -639,9 +639,12 @@ def test_an_exploring_suggestion_is_mirrored_to_arkiv_and_readable_back(settings assert all(d["plan_id"] == plan["plan_id"] for d in sent["decisions"]) # Replayable, not just a record of the outcome: links back to the real Postgres row and # carries what a replay needs (migration 0016's own reason routing_decisions exists). - assert all(isinstance(d["decision_id"], int) for d in sent["decisions"]) - assert all(d["seed"] == got["seed"] for d in sent["decisions"]) - assert all(isinstance(d["budget_micros"], int) for d in sent["decisions"]) + # u64s as exact decimal strings (the sidecar's JSON.parse would round a 62-bit number). + assert all(d["decision_id"].isdigit() for d in sent["decisions"]) + assert all(d["seed"] == str(got["seed"]) for d in sent["decisions"]) + assert all(d["budget_micros"].isdigit() for d in sent["decisions"]) + # One draw, one Postgres transaction, one created_at: what the panel groups a draw by. + assert len({d["decided_at"] for d in sent["decisions"]}) == 1 assert all("requester" not in d for d in sent["decisions"]) # The read endpoint decodes what was (supposedly) written on Arkiv, not a copy kept in Postgres. diff --git a/frontend/src/screens/live/run/SuggestAgents.tsx b/frontend/src/screens/live/run/SuggestAgents.tsx index da5259e3..80b081dd 100644 --- a/frontend/src/screens/live/run/SuggestAgents.tsx +++ b/frontend/src/screens/live/run/SuggestAgents.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { errorMessage, getArkivSuggestions, getSuggestions } from "../../../api/client"; import { percent, usdc } from "../../../api/reputation"; import type { ArkivDecision, ArkivSuggestions, Suggestions, TaskSuggestion } from "../../../api/types"; +import { latestDraw } from "./arkivDraw"; /** Does what Arkiv has for this task match the picks Postgres just answered with — proof the mirror * carries what the product actually ranked, not just a write nobody checks against anything. */ @@ -62,6 +63,10 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s ); }; + const draw = latestDraw(arkiv?.decisions ?? []); + // Both seeds come through the same JSON.parse, so they round the same way and still compare as one draw. + const sameDraw = got !== undefined && draw.latest[0] !== undefined && draw.latest[0].seed === got.seed; + return (
Suggested agents @@ -142,13 +147,15 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s
{arkiv.decisions.length > 0 - ? `${String(arkiv.decisions.length)} decision${arkiv.decisions.length === 1 ? "" : "s"} read back from Arkiv's Rabdi testnet, not this app's own database:` + ? sameDraw + ? `This draw, read back from Arkiv's Rabdi testnet, not this app's own database:` + : "The draw above has not landed on Arkiv yet (it takes a few seconds); this is the previous one:" : arkiv.writer && arkiv.writer !== "ok" ? `Nothing on Arkiv yet: the write side is not running (${arkiv.writer}), so no decision has been mirrored.` : "Not found on Arkiv yet; the write lands a few seconds after the suggestion, so try again shortly."} - {arkiv.decisions.map((d) => { - const matches = picksMatch(got.tasks[d.task_index], d); + {draw.latest.map((d) => { + const matches = sameDraw ? picksMatch(got.tasks[d.task_index], d) : undefined; return (
); })} + {draw.earlier > 0 && ( + + Plus {String(draw.earlier)} decision{draw.earlier === 1 ? "" : "s"} from earlier draws on this + plan: each explore call is a new draw, and Arkiv keeps every one until it expires. + + )}
)}
diff --git a/frontend/src/screens/live/run/arkivDraw.test.ts b/frontend/src/screens/live/run/arkivDraw.test.ts new file mode 100644 index 00000000..82e1f16e --- /dev/null +++ b/frontend/src/screens/live/run/arkivDraw.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { ArkivDecision } from "../../../api/types"; +import { latestDraw } from "./arkivDraw"; + +const d = ( + task_index: number, + decided_at: string, + entity_key = `0x${String(task_index)}${decided_at}`, +): ArkivDecision => ({ + entity_key, + decision_id: 1, + task_index, + category: "test-repair", + seed: 1, + budget_micros: 400000, + decided_at, + candidates: [], + picks: [], +}); + +describe("latestDraw", () => { + it("is empty for no decisions", () => { + expect(latestDraw([])).toEqual({ latest: [], earlier: 0 }); + }); + + it("keeps only the newest draw, in task order, and counts the rest", () => { + const older = "2026-11-06T06:19:40.100000+00:00"; + const newer = "2026-11-06T06:21:02.500000+00:00"; + // Arkiv returns them in key order, which mixes draws and tasks (as it did on the live chain). + const got = latestDraw([d(2, older), d(1, newer), d(0, older), d(2, newer), d(0, newer), d(1, older)]); + expect(got.latest.map((x) => [x.task_index, x.decided_at])).toEqual([ + [0, newer], + [1, newer], + [2, newer], + ]); + expect(got.earlier).toBe(3); + }); +}); diff --git a/frontend/src/screens/live/run/arkivDraw.ts b/frontend/src/screens/live/run/arkivDraw.ts new file mode 100644 index 00000000..11123761 --- /dev/null +++ b/frontend/src/screens/live/run/arkivDraw.ts @@ -0,0 +1,19 @@ +import type { ArkivDecision } from "../../../api/types"; + +/** + * The decisions of the most recent explore draw on a plan, in task order, and how many older ones Arkiv also holds. + * + * Every explore call is a new draw and stays on Arkiv until it expires, so a plan collects several. Comparing all of + * them with the picks on screen marked every older draw "does not match", which reads as tampering when it is only + * history. One draw's rows are written in one Postgres transaction, so they share one `decided_at`: the latest value + * is the latest draw. + */ +export function latestDraw(decisions: ArkivDecision[]): { latest: ArkivDecision[]; earlier: number } { + const newest = decisions.reduce( + (max, d) => (max === undefined || d.decided_at > max ? d.decided_at : max), + undefined, + ); + if (newest === undefined) return { latest: [], earlier: 0 }; + const latest = decisions.filter((d) => d.decided_at === newest).sort((a, b) => a.task_index - b.task_index); + return { latest, earlier: decisions.length - latest.length }; +}