@blindmarket/mcp-server 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -4
- package/dist/crypto.d.ts +16 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +41 -0
- package/dist/crypto.js.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/rent.d.ts +4 -1
- package/dist/rent.d.ts.map +1 -1
- package/dist/rent.js +809 -50
- package/dist/rent.js.map +1 -1
- package/dist/settlement.d.ts +98 -0
- package/dist/settlement.d.ts.map +1 -0
- package/dist/settlement.js +145 -0
- package/dist/settlement.js.map +1 -0
- package/dist/state.d.ts +27 -5
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js.map +1 -1
- package/dist/tools.js +2 -2
- package/dist/tools.js.map +1 -1
- package/dist/wallet.d.ts +6 -11
- package/dist/wallet.d.ts.map +1 -1
- package/dist/wallet.js +48 -14
- package/dist/wallet.js.map +1 -1
- package/package.json +2 -2
package/dist/rent.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
-
import { aesEncrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
|
|
2
|
+
import { Contract, Interface, formatUnits, parseUnits } from 'ethers';
|
|
3
|
+
import { aesDecrypt, aesEncrypt, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
|
|
4
4
|
import { createQuote, consumeQuote, getSpend, putSpend, updateSpend } from './state.js';
|
|
5
|
+
import { createSettlementResolver } from './settlement.js';
|
|
6
|
+
// Read-only view of BlindEscrow.getTask, for reading a task's state directly
|
|
7
|
+
// from the chain that holds it. Field order matches contracts/BlindEscrow.sol;
|
|
8
|
+
// a post-#38 deployment appends disputedAt, which ABI decoding ignores.
|
|
9
|
+
const ESCROW_READ_ABI = [
|
|
10
|
+
'function getTask(uint256) view returns (tuple(address agent,address worker,address token,uint256 amount,bytes32 taskHash,bytes32 evidenceHash,uint8 status,string category,string locationZone,uint256 createdAt,uint256 deadline,uint8 submissionAttempts))',
|
|
11
|
+
];
|
|
5
12
|
/**
|
|
6
13
|
* Tier-2 spending tools: the CURRENT encrypted post/rent flow, executed
|
|
7
14
|
* entirely locally. This is a 1:1 port of the "Use from your agent" script
|
|
@@ -40,11 +47,34 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
40
47
|
err.code = 'NO_API_KEY';
|
|
41
48
|
throw err;
|
|
42
49
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
// Explicit, named timeout. Node's fetch otherwise fails after ~5 minutes
|
|
51
|
+
// with a bare "fetch failed" — which is what post_task reported, twice,
|
|
52
|
+
// for posts that had already landed on-chain while /a2a/tasks/index sat
|
|
53
|
+
// behind a dead Redis socket server-side. 120s is above the slowest
|
|
54
|
+
// healthy call (index polls for a receipt for ~1 min) and well below the
|
|
55
|
+
// point where a caller assumes the money is lost.
|
|
56
|
+
let res;
|
|
57
|
+
try {
|
|
58
|
+
res = await fetch(`${cfg.apiBase}${path}`, {
|
|
59
|
+
method,
|
|
60
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': cfg.apiKey },
|
|
61
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
62
|
+
signal: AbortSignal.timeout(120_000),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
// Node's fetch reports every socket-level failure as a bare "fetch
|
|
67
|
+
// failed" and hides the real reason in `cause` (ECONNRESET, socket
|
|
68
|
+
// hang up, ECONNREFUSED…). Surface it: "fetch failed" alone cost a
|
|
69
|
+
// debugging session that ended in "the server restarted mid-request".
|
|
70
|
+
const cause = e?.cause;
|
|
71
|
+
const detail = cause?.code ?? cause?.message ?? e?.message ?? String(e);
|
|
72
|
+
const err = new Error(e?.name === 'TimeoutError'
|
|
73
|
+
? `${path} did not answer within 120s. The backend may be stalled (check its Redis connection); if this was a spend, retry with the SAME idempotencyKey — it resumes, never double-pays.`
|
|
74
|
+
: `${path} unreachable (${detail}). If the backend restarted mid-request and this was a spend, retry with the SAME idempotencyKey — it resumes from the last persisted stage.`);
|
|
75
|
+
err.code = e?.name === 'TimeoutError' ? 'BACKEND_TIMEOUT' : 'BACKEND_UNREACHABLE';
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
48
78
|
const json = await res.json().catch(() => ({}));
|
|
49
79
|
if (!res.ok || !json.success) {
|
|
50
80
|
const err = new Error(`${path} failed: ${json.error?.message || res.status}`);
|
|
@@ -53,21 +83,195 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
53
83
|
}
|
|
54
84
|
return json.data;
|
|
55
85
|
}
|
|
56
|
-
|
|
86
|
+
const settlement = createSettlementResolver({ apiBase: cfg.apiBase ?? 'https://api.blindmarket.xyz', api });
|
|
87
|
+
/** How this process pays. On 0G that is the local wallet, which must exist.
|
|
88
|
+
* On Base nothing signs locally — the relay signs from the API key's owner
|
|
89
|
+
* wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an error there. */
|
|
90
|
+
async function requireFunding() {
|
|
91
|
+
let s;
|
|
92
|
+
try {
|
|
93
|
+
s = await settlement();
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
return { error: fail(err.code ?? 'SETTLEMENT_UNKNOWN', err.message) };
|
|
97
|
+
}
|
|
98
|
+
if (s.mode === 'base')
|
|
99
|
+
return { s, payFrom: s.payFrom };
|
|
57
100
|
if (!walletCtx) {
|
|
58
|
-
return { error: fail('NO_WALLET', 'Spending
|
|
101
|
+
return { error: fail('NO_WALLET', 'Spending on 0G needs a local funding wallet — set BLINDMARKET_PRIVATE_KEY (see wallet_status)') };
|
|
102
|
+
}
|
|
103
|
+
return { s, payFrom: walletCtx.wallet.address };
|
|
104
|
+
}
|
|
105
|
+
const ERC20 = new Interface([
|
|
106
|
+
'function allowance(address owner, address spender) view returns (uint256)',
|
|
107
|
+
'function approve(address spender, uint256 amount) returns (bool)',
|
|
108
|
+
'function balanceOf(address owner) view returns (uint256)',
|
|
109
|
+
]);
|
|
110
|
+
function usdc(s) {
|
|
111
|
+
return new Contract(s.usdcAddress, ERC20, s.provider);
|
|
112
|
+
}
|
|
113
|
+
/** Spendable balance of whoever pays, in the settlement token's units. */
|
|
114
|
+
async function payFromBalance(s, payFrom) {
|
|
115
|
+
try {
|
|
116
|
+
const raw = s.mode === 'base'
|
|
117
|
+
? await usdc(s).balanceOf(payFrom)
|
|
118
|
+
: await walletCtx.provider.getBalance(payFrom);
|
|
119
|
+
return formatUnits(raw, s.decimals);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** The backend builds every unsigned tx for whichever chain it thinks holds
|
|
126
|
+
* the task, and the tx carries no chainId. Discovery is only a hint about
|
|
127
|
+
* that chain (see settlement.ts). So before broadcasting, check the tx
|
|
128
|
+
* targets the escrow THIS mode expects — otherwise native value goes to a
|
|
129
|
+
* Base address on the 0G RPC, or a 0G refund is relayed onto Base. A
|
|
130
|
+
* mismatch also drops the cached mode so the next call re-asks. */
|
|
131
|
+
async function verifyTarget(s, to, what) {
|
|
132
|
+
const target = String(to).toLowerCase();
|
|
133
|
+
const expected = s.mode === 'base' ? s.escrowAddress : s.escrowAddress;
|
|
134
|
+
if (expected) {
|
|
135
|
+
if (target === expected.toLowerCase())
|
|
136
|
+
return;
|
|
137
|
+
settlement.invalidate();
|
|
138
|
+
const e = new Error(`backend built ${what} for ${to} but this process is in ${s.mode} mode expecting escrow ${expected}. ` +
|
|
139
|
+
(s.mode === 'base'
|
|
140
|
+
? 'This task is escrowed on 0G — handle it with BLINDMARKET_SETTLEMENT=0g and a local key, or from the web app.'
|
|
141
|
+
: 'The backend is building Base transactions — re-run and discovery will re-check, or set BLINDMARKET_SETTLEMENT=base.'));
|
|
142
|
+
e.code = 'ESCROW_MISMATCH';
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
// 0G with no escrow address to compare against (forced 0g, or a backend
|
|
146
|
+
// that doesn't report one). The cheapest truth we have is whether
|
|
147
|
+
// anything lives at `to` on the 0G RPC — a Base escrow address holds no
|
|
148
|
+
// BlindEscrow there.
|
|
149
|
+
const code = await walletCtx.provider.getCode(to).catch(() => '0x');
|
|
150
|
+
if (code === '0x') {
|
|
151
|
+
settlement.invalidate();
|
|
152
|
+
const e = new Error(`backend built ${what} for ${to}, which holds no contract on the 0G RPC — it is almost certainly a Base transaction. Set BLINDMARKET_SETTLEMENT=base.`);
|
|
153
|
+
e.code = 'ESCROW_MISMATCH';
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function relaySend(s, tx) {
|
|
158
|
+
// gas:'auto' asks the backend to negotiate: user-pays (USDC) → app-pays →
|
|
159
|
+
// wallet-pays, advancing only on Privy's exact refusal for each rung. The
|
|
160
|
+
// negotiation lives server-side on purpose — that is the one place that
|
|
161
|
+
// sees Privy's raw errors, and it is shared with the web app, so both
|
|
162
|
+
// clients behave identically. An earlier version did the fallback here,
|
|
163
|
+
// and it skipped app-pays entirely: the wallet kept paying its own ETH
|
|
164
|
+
// even after sponsorship was switched on in the Privy dashboard.
|
|
165
|
+
const r = await api('POST', '/api/v1/tx/relay-tx', {
|
|
166
|
+
walletAddress: s.payFrom,
|
|
167
|
+
to: tx.to,
|
|
168
|
+
data: tx.data,
|
|
169
|
+
value: tx.value === undefined ? undefined : String(tx.value),
|
|
170
|
+
chain: s.relayChain,
|
|
171
|
+
asset: 'usdc',
|
|
172
|
+
gas: 'auto',
|
|
173
|
+
});
|
|
174
|
+
if (!r?.hash) {
|
|
175
|
+
const e = new Error('relay-tx returned no hash');
|
|
176
|
+
e.code = 'RELAY_NO_HASH';
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
// A backend older than the `gas` field answers without it. That backend
|
|
180
|
+
// also has no negotiation, so the only way it succeeds is the explicit
|
|
181
|
+
// default it applies to this body: user-pays.
|
|
182
|
+
return { hash: r.hash, isUserOp: r.isUserOp === true, gas: r.gas ?? 'user-pays' };
|
|
183
|
+
}
|
|
184
|
+
/** Wait for a relayed tx to land. A plain hash can be polled for its receipt;
|
|
185
|
+
* a user-op hash cannot (getTransactionReceipt is always null for it), so
|
|
186
|
+
* that case returns at once and the caller confirms by on-chain STATE —
|
|
187
|
+
* see ensureAllowance and waitCancelled. */
|
|
188
|
+
async function waitRelayed(s, hash, isUserOp) {
|
|
189
|
+
if (isUserOp)
|
|
190
|
+
return;
|
|
191
|
+
for (let i = 0; i < 30; i++) {
|
|
192
|
+
const receipt = await s.provider.getTransactionReceipt(hash).catch(() => null);
|
|
193
|
+
if (receipt) {
|
|
194
|
+
if (receipt.status === 0) {
|
|
195
|
+
const e = new Error(`relayed tx ${hash} reverted`);
|
|
196
|
+
e.code = 'TX_REVERTED';
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
59
202
|
}
|
|
60
|
-
|
|
203
|
+
const e = new Error(`relayed tx ${hash} not confirmed after 90s — retry with the same idempotencyKey to resume`);
|
|
204
|
+
e.code = 'TX_PENDING';
|
|
205
|
+
throw e;
|
|
206
|
+
}
|
|
207
|
+
/** Base only: createTask pulls USDC via transferFrom, so the escrow needs an
|
|
208
|
+
* allowance first. Confirmed by re-reading allowance() rather than by
|
|
209
|
+
* receipt, which is what makes the user-op case decidable. */
|
|
210
|
+
async function ensureAllowance(s, record) {
|
|
211
|
+
const need = BigInt(record.amountWei);
|
|
212
|
+
const token = usdc(s);
|
|
213
|
+
if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
|
|
214
|
+
return;
|
|
215
|
+
const approve = async () => {
|
|
216
|
+
const data = ERC20.encodeFunctionData('approve', [s.escrowAddress, need]);
|
|
217
|
+
const { hash } = await relaySend(s, { to: s.usdcAddress, data });
|
|
218
|
+
// Persist BEFORE waiting: a crash here must resume into the poll below.
|
|
219
|
+
updateSpend(record.idempotencyKey, { stage: 'approved', approveTxHash: hash });
|
|
220
|
+
record.stage = 'approved';
|
|
221
|
+
record.approveTxHash = hash;
|
|
222
|
+
};
|
|
223
|
+
const settled = async () => {
|
|
224
|
+
for (let i = 0; i < 30; i++) {
|
|
225
|
+
if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
|
|
226
|
+
return true;
|
|
227
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
};
|
|
231
|
+
if (record.stage === 'created') {
|
|
232
|
+
await approve();
|
|
233
|
+
if (await settled())
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// Resumed at 'approved' (or the fresh approve never landed): the earlier
|
|
237
|
+
// approve was dropped or reverted. Sending another is safe — ERC-20
|
|
238
|
+
// approve SETS the allowance, it does not add — and it is the only way
|
|
239
|
+
// out of this stage, so do it rather than leave the record stuck.
|
|
240
|
+
if (await settled())
|
|
241
|
+
return;
|
|
242
|
+
await approve();
|
|
243
|
+
if (await settled())
|
|
244
|
+
return;
|
|
245
|
+
const e = new Error(`USDC allowance still below ${formatUnits(need, s.decimals)} after two approves (last ${record.approveTxHash}) — check the relay wallet's USDC balance and retry with the same idempotencyKey`);
|
|
246
|
+
e.code = 'APPROVE_PENDING';
|
|
247
|
+
throw e;
|
|
61
248
|
}
|
|
62
249
|
/** Fund escrow + index — the shared tail of rent_service and post_task.
|
|
63
|
-
* Resumable at every stage via the spend ledger.
|
|
250
|
+
* Resumable at every stage via the spend ledger.
|
|
251
|
+
*
|
|
252
|
+
* Two funding paths, chosen by settlement mode:
|
|
253
|
+
* 0g — native value from the local wallet, signed and sent here.
|
|
254
|
+
* base — USDC via transferFrom: approve first (ensureAllowance), then the
|
|
255
|
+
* createTask the backend built, both through the Privy relay with
|
|
256
|
+
* no local signing at all. The backend picks the escrow; we only
|
|
257
|
+
* check it is the one we approved. */
|
|
64
258
|
async function fundAndIndex(record) {
|
|
65
|
-
const
|
|
259
|
+
const s = await settlement();
|
|
66
260
|
let { txHash } = record;
|
|
67
|
-
|
|
261
|
+
// A record remembers the chain it started on. If the backend flips mode
|
|
262
|
+
// between attempts, re-funding through the other path would double-fund
|
|
263
|
+
// or send native value into a USDC transferFrom — refuse instead.
|
|
264
|
+
if (record.settlement && record.settlement !== s.mode) {
|
|
265
|
+
const e = new Error(`spend ${record.idempotencyKey} started on ${record.settlement} but the backend now settles on ${s.mode} — finish or refund it from the web app`);
|
|
266
|
+
e.code = 'SETTLEMENT_CHANGED';
|
|
267
|
+
throw e;
|
|
268
|
+
}
|
|
269
|
+
if (record.stage === 'created' || record.stage === 'approved') {
|
|
270
|
+
if (s.mode === 'base')
|
|
271
|
+
await ensureAllowance(s, record);
|
|
68
272
|
const { unsignedTx } = await api('POST', '/api/v1/tasks', {
|
|
69
273
|
taskHash: record.taskHash,
|
|
70
|
-
token: ZERO_TOKEN,
|
|
274
|
+
token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
|
|
71
275
|
amount: record.amountWei,
|
|
72
276
|
locationZone: 'global',
|
|
73
277
|
duration: String(record.durationSecs ?? 3600),
|
|
@@ -78,22 +282,39 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
78
282
|
rootHash: record.rootHash,
|
|
79
283
|
wrappedKeys: record.privacy === 'public' ? undefined : record.wrappedKeys,
|
|
80
284
|
});
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
285
|
+
// Either branch: the tx must target the escrow this mode expects. On
|
|
286
|
+
// Base that is also the escrow the allowance above was granted to.
|
|
287
|
+
await verifyTarget(s, unsignedTx.to, 'createTask');
|
|
288
|
+
if (s.mode === 'base') {
|
|
289
|
+
const { hash, isUserOp, gas } = await relaySend(s, { to: unsignedTx.to, data: unsignedTx.data });
|
|
290
|
+
// Persist BEFORE waiting, same reasoning as the 0G branch below.
|
|
291
|
+
updateSpend(record.idempotencyKey, { stage: 'funded', txHash: hash, isUserOp, gas });
|
|
292
|
+
record.gas = gas;
|
|
293
|
+
txHash = hash;
|
|
294
|
+
record.isUserOp = isUserOp;
|
|
295
|
+
await waitRelayed(s, hash, isUserOp);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const tx = await walletCtx.wallet.sendTransaction({
|
|
299
|
+
to: unsignedTx.to,
|
|
300
|
+
data: unsignedTx.data,
|
|
301
|
+
value: BigInt(record.amountWei),
|
|
302
|
+
gasLimit: GAS_LIMIT,
|
|
303
|
+
});
|
|
304
|
+
// Persist the tx hash BEFORE waiting: if we crash mid-confirmation the
|
|
305
|
+
// resume path re-runs /tasks/index with this hash instead of re-funding.
|
|
306
|
+
updateSpend(record.idempotencyKey, { stage: 'funded', txHash: tx.hash });
|
|
307
|
+
txHash = tx.hash;
|
|
308
|
+
await tx.wait();
|
|
309
|
+
}
|
|
92
310
|
}
|
|
93
311
|
// stage 'funded' (fresh or resumed): index against the verified receipt.
|
|
94
|
-
// /a2a/tasks/index
|
|
312
|
+
// /a2a/tasks/index polls both chains for the receipt server-side, and with
|
|
313
|
+
// isUserOp it skips the (always-null) receipt lookup and scans logs for the
|
|
314
|
+
// TaskCreated event instead — that is how the user-op case is resolved.
|
|
95
315
|
await api('POST', '/api/v1/a2a/tasks/index', {
|
|
96
316
|
txHash,
|
|
317
|
+
isUserOp: record.isUserOp ?? false,
|
|
97
318
|
taskHash: record.taskHash,
|
|
98
319
|
verificationMode: record.verificationMode,
|
|
99
320
|
verificationCriteria: record.verificationCriteria,
|
|
@@ -106,7 +327,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
106
327
|
publicBrief: record.privacy === 'public' ? record.publicBrief : undefined,
|
|
107
328
|
});
|
|
108
329
|
updateSpend(record.idempotencyKey, { stage: 'indexed' });
|
|
109
|
-
return { taskHash: record.taskHash, txHash: txHash };
|
|
330
|
+
return { taskHash: record.taskHash, txHash: txHash, gas: record.gas };
|
|
110
331
|
}
|
|
111
332
|
async function pollPosted(taskHash, waitSeconds) {
|
|
112
333
|
const deadline = Date.now() + Math.min(60, Math.max(0, waitSeconds)) * 1000;
|
|
@@ -130,7 +351,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
130
351
|
// ── rent_service ──────────────────────────────────────────────────────────
|
|
131
352
|
server.registerTool('rent_service', {
|
|
132
353
|
title: 'Rent an Agent Service',
|
|
133
|
-
description: 'Hire a listed agent service for one call: encrypts your prompt locally (unless privacy=public), funds escrow
|
|
354
|
+
description: 'Hire a listed agent service for one call: encrypts your prompt locally (unless privacy=public), funds escrow, and pins the task to the provider agent. Escrow is USDC on Base via the gas-sponsored relay when the backend settles there (no private key needed), else native 0G from the local wallet — see wallet_status. TWO-STEP: first call returns a price quote + quoteId; re-call with confirm=true and that quoteId to actually spend. Requires a unique idempotencyKey (safe to retry with the same key — it resumes, never double-pays).',
|
|
134
355
|
inputSchema: {
|
|
135
356
|
serviceId: z.number().int().positive().describe('Service id from browse_services / get_service'),
|
|
136
357
|
prompt: z.string().min(1).max(100_000).describe('What you want the agent to do'),
|
|
@@ -142,9 +363,10 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
142
363
|
},
|
|
143
364
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
144
365
|
}, async ({ serviceId, prompt, idempotencyKey, privacy, confirm, quoteId, waitSeconds }) => {
|
|
145
|
-
const
|
|
146
|
-
if ('error' in
|
|
147
|
-
return
|
|
366
|
+
const f = await requireFunding();
|
|
367
|
+
if ('error' in f)
|
|
368
|
+
return f.error;
|
|
369
|
+
const { s, payFrom } = f;
|
|
148
370
|
// Resume path — this key already spent (or partially spent).
|
|
149
371
|
const existing = getSpend(idempotencyKey);
|
|
150
372
|
if (existing) {
|
|
@@ -166,15 +388,29 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
166
388
|
if (!isPublic && !service.agent_public_key) {
|
|
167
389
|
return fail('NO_AGENT_PUBKEY', 'This service\'s agent has no encryption public key — only privacy=public calls are possible');
|
|
168
390
|
}
|
|
391
|
+
// price_raw is stored in the settlement token's base units (the backend
|
|
392
|
+
// compares it against the on-chain amount as-is), so it is 6-decimal
|
|
393
|
+
// USDC on Base and 18-decimal 0G otherwise — format it that way.
|
|
394
|
+
const priceRaw = BigInt(service.price_raw);
|
|
395
|
+
// A service priced in 18-decimal units on a 6-decimal chain would quote
|
|
396
|
+
// as a trillion USDC and relay an approve for it before createTask
|
|
397
|
+
// failed. The backend converts such prices since migration 31, but an
|
|
398
|
+
// older backend may still serve one. 1,000,000 USDC per call is far
|
|
399
|
+
// above any real listing — refuse.
|
|
400
|
+
if (s.mode === 'base' && priceRaw > 1000000n * 10n ** 6n) {
|
|
401
|
+
return fail('PRICE_UNITS_SUSPECT', `service ${serviceId} lists price_raw=${priceRaw} which is ${formatUnits(priceRaw, 6)} USDC — this looks like an 18-decimal 0G price on a USDC chain. Not sending. Re-list the service in USDC base units.`);
|
|
402
|
+
}
|
|
403
|
+
const price = formatUnits(priceRaw, s.decimals);
|
|
169
404
|
if (!confirm) {
|
|
170
|
-
const
|
|
171
|
-
const quote = createQuote('rent', { serviceId, price0G: formatEther(service.price_raw) });
|
|
405
|
+
const quote = createQuote('rent', { serviceId, price, currency: s.symbol });
|
|
172
406
|
return ok({
|
|
173
407
|
quote: {
|
|
174
408
|
service: { id: service.id, name: service.name, agent: service.agent_address },
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
409
|
+
price,
|
|
410
|
+
currency: s.symbol,
|
|
411
|
+
settlement: s.mode,
|
|
412
|
+
payFrom,
|
|
413
|
+
walletBalance: await payFromBalance(s, payFrom),
|
|
178
414
|
privacy: isPublic ? 'public' : 'private',
|
|
179
415
|
quoteId: quote.quoteId,
|
|
180
416
|
},
|
|
@@ -220,6 +456,8 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
220
456
|
verificationCriteria: { min_length: 1 },
|
|
221
457
|
requiredCapabilities: [],
|
|
222
458
|
amountWei: String(service.price_raw),
|
|
459
|
+
settlement: s.mode,
|
|
460
|
+
token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
|
|
223
461
|
durationSecs: 3600,
|
|
224
462
|
createdAt: new Date().toISOString(),
|
|
225
463
|
updatedAt: new Date().toISOString(),
|
|
@@ -232,7 +470,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
232
470
|
catch (err) {
|
|
233
471
|
const code = err.code;
|
|
234
472
|
if (code === 'NOT_TASK_AGENT') {
|
|
235
|
-
return fail(code, 'The API key\'s owner wallet does not match the
|
|
473
|
+
return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow. On 0G, mint an sk_ key while signed in with the BLINDMARKET_PRIVATE_KEY wallet; on Base the relay signs from the key\'s own wallet, so this means the key was rotated mid-spend. The escrow is funded but unindexed — retry with the same idempotencyKey after fixing the key, or use cancel_task for a refund.');
|
|
236
474
|
}
|
|
237
475
|
return fail(code ?? 'RENT_FAILED', err.message);
|
|
238
476
|
}
|
|
@@ -240,10 +478,11 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
240
478
|
// ── post_task ─────────────────────────────────────────────────────────────
|
|
241
479
|
server.registerTool('post_task', {
|
|
242
480
|
title: 'Post a Task to the Open Market',
|
|
243
|
-
description: 'Post a task any matching agent can pick up: encrypts the brief locally and wraps its key to every registered matching executor (or posts it in plaintext with privacy=public), then funds escrow from
|
|
481
|
+
description: 'Post a task any matching agent can pick up: encrypts the brief locally and wraps its key to every registered matching executor (or posts it in plaintext with privacy=public), then funds escrow. Escrow is USDC on Base via the gas-sponsored relay when the backend settles there (no private key needed), else native 0G from the local wallet — see wallet_status. TWO-STEP quote/confirm like rent_service; requires a unique idempotencyKey.',
|
|
244
482
|
inputSchema: {
|
|
245
483
|
instructions: z.string().min(1).max(100_000).describe('The task brief'),
|
|
246
|
-
|
|
484
|
+
amount: z.string().regex(/^\d+(\.\d+)?$/).optional().describe('Escrow amount in the settlement token (e.g. "2.5" — USDC on Base, 0G on 0G) — paid to the worker (90%) on verified completion'),
|
|
485
|
+
amount0G: z.string().regex(/^\d+(\.\d+)?$/).optional().describe('Deprecated alias of `amount` kept for existing callers — same meaning, same units as the settlement token'),
|
|
247
486
|
idempotencyKey: z.string().min(8).max(128).describe('Unique key for this spend — reuse it on retries'),
|
|
248
487
|
capabilities: z.array(z.string()).optional().describe('Optional capability tags to route to matching agents first; empty = every agent'),
|
|
249
488
|
durationSeconds: z.number().int().min(3600).max(90 * 24 * 3600).optional().describe('Deadline seconds from now (default 86400 = 24h)'),
|
|
@@ -252,10 +491,11 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
252
491
|
quoteId: z.string().optional().describe('From the quote step'),
|
|
253
492
|
},
|
|
254
493
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
255
|
-
}, async ({ instructions, amount0G, idempotencyKey, capabilities, durationSeconds, privacy, confirm, quoteId }) => {
|
|
256
|
-
const
|
|
257
|
-
if ('error' in
|
|
258
|
-
return
|
|
494
|
+
}, async ({ instructions, amount, amount0G, idempotencyKey, capabilities, durationSeconds, privacy, confirm, quoteId }) => {
|
|
495
|
+
const f = await requireFunding();
|
|
496
|
+
if ('error' in f)
|
|
497
|
+
return f.error;
|
|
498
|
+
const { s, payFrom } = f;
|
|
259
499
|
const existing = getSpend(idempotencyKey);
|
|
260
500
|
if (existing) {
|
|
261
501
|
if (existing.stage === 'indexed') {
|
|
@@ -269,15 +509,26 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
269
509
|
}
|
|
270
510
|
}
|
|
271
511
|
const isPublic = privacy === 'public';
|
|
272
|
-
const
|
|
512
|
+
const amountStr = amount ?? amount0G;
|
|
513
|
+
if (!amountStr) {
|
|
514
|
+
return fail('AMOUNT_REQUIRED', `Pass \`amount\` — the escrow in ${s.symbol} (e.g. "2.5").`);
|
|
515
|
+
}
|
|
516
|
+
let amountWei;
|
|
517
|
+
try {
|
|
518
|
+
amountWei = parseUnits(amountStr, s.decimals);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return fail('AMOUNT_INVALID', `"${amountStr}" is not a valid ${s.symbol} amount — at most ${s.decimals} decimal places.`);
|
|
522
|
+
}
|
|
273
523
|
if (!confirm) {
|
|
274
|
-
const
|
|
275
|
-
const quote = createQuote('post', { amount0G });
|
|
524
|
+
const quote = createQuote('post', { amount: amountStr, currency: s.symbol });
|
|
276
525
|
return ok({
|
|
277
526
|
quote: {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
527
|
+
escrow: amountStr,
|
|
528
|
+
currency: s.symbol,
|
|
529
|
+
settlement: s.mode,
|
|
530
|
+
payFrom,
|
|
531
|
+
walletBalance: await payFromBalance(s, payFrom),
|
|
281
532
|
privacy: isPublic ? 'public' : 'private',
|
|
282
533
|
capabilities: capabilities ?? [],
|
|
283
534
|
quoteId: quote.quoteId,
|
|
@@ -333,6 +584,8 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
333
584
|
verificationCriteria: { min_length: 10, pass_threshold: 60 },
|
|
334
585
|
requiredCapabilities: capabilities ?? [],
|
|
335
586
|
amountWei: amountWei.toString(),
|
|
587
|
+
settlement: s.mode,
|
|
588
|
+
token: s.mode === 'base' ? s.usdcAddress : ZERO_TOKEN,
|
|
336
589
|
durationSecs: durationSeconds ?? 86400,
|
|
337
590
|
createdAt: new Date().toISOString(),
|
|
338
591
|
updatedAt: new Date().toISOString(),
|
|
@@ -344,7 +597,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
344
597
|
catch (err) {
|
|
345
598
|
const code = err.code;
|
|
346
599
|
if (code === 'NOT_TASK_AGENT') {
|
|
347
|
-
return fail(code, 'The API key\'s owner wallet does not match the
|
|
600
|
+
return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow. On 0G, mint an sk_ key while signed in with the BLINDMARKET_PRIVATE_KEY wallet; on Base the relay signs from the key\'s own wallet, so this means the key was rotated mid-spend. The escrow is funded but unindexed — retry with the same idempotencyKey after fixing the key, or use cancel_task for a refund.');
|
|
348
601
|
}
|
|
349
602
|
return fail(code ?? 'POST_FAILED', err.message);
|
|
350
603
|
}
|
|
@@ -359,5 +612,511 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
359
612
|
},
|
|
360
613
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
361
614
|
}, async ({ taskHash, waitSeconds }) => ok(await pollPosted(taskHash, waitSeconds ?? 30)));
|
|
615
|
+
// ── cancel_task / claim_timeout ───────────────────────────────────────────
|
|
616
|
+
//
|
|
617
|
+
// The two ways escrow comes back to the poster. BlindEscrow splits them by
|
|
618
|
+
// task status and they are NOT interchangeable — calling the wrong one
|
|
619
|
+
// reverts on-chain:
|
|
620
|
+
//
|
|
621
|
+
// Funded (0) → cancelTask, no deadline required
|
|
622
|
+
// Assigned/Submitted/Verified (1-3) → claimTimeout, deadline must have passed
|
|
623
|
+
// Disputed (6) → claimTimeout, but only once
|
|
624
|
+
// DISPUTE_WINDOW has elapsed
|
|
625
|
+
//
|
|
626
|
+
// So the quote step reads the live status first and refuses a path the
|
|
627
|
+
// contract would reject, rather than letting the caller burn gas to find out.
|
|
628
|
+
//
|
|
629
|
+
// Both send a transaction, so both carry the same quote/confirm +
|
|
630
|
+
// idempotencyKey treatment as post_task. Here the ledger's job is to stop a
|
|
631
|
+
// retry from broadcasting a SECOND refund tx while the first is still
|
|
632
|
+
// unconfirmed — the first would land and the second would revert.
|
|
633
|
+
//
|
|
634
|
+
// Chain note: the backend resolves which chain holds the task
|
|
635
|
+
// (resolveTaskChainById) and builds the unsigned tx for it; this side only
|
|
636
|
+
// decides who signs — the local wallet on 0G, the Privy relay on Base — and
|
|
637
|
+
// verifies the tx targets the escrow that mode expects before sending
|
|
638
|
+
// (verifyTarget). Task state is read from the chain the mode names, not
|
|
639
|
+
// from GET /tasks/:id, which is bound to the 0G escrow.
|
|
640
|
+
const STATUS_NAMES = ['Funded', 'Assigned', 'Submitted', 'Verified', 'Completed', 'Cancelled', 'Disputed'];
|
|
641
|
+
function statusName(status) {
|
|
642
|
+
return STATUS_NAMES[status] ?? `Unknown(${status})`;
|
|
643
|
+
}
|
|
644
|
+
/** Resolve a task id-or-hash to its live on-chain state.
|
|
645
|
+
*
|
|
646
|
+
* GET /api/v1/tasks/:id accepts either form — but it reads the 0G escrow
|
|
647
|
+
* only (escrowService.getTask is bound to the 0G contract), so on Base its
|
|
648
|
+
* struct is for whatever 0G task happens to share the id. In Base mode we
|
|
649
|
+
* use it just to turn a hash into an id, then read the struct from the
|
|
650
|
+
* Base escrow ourselves over the read-only provider. */
|
|
651
|
+
async function loadTask(s, task) {
|
|
652
|
+
if (s.mode !== 'base') {
|
|
653
|
+
return api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
|
|
654
|
+
}
|
|
655
|
+
// Base: the escrow is the authority, and we can read it directly. Only ask
|
|
656
|
+
// the backend when we need a hash resolved to an id — that endpoint makes
|
|
657
|
+
// several Redis round trips and is the slowest thing in this path, so a
|
|
658
|
+
// numeric id must not pay for it.
|
|
659
|
+
let taskId;
|
|
660
|
+
if (/^\d+$/.test(task)) {
|
|
661
|
+
taskId = task;
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
const viaBackend = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
|
|
665
|
+
taskId = viaBackend.taskId;
|
|
666
|
+
}
|
|
667
|
+
const escrow = new Contract(s.escrowAddress, ESCROW_READ_ABI, s.provider);
|
|
668
|
+
const t = await escrow.getTask(BigInt(taskId));
|
|
669
|
+
if (String(t.agent).toLowerCase() === ZERO_TOKEN) {
|
|
670
|
+
const e = new Error(`task ${taskId} does not exist on the Base escrow ${s.escrowAddress} — it is probably a 0G task; handle it with BLINDMARKET_SETTLEMENT=0g`);
|
|
671
|
+
e.code = 'TASK_NOT_ON_BASE';
|
|
672
|
+
throw e;
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
taskId,
|
|
676
|
+
taskHash: String(t.taskHash),
|
|
677
|
+
status: Number(t.status),
|
|
678
|
+
amount: String(t.amount),
|
|
679
|
+
deadline: String(t.deadline),
|
|
680
|
+
token: String(t.token),
|
|
681
|
+
submissionAttempts: Number(t.submissionAttempts),
|
|
682
|
+
decimals: s.decimals,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
/** Amount formatted against the task's OWN decimals — a Base task settles in
|
|
686
|
+
* USDC (6), not the wallet's native 18. */
|
|
687
|
+
function refundAmount(detail) {
|
|
688
|
+
return formatUnits(BigInt(detail.amount), detail.decimals ?? 18);
|
|
689
|
+
}
|
|
690
|
+
/** The refund has landed when the task reads Cancelled on-chain. Checking
|
|
691
|
+
* state rather than a receipt is what makes a relayed user-op decidable
|
|
692
|
+
* (there is no receipt to poll), and it is a cheap truth check for the
|
|
693
|
+
* local-signing path too. */
|
|
694
|
+
async function waitCancelled(s, taskId) {
|
|
695
|
+
for (let i = 0; i < 30; i++) {
|
|
696
|
+
const detail = await loadTask(s, String(taskId)).catch(() => null);
|
|
697
|
+
if (detail && Number(detail.status) === 5)
|
|
698
|
+
return;
|
|
699
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
700
|
+
}
|
|
701
|
+
const e = new Error(`task ${taskId} still not Cancelled on-chain after 90s — retry with the same idempotencyKey to keep waiting`);
|
|
702
|
+
e.code = 'REFUND_PENDING';
|
|
703
|
+
throw e;
|
|
704
|
+
}
|
|
705
|
+
/** Broadcast the refund and wait for it — the shared tail of both tools.
|
|
706
|
+
* Resumable: a record past 'created' waits on the tx it already saved.
|
|
707
|
+
* Same two paths as fundAndIndex: local wallet on 0G, Privy relay on Base.
|
|
708
|
+
* The backend resolves which chain holds the task and builds the tx for
|
|
709
|
+
* it; this only decides who signs. */
|
|
710
|
+
async function sendRefund(record) {
|
|
711
|
+
const s = await settlement();
|
|
712
|
+
const taskId = record.taskId;
|
|
713
|
+
let { txHash } = record;
|
|
714
|
+
if (record.settlement && record.settlement !== s.mode) {
|
|
715
|
+
const e = new Error(`refund ${record.idempotencyKey} started on ${record.settlement} but the backend now settles on ${s.mode} — finish it from the web app`);
|
|
716
|
+
e.code = 'SETTLEMENT_CHANGED';
|
|
717
|
+
throw e;
|
|
718
|
+
}
|
|
719
|
+
if (record.stage === 'created') {
|
|
720
|
+
const route = record.kind === 'cancel' ? 'cancel' : 'timeout';
|
|
721
|
+
const { unsignedTx } = await api('POST', `/api/v1/tasks/${taskId}/${route}`);
|
|
722
|
+
// The backend resolves the chain that holds the task and builds for it;
|
|
723
|
+
// the tx carries no chainId. If that chain is not the one this mode
|
|
724
|
+
// broadcasts on, stop here — relaying a 0G refund onto Base lands on an
|
|
725
|
+
// address with no escrow and burns the gas.
|
|
726
|
+
await verifyTarget(s, unsignedTx.to, `${route}Task`);
|
|
727
|
+
if (s.mode === 'base') {
|
|
728
|
+
const { hash, isUserOp, gas } = await relaySend(s, { to: unsignedTx.to, data: unsignedTx.data });
|
|
729
|
+
// Persist BEFORE waiting, same reasoning as the 0G branch below.
|
|
730
|
+
updateSpend(record.idempotencyKey, { stage: 'sent', txHash: hash, isUserOp, gas });
|
|
731
|
+
record.gas = gas;
|
|
732
|
+
txHash = hash;
|
|
733
|
+
record.isUserOp = isUserOp;
|
|
734
|
+
await waitRelayed(s, hash, isUserOp);
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
const tx = await walletCtx.wallet.sendTransaction({
|
|
738
|
+
to: unsignedTx.to,
|
|
739
|
+
data: unsignedTx.data,
|
|
740
|
+
gasLimit: GAS_LIMIT,
|
|
741
|
+
});
|
|
742
|
+
// Persist the hash BEFORE waiting, same reasoning as fundAndIndex: a
|
|
743
|
+
// crash mid-confirmation must resume onto THIS tx, not broadcast another.
|
|
744
|
+
updateSpend(record.idempotencyKey, { stage: 'sent', txHash: tx.hash });
|
|
745
|
+
txHash = tx.hash;
|
|
746
|
+
await tx.wait();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
else if (txHash) {
|
|
750
|
+
if (s.mode === 'base')
|
|
751
|
+
await waitRelayed(s, txHash, record.isUserOp ?? false);
|
|
752
|
+
else
|
|
753
|
+
await walletCtx.provider.waitForTransaction(txHash);
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
throw new Error(`Spend record ${record.idempotencyKey} is at stage '${record.stage}' with no txHash — cannot resume safely`);
|
|
757
|
+
}
|
|
758
|
+
await waitCancelled(s, taskId);
|
|
759
|
+
updateSpend(record.idempotencyKey, { stage: 'confirmed' });
|
|
760
|
+
return { taskId, txHash: txHash, gas: record.gas };
|
|
761
|
+
}
|
|
762
|
+
/** Shared resume arm: an idempotencyKey that has already moved. The kind
|
|
763
|
+
* guard matters — the ledger is one namespace shared with rent/post, and
|
|
764
|
+
* resuming a 'post' record through the refund path would wait on the FUNDING
|
|
765
|
+
* tx and then mark that record confirmed, corrupting it. */
|
|
766
|
+
async function resumeRefund(existing, kind) {
|
|
767
|
+
if (existing.kind !== kind) {
|
|
768
|
+
return fail('IDEMPOTENCY_KEY_REUSED', `idempotencyKey "${existing.idempotencyKey}" already belongs to a '${existing.kind}' spend (task ${existing.taskId ?? existing.taskHash}). Use a fresh key for this refund.`);
|
|
769
|
+
}
|
|
770
|
+
if (existing.stage === 'confirmed') {
|
|
771
|
+
return ok({
|
|
772
|
+
resumed: true,
|
|
773
|
+
taskId: existing.taskId,
|
|
774
|
+
txHash: existing.txHash,
|
|
775
|
+
hint: 'Already refunded — this idempotencyKey completed earlier.',
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
return ok({ resumed: true, ...(await sendRefund(existing)) });
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
return fail(err.code ?? 'RESUME_FAILED', err.message);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
server.registerTool('cancel_task', {
|
|
786
|
+
title: 'Cancel a Task and Reclaim Escrow',
|
|
787
|
+
description: 'Reclaim the escrow on a task you posted that has NOT been assigned to a worker (status Funded). Works immediately — no deadline wait. For a task that was assigned but never delivered, use claim_timeout instead. TWO-STEP quote/confirm; requires a unique idempotencyKey.',
|
|
788
|
+
inputSchema: {
|
|
789
|
+
task: z.string().min(1).describe('Task id (e.g. "51") or the 0x task hash returned by post_task'),
|
|
790
|
+
idempotencyKey: z.string().min(8).max(128).describe('Unique key for this refund — reuse it on retries'),
|
|
791
|
+
confirm: z.boolean().optional().describe('Set true (with quoteId) to send the transaction'),
|
|
792
|
+
quoteId: z.string().optional().describe('From the quote step'),
|
|
793
|
+
},
|
|
794
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
795
|
+
}, async ({ task, idempotencyKey, confirm, quoteId }) => {
|
|
796
|
+
const f = await requireFunding();
|
|
797
|
+
if ('error' in f)
|
|
798
|
+
return f.error;
|
|
799
|
+
const { s, payFrom } = f;
|
|
800
|
+
const existing = getSpend(idempotencyKey);
|
|
801
|
+
if (existing)
|
|
802
|
+
return resumeRefund(existing, 'cancel');
|
|
803
|
+
let detail;
|
|
804
|
+
try {
|
|
805
|
+
detail = await loadTask(s, task);
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
return fail(err.code ?? 'TASK_LOOKUP_FAILED', err.message);
|
|
809
|
+
}
|
|
810
|
+
const status = Number(detail.status);
|
|
811
|
+
if (status !== 0) {
|
|
812
|
+
const alt = status >= 1 && status <= 3
|
|
813
|
+
? ' Use claim_timeout instead, once the deadline has passed.'
|
|
814
|
+
: ' The escrow is already settled — nothing to reclaim.';
|
|
815
|
+
return fail('WRONG_REFUND_PATH', `Task ${detail.taskId} is ${statusName(status)}, and cancelTask only accepts Funded tasks.${alt}`);
|
|
816
|
+
}
|
|
817
|
+
if (!confirm) {
|
|
818
|
+
const quote = createQuote('cancel', { taskId: detail.taskId });
|
|
819
|
+
return ok({
|
|
820
|
+
quote: {
|
|
821
|
+
action: 'cancelTask',
|
|
822
|
+
taskId: detail.taskId,
|
|
823
|
+
status: statusName(status),
|
|
824
|
+
refund: refundAmount(detail),
|
|
825
|
+
refundTo: payFrom,
|
|
826
|
+
settlement: s.mode,
|
|
827
|
+
quoteId: quote.quoteId,
|
|
828
|
+
},
|
|
829
|
+
next: `Re-call cancel_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
if (!quoteId || !consumeQuote(quoteId, 'cancel')) {
|
|
833
|
+
return fail('QUOTE_REQUIRED', 'Get a quote first (call without confirm), then re-call with confirm=true and the returned quoteId (quotes are single-use and expire after 10 minutes)');
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
const record = {
|
|
837
|
+
idempotencyKey,
|
|
838
|
+
kind: 'cancel',
|
|
839
|
+
stage: 'created',
|
|
840
|
+
settlement: s.mode,
|
|
841
|
+
taskId: Number(detail.taskId),
|
|
842
|
+
taskHash: detail.taskHash,
|
|
843
|
+
amountWei: detail.amount,
|
|
844
|
+
createdAt: new Date().toISOString(),
|
|
845
|
+
updatedAt: new Date().toISOString(),
|
|
846
|
+
};
|
|
847
|
+
putSpend(record);
|
|
848
|
+
const done = await sendRefund(record);
|
|
849
|
+
return ok({ ...done, refunded: refundAmount(detail), hint: 'Escrow returned to the posting wallet.' });
|
|
850
|
+
}
|
|
851
|
+
catch (err) {
|
|
852
|
+
return fail(err.code ?? 'CANCEL_FAILED', err.message);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
server.registerTool('claim_timeout', {
|
|
856
|
+
title: 'Reclaim Escrow After the Deadline',
|
|
857
|
+
description: 'Reclaim the escrow on a task that WAS assigned but never completed, once its deadline has passed (status Assigned, Submitted, or Verified-failed). For a task no worker ever picked up, use cancel_task instead — it needs no deadline. TWO-STEP quote/confirm; requires a unique idempotencyKey.',
|
|
858
|
+
inputSchema: {
|
|
859
|
+
task: z.string().min(1).describe('Task id (e.g. "51") or the 0x task hash returned by post_task'),
|
|
860
|
+
idempotencyKey: z.string().min(8).max(128).describe('Unique key for this refund — reuse it on retries'),
|
|
861
|
+
confirm: z.boolean().optional().describe('Set true (with quoteId) to send the transaction'),
|
|
862
|
+
quoteId: z.string().optional().describe('From the quote step'),
|
|
863
|
+
},
|
|
864
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
865
|
+
}, async ({ task, idempotencyKey, confirm, quoteId }) => {
|
|
866
|
+
const f = await requireFunding();
|
|
867
|
+
if ('error' in f)
|
|
868
|
+
return f.error;
|
|
869
|
+
const { s, payFrom } = f;
|
|
870
|
+
const existing = getSpend(idempotencyKey);
|
|
871
|
+
if (existing)
|
|
872
|
+
return resumeRefund(existing, 'timeout');
|
|
873
|
+
let detail;
|
|
874
|
+
try {
|
|
875
|
+
detail = await loadTask(s, task);
|
|
876
|
+
}
|
|
877
|
+
catch (err) {
|
|
878
|
+
return fail(err.code ?? 'TASK_LOOKUP_FAILED', err.message);
|
|
879
|
+
}
|
|
880
|
+
const status = Number(detail.status);
|
|
881
|
+
if (status === 0) {
|
|
882
|
+
return fail('WRONG_REFUND_PATH', `Task ${detail.taskId} is Funded — claimTimeout reverts on Funded tasks because no worker was ever assigned. Use cancel_task, which works right now with no deadline wait.`);
|
|
883
|
+
}
|
|
884
|
+
if (status === 4 || status === 5) {
|
|
885
|
+
return fail('NOTHING_TO_REFUND', `Task ${detail.taskId} is ${statusName(status)} — the escrow is already settled.`);
|
|
886
|
+
}
|
|
887
|
+
const deadline = BigInt(detail.deadline);
|
|
888
|
+
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
889
|
+
if (now < deadline) {
|
|
890
|
+
return fail('DEADLINE_NOT_REACHED', `Task ${detail.taskId} is still live until ${new Date(Number(deadline) * 1000).toISOString()} — claimTimeout reverts before then.`);
|
|
891
|
+
}
|
|
892
|
+
if (!confirm) {
|
|
893
|
+
const quote = createQuote('timeout', { taskId: detail.taskId });
|
|
894
|
+
return ok({
|
|
895
|
+
quote: {
|
|
896
|
+
action: 'claimTimeout',
|
|
897
|
+
taskId: detail.taskId,
|
|
898
|
+
status: statusName(status),
|
|
899
|
+
deadlinePassed: new Date(Number(deadline) * 1000).toISOString(),
|
|
900
|
+
refund: refundAmount(detail),
|
|
901
|
+
refundTo: payFrom,
|
|
902
|
+
settlement: s.mode,
|
|
903
|
+
// DISPUTE_WINDOW is enforced on-chain and disputedAt is not exposed
|
|
904
|
+
// here, so a disputed task can still revert after this quote.
|
|
905
|
+
...(status === 6 ? { note: 'Task is Disputed — this only succeeds once the on-chain DISPUTE_WINDOW has elapsed since the dispute was raised, otherwise it reverts with DisputeWindowActive.' } : {}),
|
|
906
|
+
quoteId: quote.quoteId,
|
|
907
|
+
},
|
|
908
|
+
next: `Re-call claim_timeout with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
if (!quoteId || !consumeQuote(quoteId, 'timeout')) {
|
|
912
|
+
return fail('QUOTE_REQUIRED', 'Get a quote first (call without confirm), then re-call with confirm=true and the returned quoteId (quotes are single-use and expire after 10 minutes)');
|
|
913
|
+
}
|
|
914
|
+
try {
|
|
915
|
+
const record = {
|
|
916
|
+
idempotencyKey,
|
|
917
|
+
kind: 'timeout',
|
|
918
|
+
stage: 'created',
|
|
919
|
+
settlement: s.mode,
|
|
920
|
+
taskId: Number(detail.taskId),
|
|
921
|
+
taskHash: detail.taskHash,
|
|
922
|
+
amountWei: detail.amount,
|
|
923
|
+
createdAt: new Date().toISOString(),
|
|
924
|
+
updatedAt: new Date().toISOString(),
|
|
925
|
+
};
|
|
926
|
+
putSpend(record);
|
|
927
|
+
const done = await sendRefund(record);
|
|
928
|
+
return ok({ ...done, refunded: refundAmount(detail), hint: 'Escrow returned to the posting wallet.' });
|
|
929
|
+
}
|
|
930
|
+
catch (err) {
|
|
931
|
+
return fail(err.code ?? 'TIMEOUT_CLAIM_FAILED', err.message);
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
// ── Executor side (Base) ─────────────────────────────────────────────────
|
|
935
|
+
//
|
|
936
|
+
// accept_task (tools.ts) already assigns you on-chain: the backend awaits
|
|
937
|
+
// marketplaceAssign inside POST /accept. What was missing is everything
|
|
938
|
+
// after: the worker must sign submitEvidence itself (onlyWorker), and the
|
|
939
|
+
// platform worker (backend/agents/worker.js) only carries a 0G signer, so
|
|
940
|
+
// a Base task could be accepted but never delivered. These two tools close
|
|
941
|
+
// that gap for an MCP executor whose wallet is a Privy relay wallet — no
|
|
942
|
+
// local key, gas negotiated by the backend like every other relayed send.
|
|
943
|
+
/** Poll the escrow until the task reads `want`, or give up. */
|
|
944
|
+
async function waitStatus(s, taskId, want, label) {
|
|
945
|
+
let last = -1;
|
|
946
|
+
for (let i = 0; i < 30; i++) {
|
|
947
|
+
last = Number((await loadTask(s, taskId)).status);
|
|
948
|
+
if (last === want)
|
|
949
|
+
return last;
|
|
950
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
951
|
+
}
|
|
952
|
+
const e = new Error(`task ${taskId} still reads ${statusName(last)} after 90s waiting for ${label} — re-call complete_task to resume from the chain's state`);
|
|
953
|
+
e.code = 'STATE_PENDING';
|
|
954
|
+
throw e;
|
|
955
|
+
}
|
|
956
|
+
server.registerTool('fetch_brief', {
|
|
957
|
+
title: 'Fetch a Task Brief',
|
|
958
|
+
description: "Download a task's brief by rootHash (accept_task returns it). Public briefs come back as text. For a PRIVATE brief pass the wrappedKey accept_task returned: it is decrypted with BLINDMARKET_PRIVATE_KEY, which must be the key whose public half you registered (wallet_status shows it as executorPublicKey).",
|
|
959
|
+
inputSchema: {
|
|
960
|
+
rootHash: z.string().min(32).max(80).describe('rootHash from accept_task or list_open_tasks'),
|
|
961
|
+
wrappedKey: z.string().optional().describe('ECIES-wrapped AES key from accept_task (hex, no 0x). Required for private briefs.'),
|
|
962
|
+
},
|
|
963
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
964
|
+
}, async ({ rootHash, wrappedKey }) => {
|
|
965
|
+
try {
|
|
966
|
+
// Buffer.from(x, 'hex') stops silently at the first bad char, so a
|
|
967
|
+
// pasted "0x…" or a truncated blob would otherwise surface as
|
|
968
|
+
// WRONG_KEY after a storage round-trip. Validate first.
|
|
969
|
+
// ECIES blob = 65-byte ephemeral pubkey + 12 IV + 16 tag + ciphertext.
|
|
970
|
+
const keyHex = wrappedKey?.replace(/^0x/i, '');
|
|
971
|
+
if (keyHex !== undefined && (!/^[0-9a-fA-F]+$/.test(keyHex) || keyHex.length % 2 !== 0 || keyHex.length < (65 + 12 + 16 + 1) * 2)) {
|
|
972
|
+
return fail('INVALID_WRAPPED_KEY', 'wrappedKey must be the full hex ECIES blob from accept_task (even length, at least 94 bytes). Pass it exactly as returned.');
|
|
973
|
+
}
|
|
974
|
+
const { blob } = await api('GET', `/api/v1/storage/${encodeURIComponent(rootHash)}`);
|
|
975
|
+
const buf = Buffer.from(blob, 'base64');
|
|
976
|
+
if (keyHex !== undefined) {
|
|
977
|
+
if (!walletCtx) {
|
|
978
|
+
return fail('NO_WALLET', 'A private brief is decrypted with BLINDMARKET_PRIVATE_KEY — set it to the key whose public half you registered as executor.');
|
|
979
|
+
}
|
|
980
|
+
let aesKey;
|
|
981
|
+
try {
|
|
982
|
+
aesKey = eciesDecrypt(Buffer.from(keyHex, 'hex'), walletCtx.wallet.privateKey);
|
|
983
|
+
}
|
|
984
|
+
catch (e) {
|
|
985
|
+
return fail('WRONG_KEY', `Could not unwrap the brief key with the local wallet ${walletCtx.wallet.address}: ${e.message}. The poster wrapped it to the pubkey on your executor registration — wallet_status shows the pubkey this process derives; they must match.`);
|
|
986
|
+
}
|
|
987
|
+
let brief;
|
|
988
|
+
try {
|
|
989
|
+
brief = aesDecrypt(buf, aesKey).toString('utf8');
|
|
990
|
+
}
|
|
991
|
+
catch (e) {
|
|
992
|
+
// The key unwrapped fine, so the BLOB is the problem: a public
|
|
993
|
+
// (plaintext) brief passed with a wrappedKey, or the wrong rootHash.
|
|
994
|
+
return fail('BRIEF_DECRYPT_FAILED', `The wrapped key unwrapped, but the blob at ${rootHash} did not decrypt with it: ${e.message}. If the task is public, call fetch_brief without wrappedKey; otherwise check the rootHash came from the same accept_task response.`);
|
|
995
|
+
}
|
|
996
|
+
return ok({ rootHash, bytes: buf.length, brief, decrypted: true });
|
|
997
|
+
}
|
|
998
|
+
const text = buf.toString('utf8');
|
|
999
|
+
if (text.includes('�')) {
|
|
1000
|
+
return fail('ENCRYPTED_BRIEF', 'This brief is encrypted (private task). Re-call fetch_brief with the wrappedKey from accept_task.');
|
|
1001
|
+
}
|
|
1002
|
+
return ok({ rootHash, bytes: buf.length, brief: text });
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
return fail(err.code ?? 'BRIEF_FETCH_FAILED', err.message);
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
server.registerTool('complete_task', {
|
|
1009
|
+
title: 'Deliver a Task Result and Settle',
|
|
1010
|
+
description: 'Executor side, after accept_task: submits your result, sends submitEvidence from YOUR wallet (Base: through the backend relay, gas in USDC; 0G: signed locally with BLINDMARKET_PRIVATE_KEY), then asks the backend to verify and release the escrow to you. If the last verification FAILED, calling this again resubmits — the contract allows up to 3 attempts before the deadline. Safe to re-call: it resumes from whatever stage the escrow shows.',
|
|
1011
|
+
inputSchema: {
|
|
1012
|
+
task: z.string().regex(/^0x[0-9a-fA-F]{64}$/).describe('The 0x task hash — A2A tasks are addressed by hash, not by numeric id'),
|
|
1013
|
+
output: z.string().min(1).max(200_000).describe('Your result. Verification judges this text (auto mode scores it against the poster\'s criteria).'),
|
|
1014
|
+
},
|
|
1015
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1016
|
+
}, async ({ task, output }) => {
|
|
1017
|
+
const f = await requireFunding();
|
|
1018
|
+
if ('error' in f)
|
|
1019
|
+
return f.error;
|
|
1020
|
+
const { s, payFrom } = f;
|
|
1021
|
+
// requireFunding returns an error on 0G without a wallet, so walletCtx is
|
|
1022
|
+
// non-null whenever s.mode is '0g' from here on.
|
|
1023
|
+
let detail;
|
|
1024
|
+
try {
|
|
1025
|
+
detail = await loadTask(s, task);
|
|
1026
|
+
}
|
|
1027
|
+
catch (err) {
|
|
1028
|
+
return fail(err.code ?? 'TASK_LOOKUP_FAILED', err.message);
|
|
1029
|
+
}
|
|
1030
|
+
// 0 Funded · 1 Assigned · 2 Submitted · 3 Verified · 4 Completed · 5 Cancelled · 6 Disputed
|
|
1031
|
+
let status = Number(detail.status);
|
|
1032
|
+
if (status === 0) {
|
|
1033
|
+
return fail('NOT_ASSIGNED', `Task ${detail.taskId} is still Funded — call accept_task("${task}") first; the backend assigns you on-chain as part of accept.`);
|
|
1034
|
+
}
|
|
1035
|
+
// Verified(3) = the last round FAILED and the escrow is still locked. The
|
|
1036
|
+
// contract lets the worker submitEvidence again from here, up to
|
|
1037
|
+
// MAX_SUBMISSION_ATTEMPTS (3) and before the deadline. The backend's
|
|
1038
|
+
// /submit enforces all three on-chain gates; check them here too so the
|
|
1039
|
+
// caller gets the reason instead of a relayed revert.
|
|
1040
|
+
const isRetry = status === 3;
|
|
1041
|
+
// The contract reverts DeadlineReached from Assigned as well as from a
|
|
1042
|
+
// retry. With an explicit gasLimit ethers skips estimateGas, so a
|
|
1043
|
+
// predictable revert would be mined and charged — refuse it here.
|
|
1044
|
+
if ((status === 1 || isRetry) && BigInt(Math.floor(Date.now() / 1000)) >= BigInt(detail.deadline)) {
|
|
1045
|
+
return fail('DEADLINE_REACHED', `Task ${detail.taskId} is past its deadline — no further submissions are accepted on-chain. The poster can reclaim the escrow with claim_timeout.`);
|
|
1046
|
+
}
|
|
1047
|
+
if (isRetry) {
|
|
1048
|
+
const attempts = detail.submissionAttempts ?? 0;
|
|
1049
|
+
if (attempts >= 3) {
|
|
1050
|
+
return fail('MAX_ATTEMPTS_REACHED', `Task ${detail.taskId} has used all 3 submission attempts (${attempts}/3). The escrow stays locked until the poster reclaims it after the deadline.`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (status >= 4) {
|
|
1054
|
+
return ok({ taskId: detail.taskId, taskHash: detail.taskHash, onChainStatus: statusName(status), hint: 'Already settled — nothing to do.' });
|
|
1055
|
+
}
|
|
1056
|
+
let submitTxHash;
|
|
1057
|
+
let gas;
|
|
1058
|
+
try {
|
|
1059
|
+
if (status === 1 || isRetry) {
|
|
1060
|
+
const sub = await api('POST', `/api/v1/a2a/tasks/${task}/submit`, { resultData: { output } });
|
|
1061
|
+
const tx = sub.unsignedSubmitEvidence;
|
|
1062
|
+
await verifyTarget(s, tx.to, 'submitEvidence');
|
|
1063
|
+
if (s.mode === 'base') {
|
|
1064
|
+
const sent = await relaySend(s, { to: tx.to, data: tx.data });
|
|
1065
|
+
submitTxHash = sent.hash;
|
|
1066
|
+
gas = sent.gas;
|
|
1067
|
+
await waitRelayed(s, sent.hash, sent.isUserOp);
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
// submitEvidence is onlyWorker: the backend built the tx for the
|
|
1071
|
+
// API key's wallet (tx.from). If BLINDMARKET_PRIVATE_KEY is a
|
|
1072
|
+
// different wallet the tx reverts on-chain — with gasLimit set,
|
|
1073
|
+
// that revert is mined and paid for. Refuse before sending.
|
|
1074
|
+
if (tx.from && tx.from.toLowerCase() !== walletCtx.wallet.address.toLowerCase()) {
|
|
1075
|
+
return fail('WALLET_MISMATCH', `The backend assigned this task to ${tx.from} (the wallet behind BLINDMARKET_API_KEY), but BLINDMARKET_PRIVATE_KEY is ${walletCtx.wallet.address}. submitEvidence is worker-only — set the private key of ${tx.from}.`);
|
|
1076
|
+
}
|
|
1077
|
+
// 0G: sign locally, exactly as fundAndIndex does for createTask.
|
|
1078
|
+
// The backend pins chainId onto the tx; ethers refuses to send it
|
|
1079
|
+
// if this wallet's provider is on a different network — the guard
|
|
1080
|
+
// against a Base tx reaching a 0G signer.
|
|
1081
|
+
const tx0g = await walletCtx.wallet.sendTransaction({
|
|
1082
|
+
to: tx.to, data: tx.data, gasLimit: GAS_LIMIT,
|
|
1083
|
+
...(tx.chainId !== undefined ? { chainId: tx.chainId } : {}),
|
|
1084
|
+
});
|
|
1085
|
+
submitTxHash = tx0g.hash;
|
|
1086
|
+
await tx0g.wait();
|
|
1087
|
+
}
|
|
1088
|
+
status = await waitStatus(s, detail.taskId, 2, 'Submitted');
|
|
1089
|
+
}
|
|
1090
|
+
// Backend runs the verification for this task's mode and, on a pass,
|
|
1091
|
+
// sends completeVerification from the marketplace signer — which is
|
|
1092
|
+
// what releases the USDC to payFrom. The call awaits that tx.
|
|
1093
|
+
const fin = await api('POST', `/api/v1/a2a/tasks/${task}/finalize`);
|
|
1094
|
+
const after = await loadTask(s, detail.taskId);
|
|
1095
|
+
const done = Number(after.status) === 4;
|
|
1096
|
+
return ok({
|
|
1097
|
+
taskId: detail.taskId,
|
|
1098
|
+
taskHash: detail.taskHash,
|
|
1099
|
+
submitTxHash,
|
|
1100
|
+
gas,
|
|
1101
|
+
verification: fin.verificationResult ?? null,
|
|
1102
|
+
backendStatus: fin.status,
|
|
1103
|
+
onChainStatus: statusName(Number(after.status)),
|
|
1104
|
+
paidTo: done ? payFrom : undefined,
|
|
1105
|
+
submissionAttempts: after.submissionAttempts,
|
|
1106
|
+
hint: done
|
|
1107
|
+
? `Escrow released: ${formatUnits(BigInt(after.amount), after.decimals ?? 6)} ${s.mode === 'base' ? 'USDC' : '0G'} minus the marketplace fee is now in ${payFrom}.`
|
|
1108
|
+
: fin.awaitingPosterApproval
|
|
1109
|
+
? 'Manual-verification task: the poster must approve via verify_task before the escrow releases.'
|
|
1110
|
+
: `Verification did not pass (${(fin.verificationResult?.reasons ?? []).join('; ') || 'no reasons given'}). ` +
|
|
1111
|
+
((after.submissionAttempts ?? 0) < 3
|
|
1112
|
+
? `Revise and call complete_task again — ${3 - (after.submissionAttempts ?? 0)} attempt(s) left before the deadline.`
|
|
1113
|
+
: 'No attempts left; the escrow stays locked until the poster reclaims it.'),
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
catch (err) {
|
|
1117
|
+
return fail(err.code ?? 'COMPLETE_FAILED', err.message);
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
return { settlement };
|
|
362
1121
|
}
|
|
363
1122
|
//# sourceMappingURL=rent.js.map
|