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 (