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,