Skip to content

Commit 2b29717

Browse files
committed
Fix escrow flows, submission safety, and SDK defaults
1 parent 4217e01 commit 2b29717

21 files changed

Lines changed: 901 additions & 117 deletions

packages/api/src/routes/a2a.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { agents, agentSkills, taskBids, tasks } from '../db/schema.js';
66
import { BidCreateSchema, TaskCreateSchema, TaskListQuerySchema, TASK_STATUS } from '@swarmdock/shared';
77
import { eventBus } from '../lib/events.js';
88
import { getAgentPortfolio } from '../services/portfolio.js';
9+
import { createTaskWithOptionalFunding } from '../services/task-creation.js';
910

1011
type JsonRpcId = string | number | null;
1112

@@ -132,19 +133,19 @@ app.post('/', authMiddleware, async (c) => {
132133
return c.json(failure(request.id ?? null, -32602, 'Invalid task payload', parsed.error.flatten()), 400);
133134
}
134135

135-
const [task] = await db.insert(tasks).values({
136-
requesterId: caller.agent_id,
137-
assigneeId: parsed.data.directAssigneeId ?? null,
138-
title: parsed.data.title,
139-
description: parsed.data.description,
140-
skillRequirements: parsed.data.skillRequirements,
141-
inputData: parsed.data.inputData ?? null,
142-
matchingMode: parsed.data.matchingMode,
143-
budgetMin: parsed.data.budgetMin ? BigInt(parsed.data.budgetMin) : null,
144-
budgetMax: BigInt(parsed.data.budgetMax),
145-
deadline: parsed.data.deadline ? new Date(parsed.data.deadline) : null,
146-
status: parsed.data.directAssigneeId ? TASK_STATUS.ASSIGNED : TASK_STATUS.OPEN,
147-
}).returning();
136+
const creation = await createTaskWithOptionalFunding(c, caller.agent_id, parsed.data, { db });
137+
if (creation.response) {
138+
return creation.response;
139+
}
140+
141+
const task = creation.task as {
142+
id: string;
143+
title: string;
144+
skillRequirements: string[];
145+
budgetMax: bigint;
146+
finalPrice: bigint | null;
147+
matchingMode: string;
148+
};
148149

149150
eventBus.broadcast({
150151
type: 'task.created',
@@ -157,6 +158,27 @@ app.post('/', authMiddleware, async (c) => {
157158
},
158159
});
159160

161+
if (creation.escrow) {
162+
eventBus.emit(caller.agent_id, {
163+
type: 'payment.escrowed',
164+
data: {
165+
taskId: task.id,
166+
amount: task.finalPrice?.toString() ?? task.budgetMax.toString(),
167+
txHash: (creation.escrow as { escrowTxHash: string | null }).escrowTxHash,
168+
},
169+
});
170+
}
171+
172+
if (parsed.data.directAssigneeId) {
173+
eventBus.emit(parsed.data.directAssigneeId, {
174+
type: 'task.assigned',
175+
data: {
176+
taskId: task.id,
177+
price: task.finalPrice?.toString() ?? task.budgetMax.toString(),
178+
},
179+
});
180+
}
181+
160182
return c.json(success(request.id ?? null, task));
161183
}
162184

packages/api/src/routes/bids.ts

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Hono } from 'hono';
22
import { db, type Database } from '../db/client.js';
33
import { tasks, taskBids, escrowTransactions } from '../db/schema.js';
4-
import { eq, and } from 'drizzle-orm';
4+
import { eq, and, sql } from 'drizzle-orm';
55
import { authMiddleware, requireScope, type AuthContext } from '../middleware/auth.js';
66
import { BidCreateSchema, TASK_STATUS, BID_STATUS, ESCROW_STATUS } from '@swarmdock/shared';
77
import { eventBus } from '../lib/events.js';
@@ -13,7 +13,7 @@ type BidRouteDeps = {
1313
db: Pick<Database, 'select' | 'insert' | 'update' | 'transaction'>;
1414
authMiddleware: typeof authMiddleware;
1515
requireScope: typeof requireScope;
16-
eventBus: Pick<typeof eventBus, 'emit'>;
16+
eventBus: Pick<typeof eventBus, 'emit' | 'broadcast'>;
1717
createTxHash: () => string;
1818
requirePayment: typeof requireX402Payment;
1919
};
@@ -82,7 +82,7 @@ export function createBidsApp(overrides: Partial<BidRouteDeps> = {}) {
8282
await database.update(tasks).set({ status: TASK_STATUS.BIDDING, updatedAt: new Date() }).where(eq(tasks.id, taskId));
8383
}
8484

85-
eventBus.broadcast({
85+
events.broadcast({
8686
type: 'task.updated',
8787
data: { taskId, status: TASK_STATUS.BIDDING },
8888
});
@@ -110,23 +110,19 @@ export function createBidsApp(overrides: Partial<BidRouteDeps> = {}) {
110110
const [task] = await database.select().from(tasks).where(eq(tasks.id, taskId)).limit(1);
111111
if (!task) return c.json({ error: 'Task not found' }, 404);
112112
if (task.requesterId !== agent.agent_id) return c.json({ error: 'Not task owner' }, 403);
113-
if (![TASK_STATUS.OPEN, TASK_STATUS.BIDDING].includes(task.status as 'open' | 'bidding')) {
114-
return c.json({ error: 'Task not accepting bids' }, 400);
115-
}
116113

117-
const [bid] = await database
114+
const [preflightBid] = await database
118115
.select()
119116
.from(taskBids)
120117
.where(and(eq(taskBids.id, bidId), eq(taskBids.taskId, taskId)))
121118
.limit(1);
122-
123-
if (!bid) return c.json({ error: 'Bid not found' }, 404);
124-
if (bid.status !== BID_STATUS.PENDING) return c.json({ error: 'Bid no longer pending' }, 400);
119+
if (!preflightBid) return c.json({ error: 'Bid not found' }, 404);
120+
if (preflightBid.status !== BID_STATUS.PENDING) return c.json({ error: 'Bid no longer pending' }, 400);
125121

126122
const paymentGate = await requirePayment(c, {
127123
accepts: {
128124
scheme: 'exact',
129-
price: microUsdcToUsdPrice(bid.proposedPrice),
125+
price: microUsdcToUsdPrice(preflightBid.proposedPrice),
130126
network: getX402Network(),
131127
payTo: process.env.PLATFORM_WALLET_ADDRESS ?? '0x0000000000000000000000000000000000000000',
132128
},
@@ -138,7 +134,7 @@ export function createBidsApp(overrides: Partial<BidRouteDeps> = {}) {
138134
error: 'Payment required to fund escrow',
139135
taskId,
140136
bidId,
141-
amount: bid.proposedPrice.toString(),
137+
amount: preflightBid.proposedPrice.toString(),
142138
},
143139
}),
144140
});
@@ -150,7 +146,38 @@ export function createBidsApp(overrides: Partial<BidRouteDeps> = {}) {
150146
const pendingEscrowTxHash = paymentGate.pendingSettlement ? null : createTxHash();
151147

152148
// Accept this bid, assign the task, and record pending/funded escrow in one transaction.
153-
const { updatedTask, escrow } = await database.transaction(async (tx) => {
149+
const transactionResult = await database.transaction(async (tx) => {
150+
await tx.execute(sql`SELECT id FROM tasks WHERE id = ${taskId} FOR UPDATE`);
151+
152+
const [lockedTask] = await tx
153+
.select()
154+
.from(tasks)
155+
.where(eq(tasks.id, taskId))
156+
.limit(1);
157+
158+
if (!lockedTask) {
159+
return { ok: false, status: 404, body: { error: 'Task not found' } } as const;
160+
}
161+
if (lockedTask.requesterId !== agent.agent_id) {
162+
return { ok: false, status: 403, body: { error: 'Not task owner' } } as const;
163+
}
164+
if (![TASK_STATUS.OPEN, TASK_STATUS.BIDDING].includes(lockedTask.status as 'open' | 'bidding')) {
165+
return { ok: false, status: 400, body: { error: 'Task not accepting bids' } } as const;
166+
}
167+
168+
const [bid] = await tx
169+
.select()
170+
.from(taskBids)
171+
.where(and(eq(taskBids.id, bidId), eq(taskBids.taskId, taskId)))
172+
.limit(1);
173+
174+
if (!bid) {
175+
return { ok: false, status: 404, body: { error: 'Bid not found' } } as const;
176+
}
177+
if (bid.status !== BID_STATUS.PENDING) {
178+
return { ok: false, status: 400, body: { error: 'Bid no longer pending' } } as const;
179+
}
180+
154181
await tx.update(taskBids).set({ status: BID_STATUS.ACCEPTED }).where(eq(taskBids.id, bidId));
155182
await tx.update(taskBids).set({ status: BID_STATUS.REJECTED })
156183
.where(and(eq(taskBids.taskId, taskId), eq(taskBids.status, BID_STATUS.PENDING)));
@@ -172,9 +199,14 @@ export function createBidsApp(overrides: Partial<BidRouteDeps> = {}) {
172199
network: process.env.X402_NETWORK ?? 'base-sepolia',
173200
}).returning();
174201

175-
return { updatedTask, escrow };
202+
return { ok: true, updatedTask, escrow, bid } as const;
176203
});
177204

205+
if (!transactionResult.ok) {
206+
return c.json(transactionResult.body, transactionResult.status);
207+
}
208+
209+
const { updatedTask, escrow, bid } = transactionResult;
178210
let settledEscrow = escrow;
179211
let settlementHeaders: Record<string, string> = {};
180212

@@ -222,7 +254,7 @@ export function createBidsApp(overrides: Partial<BidRouteDeps> = {}) {
222254
type: 'payment.escrowed',
223255
data: { taskId, amount: bid.proposedPrice.toString(), txHash: settledEscrow.escrowTxHash },
224256
});
225-
eventBus.broadcast({
257+
events.broadcast({
226258
type: 'task.updated',
227259
data: { taskId, status: TASK_STATUS.ASSIGNED, assigneeId: bid.bidderId },
228260
});

packages/api/src/routes/payments.ts

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { escrowTransactions, transactions, agents } from '../db/schema.js';
44
import { eq, or, desc } from 'drizzle-orm';
55
import { authMiddleware, type AuthContext } from '../middleware/auth.js';
66
import { queryOnChainBalance } from '../services/escrow.js';
7+
import { ESCROW_STATUS } from '@swarmdock/shared';
78

89
type PaymentsDeps = {
910
db: Pick<Database, 'select'>;
@@ -14,6 +15,45 @@ export function canAccessAgentPayments(requestedAgentId: string, viewerAgentId:
1415
return requestedAgentId === viewerAgentId;
1516
}
1617

18+
export function summarizeAgentBalance(
19+
agentId: string,
20+
escrowTxs: Array<{
21+
payerId: string;
22+
payeeId: string | null;
23+
amount: bigint;
24+
platformFee: bigint | null;
25+
status: string;
26+
}>,
27+
) {
28+
let earned = 0n;
29+
let spent = 0n;
30+
let escrowed = 0n;
31+
let released = 0n;
32+
33+
for (const tx of escrowTxs) {
34+
if (tx.payeeId === agentId && tx.status === ESCROW_STATUS.RELEASED) {
35+
const payout = tx.amount - (tx.platformFee ?? 0n);
36+
earned += payout;
37+
released += payout;
38+
}
39+
40+
if (tx.payerId === agentId && (tx.status === ESCROW_STATUS.FUNDED || tx.status === ESCROW_STATUS.RELEASED)) {
41+
spent += tx.amount;
42+
}
43+
44+
if (tx.payerId === agentId && (tx.status === ESCROW_STATUS.PENDING || tx.status === ESCROW_STATUS.FUNDED)) {
45+
escrowed += tx.amount;
46+
}
47+
}
48+
49+
return {
50+
earned: earned.toString(),
51+
spent: spent.toString(),
52+
escrowed: escrowed.toString(),
53+
released: released.toString(),
54+
};
55+
}
56+
1757
export function createPaymentsApp(overrides: Partial<PaymentsDeps> = {}) {
1858
const database = overrides.db ?? db;
1959
const requireAuth = overrides.authMiddleware ?? authMiddleware;
@@ -35,39 +75,7 @@ export function createPaymentsApp(overrides: Partial<PaymentsDeps> = {}) {
3575
.from(escrowTransactions)
3676
.where(or(eq(escrowTransactions.payerId, id), eq(escrowTransactions.payeeId, id)));
3777

38-
let earned = 0n;
39-
let spent = 0n;
40-
let escrowed = 0n;
41-
let released = 0n;
42-
for (const tx of escrowTxs) {
43-
if (tx.payeeId === id && tx.status === 'released') {
44-
const payout = tx.amount - (tx.platformFee ?? 0n);
45-
earned += payout;
46-
released += payout;
47-
}
48-
if (tx.payerId === id && tx.status !== 'refunded') {
49-
spent += tx.amount;
50-
}
51-
if (tx.payerId === id && (tx.status === 'pending' || tx.status === 'funded')) {
52-
escrowed += tx.amount;
53-
}
54-
}
55-
56-
// Also query from transactions table for a more complete picture
57-
const txRows = await database
58-
.select()
59-
.from(transactions)
60-
.where(or(eq(transactions.fromAgentId, id), eq(transactions.toAgentId, id)));
61-
62-
for (const tx of txRows) {
63-
if (tx.status !== 'confirmed') continue;
64-
if (tx.toAgentId === id && tx.type === 'escrow_release') {
65-
earned += tx.amount;
66-
}
67-
if (tx.fromAgentId === id && tx.type === 'escrow_deposit') {
68-
spent += tx.amount;
69-
}
70-
}
78+
const summary = summarizeAgentBalance(id, escrowTxs);
7179

7280
// Query actual on-chain USDC balance if wallet is configured
7381
let onChainBalance: string | null = null;
@@ -85,10 +93,10 @@ export function createPaymentsApp(overrides: Partial<PaymentsDeps> = {}) {
8593

8694
return c.json({
8795
agentId: id,
88-
earned: earned.toString(),
89-
spent: spent.toString(),
90-
escrowed: escrowed.toString(),
91-
released: released.toString(),
96+
earned: summary.earned,
97+
spent: summary.spent,
98+
escrowed: summary.escrowed,
99+
released: summary.released,
92100
onChainBalance,
93101
currency: 'USDC',
94102
network: settlementNetwork,

0 commit comments

Comments
 (0)