Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 40 additions & 21 deletions chain/arkiv/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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 });
Expand Down
9 changes: 6 additions & 3 deletions control-plane/mergit_cp/arkiv_mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 15 additions & 3 deletions control-plane/tests/test_arkiv_mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 6 additions & 3 deletions control-plane/tests/test_routes_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 16 additions & 3 deletions frontend/src/screens/live/run/SuggestAgents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 (
<div className="col g8" aria-label="Suggested agents">
<span className="cap">Suggested agents</span>
Expand Down Expand Up @@ -142,13 +147,15 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s
<div className="col g4" data-testid="arkiv-suggestions">
<span className="small c3">
{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."}
</span>
{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 (
<div key={d.entity_key} className="row g8">
<a
Expand All @@ -167,6 +174,12 @@ export function SuggestAgents({ planUuid, onUse }: { planUuid: string; onUse: (s
</div>
);
})}
{draw.earlier > 0 && (
<span className="small c3">
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.
</span>
)}
</div>
)}
</div>
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/screens/live/run/arkivDraw.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 19 additions & 0 deletions frontend/src/screens/live/run/arkivDraw.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(
(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 };
}
Loading