From 79c673ce875fad05f662f15ca3ea4748633eb207 Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Sat, 4 Jul 2026 20:34:08 +0900 Subject: [PATCH] fix(agent): count pending queued orders against the per-token budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit placeAgentOrder checks the token's remaining budget against tokenSpentThisWindow(), which only reflects orders the daemon has already executed into crypto_spend_ledger. Orders this token already has enqueued in source_state ('agent-orders') but not yet executed are invisible to that check. So N rapid POST /api/agent/orders requests submitted before the daemon's next tick each see the same stale `spent` figure and can all pass the per-token budget_usd check — the only guard applied at execution time is the engine's global account spend budget (see apps/daemon crypto-trade.ts executeAgentMarketBuy → applySpendBudget), not the token's own cap. That defeats the entire point of "Coinbase for Agents" scoped tokens: a user handing out a $50-budget token to a semi-trusted agent should not be exposed to $50 × N in real exchange buys just because the ledger hasn't caught up yet. Fix: read the pending queue before the budget check and add this token's already-enqueued-but-unexecuted usd to `spent` before calling checkAgentBudget, in both placeAgentOrder (enforcement) and getAgentBudget (so the reported remainingUsd matches). Added a test that reproduces the bypass: two 80-usd orders against a 100-usd budget now correctly reject the second with 402 instead of enqueuing both. --- apps/web/src/lib/agent-trade.test.ts | 21 ++++++++++++ apps/web/src/lib/agent-trade.ts | 48 +++++++++++++++++++--------- 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lib/agent-trade.test.ts b/apps/web/src/lib/agent-trade.test.ts index 0b1368f..70fd485 100644 --- a/apps/web/src/lib/agent-trade.test.ts +++ b/apps/web/src/lib/agent-trade.test.ts @@ -127,6 +127,27 @@ describe('placeAgentOrder (contract)', () => { await placeAgentOrder(agent({}, admin), { pair: 'btc-usd', usd: 10, idempotencyKey: 'k' }); expect((state.queue[0] as { pair: string }).pair).toBe('BTC-USD'); }); + + it('counts already-enqueued-but-unexecuted orders against the budget (no rapid-fire bypass)', async () => { + // tokenSpentThisWindow only reflects the durable ledger, which the daemon + // only writes to once it actually executes a queued order. Two orders + // submitted back-to-back before that happens must not both be allowed + // to exceed budget_usd just because the ledger hasn't caught up yet. + tokenSpentMock.mockResolvedValue(0); + const { admin, state } = makeAdmin(); + const a = agent({ budget_usd: 100 }, admin); + + const first = await placeAgentOrder(a, { pair: 'BTC-USD', usd: 80, idempotencyKey: 'k1' }); + expect(first.status).toBe(202); + expect(state.queue).toHaveLength(1); + + // Ledger still shows 0 spent (daemon hasn't drained the queue yet), but + // the first order is now pending in source_state for this same token. + const second = await placeAgentOrder(a, { pair: 'BTC-USD', usd: 80, idempotencyKey: 'k2' }); + expect(second.status).toBe(402); + expect(second.body.remainingUsd).toBe(20); + expect(state.queue).toHaveLength(1); // second order never got enqueued + }); }); describe('getAgentBudget (contract)', () => { diff --git a/apps/web/src/lib/agent-trade.ts b/apps/web/src/lib/agent-trade.ts index 55ea4b8..cb55888 100644 --- a/apps/web/src/lib/agent-trade.ts +++ b/apps/web/src/lib/agent-trade.ts @@ -38,10 +38,28 @@ async function logAction(agent: AuthedAgent, action: string, detail: Record { + const { data } = await agent.admin + .from('source_state') + .select('payload') + .eq('user_id', agent.userId) + .eq('source_id', 'agent-orders') + .maybeSingle(); + const queue = ((data?.payload as { queue?: AgentQueueItem[] } | undefined)?.queue ?? []) as AgentQueueItem[]; + return queue; +} + +function pendingUsdFor(queue: AgentQueueItem[], tokenId: string): number { + return queue.filter((q) => q.tokenId === tokenId).reduce((acc, q) => acc + Number(q.usd ?? 0), 0); +} + export async function getAgentBudget(agent: AuthedAgent): Promise { const window = (agent.token.budget_window as AgentBudgetWindow) ?? 'daily'; const spent = await tokenSpentThisWindow(agent.tokenId, window); - const check = checkAgentBudget(agent.token, spent, 0); + const queue = await pendingQueue(agent); + const pendingUsd = pendingUsdFor(queue, agent.tokenId); + const check = checkAgentBudget(agent.token, spent + pendingUsd, 0); return { status: 200, body: { @@ -106,26 +124,26 @@ export async function placeAgentOrder(agent: AuthedAgent, req: AgentOrderRequest return { status: 403, body: { error: `${pair} is not in this token's allowed symbols` } }; } + // Read the pending queue BEFORE the budget check. tokenSpentThisWindow only + // reflects orders the daemon has already executed and ledgered — orders + // this token already has enqueued-but-not-yet-executed are invisible to it, + // so without counting them here, N rapid requests submitted before the + // daemon's next tick would each see the same stale `spent` and all pass, + // letting the token's real committed spend exceed budget_usd by up to N×. + const queue = await pendingQueue(agent); + const key = req.idempotencyKey ?? `${agent.tokenId}:${Date.now()}`; + if (queue.some((q) => q.idempotencyKey === key)) { + return { status: 200, body: { status: 'accepted', idempotencyKey: key, duplicate: true } }; + } + const window = (agent.token.budget_window as AgentBudgetWindow) ?? 'daily'; const spent = await tokenSpentThisWindow(agent.tokenId, window); - const budget = checkAgentBudget(agent.token, spent, usd); + const pendingUsd = pendingUsdFor(queue, agent.tokenId); + const budget = checkAgentBudget(agent.token, spent + pendingUsd, usd); if (!budget.allowed) { await logAction(agent, 'place_order', { pair, usd, reason: budget.reason }, false); return { status: 402, body: { error: budget.reason, remainingUsd: budget.remainingUsd } }; } - - // Enqueue for the daemon's crypto-trade worker. Idempotent on the key. - const { data: row } = await agent.admin - .from('source_state') - .select('payload') - .eq('user_id', agent.userId) - .eq('source_id', 'agent-orders') - .maybeSingle(); - const queue = ((row?.payload as { queue?: AgentQueueItem[] } | undefined)?.queue ?? []) as AgentQueueItem[]; - const key = req.idempotencyKey ?? `${agent.tokenId}:${Date.now()}`; - if (queue.some((q) => q.idempotencyKey === key)) { - return { status: 200, body: { status: 'accepted', idempotencyKey: key, duplicate: true } }; - } const item: AgentQueueItem = { idempotencyKey: key, tokenId: agent.tokenId,