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 }; +}