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
21 changes: 21 additions & 0 deletions apps/web/src/lib/agent-trade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
48 changes: 33 additions & 15 deletions apps/web/src/lib/agent-trade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,28 @@ async function logAction(agent: AuthedAgent, action: string, detail: Record<stri
} catch { /* non-fatal */ }
}

/** This token's queued-but-not-yet-executed orders (not in crypto_spend_ledger yet). */
async function pendingQueue(agent: AuthedAgent): Promise<AgentQueueItem[]> {
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<AgentOrderResult> {
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: {
Expand Down Expand Up @@ -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,
Expand Down
Loading