@blindmarket/mcp-server 0.4.0 → 0.6.0
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 +98 -19
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/rent.d.ts.map +1 -1
- package/dist/rent.js +636 -96
- package/dist/rent.js.map +1 -1
- package/dist/settlement.d.ts +42 -2
- package/dist/settlement.d.ts.map +1 -1
- package/dist/settlement.js +72 -7
- package/dist/settlement.js.map +1 -1
- package/dist/state.d.ts +49 -4
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +23 -6
- package/dist/state.js.map +1 -1
- package/dist/wallet.d.ts.map +1 -1
- package/dist/wallet.js +5 -3
- package/dist/wallet.js.map +1 -1
- package/package.json +2 -2
package/dist/rent.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { Contract, Interface, formatUnits, parseUnits } from 'ethers';
|
|
3
|
-
import { aesDecrypt, aesEncrypt, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
|
|
4
|
-
import { createQuote, consumeQuote, getSpend, putSpend, updateSpend } from './state.js';
|
|
5
|
-
import { createSettlementResolver } from './settlement.js';
|
|
2
|
+
import { Contract, Interface, JsonRpcProvider, formatUnits, keccak256, parseUnits, toUtf8Bytes } from 'ethers';
|
|
3
|
+
import { aesDecrypt, aesEncrypt, derivePublicKeyHex, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
|
|
4
|
+
import { createQuote, consumeQuote, getSpend, putSpend, updateSpend, } from './state.js';
|
|
5
|
+
import { createSettlementResolver, isErc20Settlement, rpcFor, rpcEnvName, } from './settlement.js';
|
|
6
6
|
// Read-only view of BlindEscrow.getTask, for reading a task's state directly
|
|
7
7
|
// from the chain that holds it. Field order matches contracts/BlindEscrow.sol;
|
|
8
8
|
// a post-#38 deployment appends disputedAt, which ABI decoding ignores.
|
|
@@ -28,6 +28,18 @@ const ESCROW_READ_ABI = [
|
|
|
28
28
|
* machine, so retries resume instead of double-funding.
|
|
29
29
|
*/
|
|
30
30
|
const ZERO_TOKEN = '0x0000000000000000000000000000000000000000';
|
|
31
|
+
// The only calls the backend builds for this process to sign or relay:
|
|
32
|
+
// backend/src/services/escrow.ts (createTask, cancelTask, claimTimeout,
|
|
33
|
+
// submitEvidence). verifyTarget checks where a transaction goes, but the
|
|
34
|
+
// escrow address comes from the same backend, so a hostile answer could name
|
|
35
|
+
// the token as the escrow and hand over an approve; the call, its arguments
|
|
36
|
+
// and its value are what bound it (security audit run 1, C41).
|
|
37
|
+
const ESCROW_CALLS = new Interface([
|
|
38
|
+
'function createTask(bytes32 taskHash, address token, uint256 amount, string category, string locationZone, uint256 duration)',
|
|
39
|
+
'function submitEvidence(uint256 taskId, bytes32 evidenceHash)',
|
|
40
|
+
'function cancelTask(uint256 taskId)',
|
|
41
|
+
'function claimTimeout(uint256 taskId)',
|
|
42
|
+
]);
|
|
31
43
|
const GAS_LIMIT = 1000000n; // matches the canonical rent script
|
|
32
44
|
// Auto-verify releases the payment, so the bar can't be "one character" — but
|
|
33
45
|
// 40 made a correct 30-character URL unpayable. 20 is the platform floor
|
|
@@ -45,6 +57,66 @@ function ok(data) {
|
|
|
45
57
|
function fail(code, message) {
|
|
46
58
|
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: { code, message } }) }] };
|
|
47
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Throw TX_MISMATCH, before anything is signed or relayed, unless the
|
|
62
|
+
* backend's unsigned `tx` is exactly `fn` (canonically encoded) with
|
|
63
|
+
* arguments `argsOk` accepts, and names no value other than `value`. Only
|
|
64
|
+
* `to` and `data` are ever forwarded, so a value the backend names is never
|
|
65
|
+
* sent; a wrong one still means the transaction is not the one asked for.
|
|
66
|
+
*/
|
|
67
|
+
function assertEscrowCall(tx, fn, argsOk, what, value = 0n) {
|
|
68
|
+
const data = typeof tx.data === 'string' ? tx.data.toLowerCase() : '';
|
|
69
|
+
let ok = false;
|
|
70
|
+
try {
|
|
71
|
+
const args = ESCROW_CALLS.decodeFunctionData(fn, data);
|
|
72
|
+
ok = ESCROW_CALLS.encodeFunctionData(fn, args).toLowerCase() === data && argsOk(args);
|
|
73
|
+
}
|
|
74
|
+
catch { /* another function, or not ABI data at all */ }
|
|
75
|
+
if (ok && tx.value != null) {
|
|
76
|
+
try {
|
|
77
|
+
ok = BigInt(tx.value) === value;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
ok = false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!ok) {
|
|
84
|
+
const e = new Error(`backend built ${what} that is not the ${fn} call this spend asked for (another function, other arguments, or a value). Nothing was sent.`);
|
|
85
|
+
e.code = 'TX_MISMATCH';
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const QUOTE_REQUIRED_MESSAGE = '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)';
|
|
90
|
+
/** Why a confirm was refused against its quote. Nothing was uploaded,
|
|
91
|
+
* approved or sent by then. `why` replaces the generic list of what changed. */
|
|
92
|
+
function quoteRefused(check, tool, why) {
|
|
93
|
+
if (check.code === 'QUOTE_REQUIRED')
|
|
94
|
+
return fail('QUOTE_REQUIRED', QUOTE_REQUIRED_MESSAGE);
|
|
95
|
+
return fail('QUOTE_MISMATCH', `Nothing was sent: ${why ?? `this confirm would spend something other than quote ${check.quote.quoteId} (changed: ${check.changed.join(', ')})`}. ` +
|
|
96
|
+
`That quote is now used up. Call ${tool} again without confirm to get a new quote, check it, then confirm with the new quoteId.`);
|
|
97
|
+
}
|
|
98
|
+
/** The chain, escrow, token and wallet a spend moves money on, for its quote binding. */
|
|
99
|
+
function settlementFields(s, payFrom) {
|
|
100
|
+
return {
|
|
101
|
+
chain: s.mode,
|
|
102
|
+
chainId: s.chainId ?? null,
|
|
103
|
+
escrow: s.escrowAddress ? s.escrowAddress.toLowerCase() : null,
|
|
104
|
+
token: isErc20Settlement(s) ? s.token.address.toLowerCase() : ZERO_TOKEN,
|
|
105
|
+
payFrom: payFrom.toLowerCase(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** How the network a spend was recorded on differs from the one `s` settles
|
|
109
|
+
* on now, or null. A chain key keeps its name when the backend moves it to
|
|
110
|
+
* another network (Arc Testnet 5042002 and Arc mainnet 5042 are both 'arc'),
|
|
111
|
+
* so the record's chain id decides. A record written before records kept one
|
|
112
|
+
* cannot be checked. */
|
|
113
|
+
function networkChange(record, s) {
|
|
114
|
+
if (s.chainId === undefined || record.chainId === s.chainId)
|
|
115
|
+
return null;
|
|
116
|
+
return record.chainId === undefined
|
|
117
|
+
? `was recorded without a chain id, so it cannot be checked against ${s.mode} on chain ${s.chainId}`
|
|
118
|
+
: `started on ${s.mode} chain ${record.chainId}, but the backend's ${s.mode} is chain ${s.chainId} now`;
|
|
119
|
+
}
|
|
48
120
|
export function registerRentTools(server, cfg, walletCtx) {
|
|
49
121
|
/** Every authenticated call funnels through api(), so this is the one place
|
|
50
122
|
* the missing-key case needs handling. Without it the caller gets the
|
|
@@ -93,11 +165,16 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
93
165
|
}
|
|
94
166
|
return json.data;
|
|
95
167
|
}
|
|
96
|
-
const settlement = createSettlementResolver({
|
|
168
|
+
const settlement = createSettlementResolver({
|
|
169
|
+
apiBase: cfg.apiBase ?? 'https://api.blindmarket.xyz',
|
|
170
|
+
api,
|
|
171
|
+
localWallet: walletCtx?.wallet.address,
|
|
172
|
+
});
|
|
97
173
|
/** How this process pays. On 0G that is the local wallet, which must exist.
|
|
98
174
|
* On a relay chain (Base) nothing signs locally — the relay signs from the
|
|
99
175
|
* API key's owner wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an
|
|
100
|
-
* error there.
|
|
176
|
+
* error there. On an ERC-20 chain without a relay (Arc) discovery only
|
|
177
|
+
* succeeds with the local wallet, already checked to be the key's owner. */
|
|
101
178
|
async function requireFunding() {
|
|
102
179
|
let s;
|
|
103
180
|
try {
|
|
@@ -106,7 +183,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
106
183
|
catch (err) {
|
|
107
184
|
return { error: fail(err.code ?? 'SETTLEMENT_UNKNOWN', err.message) };
|
|
108
185
|
}
|
|
109
|
-
if (s
|
|
186
|
+
if (isErc20Settlement(s))
|
|
110
187
|
return { s, payFrom: s.payFrom };
|
|
111
188
|
if (!walletCtx) {
|
|
112
189
|
return { error: fail('NO_WALLET', 'Spending on 0G needs a local funding wallet — set BLINDMARKET_PRIVATE_KEY (see wallet_status)') };
|
|
@@ -124,10 +201,23 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
124
201
|
/** A new escrow is funded where POST /api/v1/tasks builds: the backend's
|
|
125
202
|
* posting chain. A process forced onto another chain (to finish or refund
|
|
126
203
|
* tasks already there) cannot post. Unknown on an older backend. */
|
|
127
|
-
function notPostingChain(s) {
|
|
128
|
-
|
|
204
|
+
async function notPostingChain(s) {
|
|
205
|
+
let posting = s.postingChain;
|
|
206
|
+
// Forced to 0G, discovery never asked the backend. Ask now, before a quote:
|
|
207
|
+
// a backend that posts elsewhere refuses the native-0G createTask only
|
|
208
|
+
// after the brief is uploaded and its hash claimed.
|
|
209
|
+
if (posting === undefined && s.mode === '0g') {
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch(`${cfg.apiBase}/health/settlement`, { signal: AbortSignal.timeout(15_000) });
|
|
212
|
+
const json = await res.json();
|
|
213
|
+
if (json?.success && typeof json.data?.postingChain === 'string')
|
|
214
|
+
posting = json.data.postingChain;
|
|
215
|
+
}
|
|
216
|
+
catch { /* an older backend or a blip: keep the old behaviour, the escrow check still guards the send */ }
|
|
217
|
+
}
|
|
218
|
+
if (posting === undefined || s.mode === posting)
|
|
129
219
|
return null;
|
|
130
|
-
const e = new Error(`This process settles on ${s.mode} (BLINDMARKET_SETTLEMENT), but the backend posts new tasks on ${
|
|
220
|
+
const e = new Error(`This process settles on ${s.mode} (BLINDMARKET_SETTLEMENT), but the backend posts new tasks on ${posting}, so a new escrow can only be funded there. Unset BLINDMARKET_SETTLEMENT (or set it to ${posting}) to post; ${s.mode} stays usable for tasks already on it.`);
|
|
131
221
|
e.code = 'NOT_POSTING_CHAIN';
|
|
132
222
|
return e;
|
|
133
223
|
}
|
|
@@ -139,10 +229,15 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
139
229
|
function settlementToken(s) {
|
|
140
230
|
return new Contract(s.token.address, ERC20, s.provider);
|
|
141
231
|
}
|
|
232
|
+
/** The local wallet on a no-relay ERC-20 chain, over that chain's RPC
|
|
233
|
+
* (checked at discovery to serve the chain the backend names). */
|
|
234
|
+
function localSigner(s) {
|
|
235
|
+
return walletCtx.wallet.connect(s.provider);
|
|
236
|
+
}
|
|
142
237
|
/** Spendable balance of whoever pays, in the settlement token's units. */
|
|
143
238
|
async function payFromBalance(s, payFrom) {
|
|
144
239
|
try {
|
|
145
|
-
const raw = s
|
|
240
|
+
const raw = isErc20Settlement(s)
|
|
146
241
|
? await settlementToken(s).balanceOf(payFrom)
|
|
147
242
|
: await walletCtx.provider.getBalance(payFrom);
|
|
148
243
|
return formatUnits(raw, s.decimals);
|
|
@@ -213,10 +308,24 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
213
308
|
// default it applies to this body: user-pays.
|
|
214
309
|
return { hash: r.hash, isUserOp: r.isUserOp === true, gas: r.gas ?? 'user-pays' };
|
|
215
310
|
}
|
|
311
|
+
/** Send a tx on an ERC-20 settlement: through the relay, or signed by the
|
|
312
|
+
* local wallet on a chain the relay does not serve. Either way the caller
|
|
313
|
+
* persists the hash before waiting (waitRelayed polls the chain's own RPC
|
|
314
|
+
* for a plain hash, which a local send always is). `nonce` pins a local
|
|
315
|
+
* send right after one this process made: an RPC can answer the next nonce
|
|
316
|
+
* lookup from before the earlier tx landed. */
|
|
317
|
+
async function sendErc20(s, tx, nonce) {
|
|
318
|
+
if (s.payment === 'relay-erc20')
|
|
319
|
+
return relaySend(s, tx);
|
|
320
|
+
// No gasLimit: estimation runs first, so a predictable revert (a stale
|
|
321
|
+
// allowance, a passed deadline) fails here without being mined and paid for.
|
|
322
|
+
const sent = await localSigner(s).sendTransaction({ to: tx.to, data: tx.data, ...(nonce !== undefined ? { nonce } : {}) });
|
|
323
|
+
return { hash: sent.hash, isUserOp: false, nonce: sent.nonce };
|
|
324
|
+
}
|
|
216
325
|
/** Wait for a relayed tx to land. A plain hash can be polled for its receipt;
|
|
217
326
|
* a user-op hash cannot (getTransactionReceipt is always null for it), so
|
|
218
327
|
* that case returns at once and the caller confirms by on-chain STATE —
|
|
219
|
-
* see ensureAllowance and
|
|
328
|
+
* see ensureAllowance and waitLanded. */
|
|
220
329
|
async function waitRelayed(s, hash, isUserOp) {
|
|
221
330
|
if (isUserOp)
|
|
222
331
|
return;
|
|
@@ -224,7 +333,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
224
333
|
const receipt = await s.provider.getTransactionReceipt(hash).catch(() => null);
|
|
225
334
|
if (receipt) {
|
|
226
335
|
if (receipt.status === 0) {
|
|
227
|
-
const e = new Error(`
|
|
336
|
+
const e = new Error(`tx ${hash} reverted`);
|
|
228
337
|
e.code = 'TX_REVERTED';
|
|
229
338
|
throw e;
|
|
230
339
|
}
|
|
@@ -232,21 +341,26 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
232
341
|
}
|
|
233
342
|
await new Promise((r) => setTimeout(r, 3000));
|
|
234
343
|
}
|
|
235
|
-
const e = new Error(`
|
|
344
|
+
const e = new Error(`tx ${hash} not confirmed after 90s — retry with the same idempotencyKey to resume`);
|
|
236
345
|
e.code = 'TX_PENDING';
|
|
237
346
|
throw e;
|
|
238
347
|
}
|
|
239
|
-
/**
|
|
348
|
+
/** ERC-20 chains: createTask pulls the token via transferFrom, so the
|
|
240
349
|
* escrow needs an allowance first. Confirmed by re-reading allowance()
|
|
241
|
-
* rather than by receipt, which is what makes the user-op case decidable.
|
|
350
|
+
* rather than by receipt, which is what makes the user-op case decidable.
|
|
351
|
+
* Returns the nonce the createTask should use when this call sent a local
|
|
352
|
+
* approve, else undefined. */
|
|
242
353
|
async function ensureAllowance(s, record) {
|
|
243
354
|
const need = BigInt(record.amountWei);
|
|
244
355
|
const token = settlementToken(s);
|
|
245
356
|
if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
|
|
246
|
-
return;
|
|
357
|
+
return undefined;
|
|
358
|
+
let nextNonce;
|
|
247
359
|
const approve = async () => {
|
|
248
360
|
const data = ERC20.encodeFunctionData('approve', [s.escrowAddress, need]);
|
|
249
|
-
const { hash } = await
|
|
361
|
+
const { hash, nonce } = await sendErc20(s, { to: s.token.address, data });
|
|
362
|
+
if (nonce !== undefined)
|
|
363
|
+
nextNonce = nonce + 1;
|
|
250
364
|
// Persist BEFORE waiting: a crash here must resume into the poll below.
|
|
251
365
|
updateSpend(record.idempotencyKey, { stage: 'approved', approveTxHash: hash });
|
|
252
366
|
record.stage = 'approved';
|
|
@@ -263,18 +377,18 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
263
377
|
if (record.stage === 'created') {
|
|
264
378
|
await approve();
|
|
265
379
|
if (await settled())
|
|
266
|
-
return;
|
|
380
|
+
return nextNonce;
|
|
267
381
|
}
|
|
268
382
|
// Resumed at 'approved' (or the fresh approve never landed): the earlier
|
|
269
383
|
// approve was dropped or reverted. Sending another is safe — ERC-20
|
|
270
384
|
// approve SETS the allowance, it does not add — and it is the only way
|
|
271
385
|
// out of this stage, so do it rather than leave the record stuck.
|
|
272
386
|
if (await settled())
|
|
273
|
-
return;
|
|
387
|
+
return nextNonce;
|
|
274
388
|
await approve();
|
|
275
389
|
if (await settled())
|
|
276
|
-
return;
|
|
277
|
-
const e = new Error(`${s.symbol} allowance still below ${formatUnits(need, s.decimals)} after two approves (last ${record.approveTxHash}) — check
|
|
390
|
+
return nextNonce;
|
|
391
|
+
const e = new Error(`${s.symbol} allowance still below ${formatUnits(need, s.decimals)} after two approves (last ${record.approveTxHash}) — check ${s.payFrom}'s ${s.symbol} balance and retry with the same idempotencyKey`);
|
|
278
392
|
e.code = 'APPROVE_PENDING';
|
|
279
393
|
throw e;
|
|
280
394
|
}
|
|
@@ -288,25 +402,36 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
288
402
|
* both through the Privy relay with no local signing at all. The
|
|
289
403
|
* backend picks the escrow; we only check it is the one we approved. */
|
|
290
404
|
async function fundAndIndex(record) {
|
|
291
|
-
const s = await settlement();
|
|
292
405
|
let { txHash } = record;
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
if (record.settlement && record.settlement !== s.mode) {
|
|
297
|
-
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`);
|
|
298
|
-
e.code = 'SETTLEMENT_CHANGED';
|
|
299
|
-
throw e;
|
|
300
|
-
}
|
|
406
|
+
// Nothing left to sign once the escrow is funded: /a2a/tasks/index finds
|
|
407
|
+
// the receipt on whichever chain holds it. So a funded spend finishes
|
|
408
|
+
// even when settlement cannot be discovered right now, or has moved.
|
|
301
409
|
if (record.stage === 'created' || record.stage === 'approved') {
|
|
302
|
-
const
|
|
410
|
+
const s = await settlement();
|
|
411
|
+
// A record remembers the chain it started on. If the backend flips mode
|
|
412
|
+
// between attempts, re-funding through the other path would double-fund
|
|
413
|
+
// or send native value into a USDC transferFrom — refuse instead.
|
|
414
|
+
if (record.settlement && record.settlement !== s.mode) {
|
|
415
|
+
const e = new Error(`spend ${record.idempotencyKey} started on ${record.settlement} but this process now settles on ${s.mode}. ` +
|
|
416
|
+
`Nothing was funded yet (stage ${record.stage}): start a new spend with a new idempotencyKey, or set BLINDMARKET_SETTLEMENT=${record.settlement} to finish this one.`);
|
|
417
|
+
e.code = 'SETTLEMENT_CHANGED';
|
|
418
|
+
throw e;
|
|
419
|
+
}
|
|
420
|
+
// Nor on another network under the same key: the spend was quoted and
|
|
421
|
+
// confirmed for the one it started on.
|
|
422
|
+
const moved = networkChange(record, s);
|
|
423
|
+
if (moved) {
|
|
424
|
+
const e = new Error(`spend ${record.idempotencyKey} ${moved}. Nothing was funded yet (stage ${record.stage}): start a new spend with a new idempotencyKey.`);
|
|
425
|
+
e.code = 'SETTLEMENT_CHANGED';
|
|
426
|
+
throw e;
|
|
427
|
+
}
|
|
428
|
+
const refused = await notPostingChain(s);
|
|
303
429
|
if (refused)
|
|
304
430
|
throw refused;
|
|
305
|
-
|
|
306
|
-
await ensureAllowance(s, record);
|
|
431
|
+
const nonce = isErc20Settlement(s) ? await ensureAllowance(s, record) : undefined;
|
|
307
432
|
const { unsignedTx, chain: builtChain, chainId: builtChainId } = await api('POST', '/api/v1/tasks', {
|
|
308
433
|
taskHash: record.taskHash,
|
|
309
|
-
token: s
|
|
434
|
+
token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
|
|
310
435
|
amount: record.amountWei,
|
|
311
436
|
locationZone: 'global',
|
|
312
437
|
duration: String(record.durationSecs ?? 3600),
|
|
@@ -329,8 +454,17 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
329
454
|
throw e;
|
|
330
455
|
}
|
|
331
456
|
await verifyTarget(s, unsignedTx.to, 'createTask');
|
|
332
|
-
|
|
333
|
-
|
|
457
|
+
// And it must be this spend's createTask: its task hash, token, amount
|
|
458
|
+
// and duration (the amount is what was approved above, or the value sent below).
|
|
459
|
+
const amount = BigInt(record.amountWei);
|
|
460
|
+
const token = isErc20Settlement(s) ? s.token.address : ZERO_TOKEN;
|
|
461
|
+
assertEscrowCall(unsignedTx, 'createTask', (a) => String(a[0]).toLowerCase() === String(record.taskHash).toLowerCase()
|
|
462
|
+
&& String(a[1]).toLowerCase() === token.toLowerCase()
|
|
463
|
+
&& a[2] === amount
|
|
464
|
+
&& a[4] === 'global'
|
|
465
|
+
&& a[5] === BigInt(record.durationSecs ?? 3600), 'createTask', isErc20Settlement(s) ? 0n : amount);
|
|
466
|
+
if (isErc20Settlement(s)) {
|
|
467
|
+
const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data }, nonce);
|
|
334
468
|
// Persist BEFORE waiting, same reasoning as the 0G branch below.
|
|
335
469
|
updateSpend(record.idempotencyKey, { stage: 'funded', txHash: hash, isUserOp, gas });
|
|
336
470
|
record.gas = gas;
|
|
@@ -397,7 +531,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
397
531
|
// ── rent_service ──────────────────────────────────────────────────────────
|
|
398
532
|
server.registerTool('rent_service', {
|
|
399
533
|
title: 'Rent an Agent Service',
|
|
400
|
-
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
|
|
534
|
+
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 paid on the backend\'s posting chain: USDC on Arc signed by the local wallet (BLINDMARKET_PRIVATE_KEY, which must own BLINDMARKET_API_KEY; gas is also USDC), USDC on Base through the backend relay (no private key needed), or native 0G from the local wallet. wallet_status shows which. TWO-STEP: first call returns a price quote + quoteId; re-call with the SAME arguments plus confirm=true and that quoteId to actually spend (a confirm that differs from its quote, or a listing re-priced since the quote, is refused with QUOTE_MISMATCH and nothing is sent). Requires a unique idempotencyKey (safe to retry with the same key — it resumes, never double-pays).',
|
|
401
535
|
inputSchema: {
|
|
402
536
|
serviceId: z.number().int().positive().describe('Service id from browse_services / get_service'),
|
|
403
537
|
prompt: z.string().min(1).max(100_000).describe('What you want the agent to do'),
|
|
@@ -409,11 +543,8 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
409
543
|
},
|
|
410
544
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
411
545
|
}, async ({ serviceId, prompt, idempotencyKey, privacy, confirm, quoteId, waitSeconds }) => {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
return f.error;
|
|
415
|
-
const { s, payFrom } = f;
|
|
416
|
-
// Resume path — this key already spent (or partially spent).
|
|
546
|
+
// Resume path — this key already spent (or partially spent). Checked
|
|
547
|
+
// before settlement: a funded spend finishes without it.
|
|
417
548
|
const existing = getSpend(idempotencyKey);
|
|
418
549
|
if (existing) {
|
|
419
550
|
if (existing.stage === 'indexed') {
|
|
@@ -429,7 +560,11 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
429
560
|
return fail(err.code ?? 'RESUME_FAILED', err.message);
|
|
430
561
|
}
|
|
431
562
|
}
|
|
432
|
-
const
|
|
563
|
+
const f = await requireFunding();
|
|
564
|
+
if ('error' in f)
|
|
565
|
+
return f.error;
|
|
566
|
+
const { s, payFrom } = f;
|
|
567
|
+
const offPosting = await notPostingChain(s);
|
|
433
568
|
if (offPosting)
|
|
434
569
|
return fail(offPosting.code, offPosting.message);
|
|
435
570
|
const service = await api('GET', `/api/v1/marketplace/services/${serviceId}`);
|
|
@@ -450,8 +585,22 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
450
585
|
return fail('PRICE_UNITS_SUSPECT', `service ${serviceId} lists price_raw=${priceRaw} which is ${formatUnits(priceRaw, s.decimals)} ${s.symbol} — this looks like an 18-decimal 0G price on a ${s.symbol} chain. Not sending. Re-list the service in ${s.symbol} base units.`);
|
|
451
586
|
}
|
|
452
587
|
const price = formatUnits(priceRaw, s.decimals);
|
|
588
|
+
// Exactly what this call would spend, derived after every lookup above
|
|
589
|
+
// and before anything is uploaded or sent. The quote stores it and the
|
|
590
|
+
// confirm must match it: a provider can re-price its listing between
|
|
591
|
+
// the two calls, and the confirm call carries no price of its own.
|
|
592
|
+
const spend = {
|
|
593
|
+
...settlementFields(s, payFrom),
|
|
594
|
+
idempotencyKey,
|
|
595
|
+
serviceId: String(serviceId),
|
|
596
|
+
listingId: String(service.id),
|
|
597
|
+
agent: String(service.agent_address).toLowerCase(),
|
|
598
|
+
priceRaw: priceRaw.toString(),
|
|
599
|
+
privacy: isPublic ? 'public' : 'private',
|
|
600
|
+
prompt: sha256Hex(Buffer.from(prompt, 'utf8')),
|
|
601
|
+
};
|
|
453
602
|
if (!confirm) {
|
|
454
|
-
const quote = createQuote('rent', { serviceId, price, currency: s.symbol });
|
|
603
|
+
const quote = createQuote('rent', { serviceId, price, currency: s.symbol }, spend);
|
|
455
604
|
return ok({
|
|
456
605
|
quote: {
|
|
457
606
|
service: { id: service.id, name: service.name, agent: service.agent_address },
|
|
@@ -466,8 +615,12 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
466
615
|
next: `Re-call rent_service with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to execute this spend.`,
|
|
467
616
|
});
|
|
468
617
|
}
|
|
469
|
-
|
|
470
|
-
|
|
618
|
+
const check = consumeQuote(quoteId, 'rent', spend);
|
|
619
|
+
if (!check.ok) {
|
|
620
|
+
const repriced = check.code === 'QUOTE_MISMATCH' && check.changed.includes('priceRaw')
|
|
621
|
+
? `the listing's price changed from ${check.quote.summary.price} ${check.quote.summary.currency} to ${price} ${s.symbol} since the quote`
|
|
622
|
+
: undefined;
|
|
623
|
+
return quoteRefused(check, 'rent_service', repriced);
|
|
471
624
|
}
|
|
472
625
|
try {
|
|
473
626
|
// Prepare the brief blob (the canonical script's steps 1-3).
|
|
@@ -504,9 +657,11 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
504
657
|
verificationMode: 'auto',
|
|
505
658
|
verificationCriteria: RENTAL_VERIFICATION_CRITERIA,
|
|
506
659
|
requiredCapabilities: [],
|
|
507
|
-
|
|
660
|
+
// The quoted price, which the binding above just matched.
|
|
661
|
+
amountWei: priceRaw.toString(),
|
|
508
662
|
settlement: s.mode,
|
|
509
|
-
|
|
663
|
+
chainId: s.chainId,
|
|
664
|
+
token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
|
|
510
665
|
durationSecs: 3600,
|
|
511
666
|
createdAt: new Date().toISOString(),
|
|
512
667
|
updatedAt: new Date().toISOString(),
|
|
@@ -514,12 +669,12 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
514
669
|
putSpend(record);
|
|
515
670
|
const done = await fundAndIndex(record);
|
|
516
671
|
const polled = await pollPosted(done.taskHash, waitSeconds ?? 45);
|
|
517
|
-
return ok({ ...done, ...polled });
|
|
672
|
+
return ok({ ...done, escrowed: { amount: price, amountRaw: priceRaw.toString(), currency: s.symbol }, ...polled });
|
|
518
673
|
}
|
|
519
674
|
catch (err) {
|
|
520
675
|
const code = err.code;
|
|
521
676
|
if (code === 'NOT_TASK_AGENT') {
|
|
522
|
-
return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow.
|
|
677
|
+
return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow. When this process signs (0G, Arc), mint an sk_ key while signed in with the BLINDMARKET_PRIVATE_KEY wallet; on a relay chain (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.');
|
|
523
678
|
}
|
|
524
679
|
return fail(code ?? 'RENT_FAILED', err.message);
|
|
525
680
|
}
|
|
@@ -527,7 +682,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
527
682
|
// ── post_task ─────────────────────────────────────────────────────────────
|
|
528
683
|
server.registerTool('post_task', {
|
|
529
684
|
title: 'Post a Task to the Open Market',
|
|
530
|
-
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
|
|
685
|
+
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 paid on the backend\'s posting chain: USDC on Arc signed by the local wallet (BLINDMARKET_PRIVATE_KEY, which must own BLINDMARKET_API_KEY; gas is also USDC), USDC on Base through the backend relay (no private key needed), or native 0G from the local wallet. wallet_status shows which. TWO-STEP quote/confirm like rent_service: confirm with the same arguments, or it is refused with QUOTE_MISMATCH; requires a unique idempotencyKey.',
|
|
531
686
|
inputSchema: {
|
|
532
687
|
instructions: z.string().min(1).max(100_000).describe('The task brief'),
|
|
533
688
|
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'),
|
|
@@ -541,10 +696,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
541
696
|
},
|
|
542
697
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
543
698
|
}, async ({ instructions, amount, amount0G, idempotencyKey, capabilities, durationSeconds, privacy, confirm, quoteId }) => {
|
|
544
|
-
|
|
545
|
-
if ('error' in f)
|
|
546
|
-
return f.error;
|
|
547
|
-
const { s, payFrom } = f;
|
|
699
|
+
// Checked before settlement: a funded spend finishes without it.
|
|
548
700
|
const existing = getSpend(idempotencyKey);
|
|
549
701
|
if (existing) {
|
|
550
702
|
if (existing.stage === 'indexed') {
|
|
@@ -557,7 +709,11 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
557
709
|
return fail(err.code ?? 'RESUME_FAILED', err.message);
|
|
558
710
|
}
|
|
559
711
|
}
|
|
560
|
-
const
|
|
712
|
+
const f = await requireFunding();
|
|
713
|
+
if ('error' in f)
|
|
714
|
+
return f.error;
|
|
715
|
+
const { s, payFrom } = f;
|
|
716
|
+
const offPosting = await notPostingChain(s);
|
|
561
717
|
if (offPosting)
|
|
562
718
|
return fail(offPosting.code, offPosting.message);
|
|
563
719
|
const isPublic = privacy === 'public';
|
|
@@ -572,8 +728,19 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
572
728
|
catch {
|
|
573
729
|
return fail('AMOUNT_INVALID', `"${amountStr}" is not a valid ${s.symbol} amount — at most ${s.decimals} decimal places.`);
|
|
574
730
|
}
|
|
731
|
+
const durationSecs = durationSeconds ?? 86400;
|
|
732
|
+
// What this call would spend, bound into the quote (see rent_service).
|
|
733
|
+
const spend = {
|
|
734
|
+
...settlementFields(s, payFrom),
|
|
735
|
+
idempotencyKey,
|
|
736
|
+
amountRaw: amountWei.toString(),
|
|
737
|
+
durationSecs,
|
|
738
|
+
capabilities: JSON.stringify(capabilities ?? []),
|
|
739
|
+
privacy: isPublic ? 'public' : 'private',
|
|
740
|
+
instructions: sha256Hex(Buffer.from(instructions, 'utf8')),
|
|
741
|
+
};
|
|
575
742
|
if (!confirm) {
|
|
576
|
-
const quote = createQuote('post', { amount: amountStr, currency: s.symbol });
|
|
743
|
+
const quote = createQuote('post', { amount: amountStr, currency: s.symbol }, spend);
|
|
577
744
|
return ok({
|
|
578
745
|
quote: {
|
|
579
746
|
escrow: amountStr,
|
|
@@ -588,9 +755,9 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
588
755
|
next: `Re-call post_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to execute this spend.`,
|
|
589
756
|
});
|
|
590
757
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
758
|
+
const check = consumeQuote(quoteId, 'post', spend);
|
|
759
|
+
if (!check.ok)
|
|
760
|
+
return quoteRefused(check, 'post_task');
|
|
594
761
|
try {
|
|
595
762
|
const plaintext = Buffer.from(instructions, 'utf8');
|
|
596
763
|
let blobB64;
|
|
@@ -637,19 +804,26 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
637
804
|
requiredCapabilities: capabilities ?? [],
|
|
638
805
|
amountWei: amountWei.toString(),
|
|
639
806
|
settlement: s.mode,
|
|
640
|
-
|
|
641
|
-
|
|
807
|
+
chainId: s.chainId,
|
|
808
|
+
token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
|
|
809
|
+
durationSecs,
|
|
642
810
|
createdAt: new Date().toISOString(),
|
|
643
811
|
updatedAt: new Date().toISOString(),
|
|
644
812
|
};
|
|
645
813
|
putSpend(record);
|
|
646
814
|
const done = await fundAndIndex(record);
|
|
647
|
-
return ok({
|
|
815
|
+
return ok({
|
|
816
|
+
...done,
|
|
817
|
+
escrowed: { amount: formatUnits(amountWei, s.decimals), amountRaw: amountWei.toString(), currency: s.symbol },
|
|
818
|
+
wrappedTo: wrappedKeys ? Object.keys(wrappedKeys).length : 0,
|
|
819
|
+
privacy: record.privacy,
|
|
820
|
+
hint: 'Use poll_task_result to wait for the deliverable.',
|
|
821
|
+
});
|
|
648
822
|
}
|
|
649
823
|
catch (err) {
|
|
650
824
|
const code = err.code;
|
|
651
825
|
if (code === 'NOT_TASK_AGENT') {
|
|
652
|
-
return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow.
|
|
826
|
+
return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow. When this process signs (0G, Arc), mint an sk_ key while signed in with the BLINDMARKET_PRIVATE_KEY wallet; on a relay chain (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.');
|
|
653
827
|
}
|
|
654
828
|
return fail(code ?? 'POST_FAILED', err.message);
|
|
655
829
|
}
|
|
@@ -675,6 +849,11 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
675
849
|
// Disputed (6) → claimTimeout, but only once
|
|
676
850
|
// DISPUTE_WINDOW has elapsed
|
|
677
851
|
//
|
|
852
|
+
// On an upgraded escrow, claimTimeout on a Submitted task (work delivered
|
|
853
|
+
// before the deadline, never judged) refunds nothing: it sends the task for
|
|
854
|
+
// review and leaves it Disputed (security audit run 1, C18). A failed
|
|
855
|
+
// (Verified) task refunds only after the worker's 3-day appeal window.
|
|
856
|
+
//
|
|
678
857
|
// So the quote step reads the live status first and refuses a path the
|
|
679
858
|
// contract would reject, rather than letting the caller burn gas to find out.
|
|
680
859
|
//
|
|
@@ -711,7 +890,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
711
890
|
e.code = `TASK_NOT_ON_${s.chain.toUpperCase().replace(/-/g, '_')}`;
|
|
712
891
|
return e;
|
|
713
892
|
};
|
|
714
|
-
if (s
|
|
893
|
+
if (!isErc20Settlement(s)) {
|
|
715
894
|
const detail = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
|
|
716
895
|
if (detail.chain && detail.chain !== s.chain)
|
|
717
896
|
throw onOtherChain(detail.taskId, detail.chain);
|
|
@@ -763,18 +942,30 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
763
942
|
function refundAmount(detail) {
|
|
764
943
|
return formatUnits(BigInt(detail.amount), detail.decimals ?? 18);
|
|
765
944
|
}
|
|
766
|
-
/**
|
|
945
|
+
/** What a cancel_task / claim_timeout confirm may send, for its quote binding. */
|
|
946
|
+
function refundFields(s, payFrom, idempotencyKey, detail) {
|
|
947
|
+
return {
|
|
948
|
+
...settlementFields(s, payFrom),
|
|
949
|
+
idempotencyKey,
|
|
950
|
+
taskId: String(detail.taskId),
|
|
951
|
+
taskHash: String(detail.taskHash).toLowerCase(),
|
|
952
|
+
amountRaw: String(detail.amount),
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
/** The refund has landed when the task reads one of `settled` on-chain:
|
|
956
|
+
* Cancelled, or for a claim on a Submitted task also Disputed (sent for
|
|
957
|
+
* review; an escrow from before that change refunds it instead). Checking
|
|
767
958
|
* state rather than a receipt is what makes a relayed user-op decidable
|
|
768
959
|
* (there is no receipt to poll), and it is a cheap truth check for the
|
|
769
|
-
* local-signing path too. */
|
|
770
|
-
async function
|
|
960
|
+
* local-signing path too. Resolves to the status it read. */
|
|
961
|
+
async function waitLanded(s, taskId, settled) {
|
|
771
962
|
for (let i = 0; i < 30; i++) {
|
|
772
963
|
const detail = await loadTask(s, String(taskId)).catch(() => null);
|
|
773
|
-
if (detail && Number(detail.status)
|
|
774
|
-
return;
|
|
964
|
+
if (detail && settled.includes(Number(detail.status)))
|
|
965
|
+
return Number(detail.status);
|
|
775
966
|
await new Promise((r) => setTimeout(r, 3000));
|
|
776
967
|
}
|
|
777
|
-
const e = new Error(`task ${taskId} still not
|
|
968
|
+
const e = new Error(`task ${taskId} still not ${settled.map(statusName).join(' or ')} on-chain after 90s — retry with the same idempotencyKey to keep waiting`);
|
|
778
969
|
e.code = 'REFUND_PENDING';
|
|
779
970
|
throw e;
|
|
780
971
|
}
|
|
@@ -788,20 +979,36 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
788
979
|
const taskId = record.taskId;
|
|
789
980
|
let { txHash } = record;
|
|
790
981
|
if (record.settlement && record.settlement !== s.mode) {
|
|
791
|
-
const e = new Error(`refund ${record.idempotencyKey} started on ${record.settlement} but
|
|
982
|
+
const e = new Error(`refund ${record.idempotencyKey} started on ${record.settlement} but this process now settles on ${s.mode} — set BLINDMARKET_SETTLEMENT=${record.settlement} to finish it, or finish it from the web app`);
|
|
983
|
+
e.code = 'SETTLEMENT_CHANGED';
|
|
984
|
+
throw e;
|
|
985
|
+
}
|
|
986
|
+
// Nor on another network under the same key, where the task id names
|
|
987
|
+
// another task. Only a refund that would still sign needs a chain id on
|
|
988
|
+
// record; a sent one waits for its own transaction.
|
|
989
|
+
const moved = networkChange(record, s);
|
|
990
|
+
if (moved && (record.stage === 'created' || record.chainId !== undefined)) {
|
|
991
|
+
const e = new Error(`refund ${record.idempotencyKey} ${moved}. ` +
|
|
992
|
+
(record.stage === 'created'
|
|
993
|
+
? 'Nothing was sent: quote the refund again with a new idempotencyKey.'
|
|
994
|
+
: `Its transaction ${record.txHash} was sent on chain ${record.chainId}.`));
|
|
792
995
|
e.code = 'SETTLEMENT_CHANGED';
|
|
793
996
|
throw e;
|
|
794
997
|
}
|
|
795
998
|
if (record.stage === 'created') {
|
|
796
999
|
const route = record.kind === 'cancel' ? 'cancel' : 'timeout';
|
|
797
|
-
|
|
1000
|
+
// Task ids repeat across chains: name this one (the backend knows
|
|
1001
|
+
// 'base' and 'arc'; a 0G task is resolved by ownership as before).
|
|
1002
|
+
const { unsignedTx } = await api('POST', `/api/v1/tasks/${taskId}/${route}`, isErc20Settlement(s) ? { chain: s.mode } : undefined);
|
|
798
1003
|
// The backend resolves the chain that holds the task and builds for it;
|
|
799
1004
|
// the tx carries no chainId. If that chain is not the one this mode
|
|
800
1005
|
// broadcasts on, stop here — relaying a 0G refund onto another chain
|
|
801
1006
|
// lands on an address with no escrow and burns the gas.
|
|
802
1007
|
await verifyTarget(s, unsignedTx.to, `${route}Task`);
|
|
803
|
-
|
|
804
|
-
|
|
1008
|
+
// And exactly this refund, for this task, with no value.
|
|
1009
|
+
assertEscrowCall(unsignedTx, record.kind === 'cancel' ? 'cancelTask' : 'claimTimeout', (a) => a[0] === BigInt(taskId), `${route}Task`);
|
|
1010
|
+
if (isErc20Settlement(s)) {
|
|
1011
|
+
const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data });
|
|
805
1012
|
// Persist BEFORE waiting, same reasoning as the 0G branch below.
|
|
806
1013
|
updateSpend(record.idempotencyKey, { stage: 'sent', txHash: hash, isUserOp, gas });
|
|
807
1014
|
record.gas = gas;
|
|
@@ -824,7 +1031,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
824
1031
|
}
|
|
825
1032
|
}
|
|
826
1033
|
else if (txHash) {
|
|
827
|
-
if (s
|
|
1034
|
+
if (isErc20Settlement(s))
|
|
828
1035
|
await waitRelayed(s, txHash, record.isUserOp ?? false);
|
|
829
1036
|
else
|
|
830
1037
|
await walletCtx.provider.waitForTransaction(txHash);
|
|
@@ -832,9 +1039,33 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
832
1039
|
else {
|
|
833
1040
|
throw new Error(`Spend record ${record.idempotencyKey} is at stage '${record.stage}' with no txHash — cannot resume safely`);
|
|
834
1041
|
}
|
|
835
|
-
await
|
|
836
|
-
|
|
837
|
-
|
|
1042
|
+
const landed = await waitLanded(s, taskId, record.kind === 'timeout' && record.fromStatus === 2 ? [5, 6] : [5]);
|
|
1043
|
+
const outcome = landed === 6 ? 'escalate' : 'refund';
|
|
1044
|
+
updateSpend(record.idempotencyKey, { stage: 'confirmed', outcome });
|
|
1045
|
+
// Sent for review: the task stays live and there is no refund to confirm.
|
|
1046
|
+
const listingClosed = outcome === 'escalate' ? false : await confirmRefund(s, taskId, txHash, record.isUserOp ?? false);
|
|
1047
|
+
return { taskId, txHash: txHash, gas: record.gas, listingClosed, outcome };
|
|
1048
|
+
}
|
|
1049
|
+
/** Tell the backend the refund landed (POST /tasks/:id/confirm-tx): it
|
|
1050
|
+
* checks the receipt and takes the task off the market, which otherwise
|
|
1051
|
+
* keeps listing it as open until its deadline. Best effort, since the
|
|
1052
|
+
* money has already moved. A relayed user-op hash has no receipt to check. */
|
|
1053
|
+
async function confirmRefund(s, taskId, txHash, isUserOp) {
|
|
1054
|
+
if (isUserOp)
|
|
1055
|
+
return false;
|
|
1056
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
1057
|
+
try {
|
|
1058
|
+
await api('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, ...(isErc20Settlement(s) ? { chain: s.mode } : {}) });
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
// The backend's RPC can lag the receipt this side just saw.
|
|
1063
|
+
if (err.code !== 'NOT_CONFIRMED' || attempt === 3)
|
|
1064
|
+
return false;
|
|
1065
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return false;
|
|
838
1069
|
}
|
|
839
1070
|
/** Shared resume arm: an idempotencyKey that has already moved. The kind
|
|
840
1071
|
* guard matters — the ledger is one namespace shared with rent/post, and
|
|
@@ -849,7 +1080,10 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
849
1080
|
resumed: true,
|
|
850
1081
|
taskId: existing.taskId,
|
|
851
1082
|
txHash: existing.txHash,
|
|
852
|
-
|
|
1083
|
+
...(existing.outcome ? { outcome: existing.outcome } : {}),
|
|
1084
|
+
hint: existing.outcome === 'escalate'
|
|
1085
|
+
? 'Already sent for review — this idempotencyKey completed earlier. Nothing was refunded.'
|
|
1086
|
+
: 'Already refunded — this idempotencyKey completed earlier.',
|
|
853
1087
|
});
|
|
854
1088
|
}
|
|
855
1089
|
try {
|
|
@@ -891,8 +1125,11 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
891
1125
|
: ' The escrow is already settled — nothing to reclaim.';
|
|
892
1126
|
return fail('WRONG_REFUND_PATH', `Task ${detail.taskId} is ${statusName(status)}, and cancelTask only accepts Funded tasks.${alt}`);
|
|
893
1127
|
}
|
|
1128
|
+
// The task (and its escrow) this refund is for, bound into the quote: a
|
|
1129
|
+
// confirm naming another task is refused rather than refunding it.
|
|
1130
|
+
const spend = refundFields(s, payFrom, idempotencyKey, detail);
|
|
894
1131
|
if (!confirm) {
|
|
895
|
-
const quote = createQuote('cancel', { taskId: detail.taskId });
|
|
1132
|
+
const quote = createQuote('cancel', { taskId: detail.taskId }, spend);
|
|
896
1133
|
return ok({
|
|
897
1134
|
quote: {
|
|
898
1135
|
action: 'cancelTask',
|
|
@@ -906,15 +1143,16 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
906
1143
|
next: `Re-call cancel_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
|
|
907
1144
|
});
|
|
908
1145
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1146
|
+
const check = consumeQuote(quoteId, 'cancel', spend);
|
|
1147
|
+
if (!check.ok)
|
|
1148
|
+
return quoteRefused(check, 'cancel_task');
|
|
912
1149
|
try {
|
|
913
1150
|
const record = {
|
|
914
1151
|
idempotencyKey,
|
|
915
1152
|
kind: 'cancel',
|
|
916
1153
|
stage: 'created',
|
|
917
1154
|
settlement: s.mode,
|
|
1155
|
+
chainId: s.chainId,
|
|
918
1156
|
taskId: Number(detail.taskId),
|
|
919
1157
|
taskHash: detail.taskHash,
|
|
920
1158
|
amountWei: detail.amount,
|
|
@@ -931,7 +1169,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
931
1169
|
});
|
|
932
1170
|
server.registerTool('claim_timeout', {
|
|
933
1171
|
title: 'Reclaim Escrow After the Deadline',
|
|
934
|
-
description: 'Reclaim the escrow on a task that WAS assigned but never completed, once its deadline has passed (status Assigned,
|
|
1172
|
+
description: 'Reclaim the escrow on a task that WAS assigned but never completed, once its deadline has passed (status Assigned, or Verified-failed once the worker\'s 3-day appeal window has passed). On a Submitted task (work delivered before the deadline, never judged) it sends the task for review instead and refunds nothing: an admin rules, and with no ruling within 14 days the worker is paid; the result says outcome "escalate". For a task no worker ever picked up, use cancel_task instead — it needs no deadline. TWO-STEP quote/confirm; requires a unique idempotencyKey.',
|
|
935
1173
|
inputSchema: {
|
|
936
1174
|
task: z.string().min(1).describe('Task id (e.g. "51") or the 0x task hash returned by post_task'),
|
|
937
1175
|
idempotencyKey: z.string().min(8).max(128).describe('Unique key for this refund — reuse it on retries'),
|
|
@@ -966,8 +1204,9 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
966
1204
|
if (now < deadline) {
|
|
967
1205
|
return fail('DEADLINE_NOT_REACHED', `Task ${detail.taskId} is still live until ${new Date(Number(deadline) * 1000).toISOString()} — claimTimeout reverts before then.`);
|
|
968
1206
|
}
|
|
1207
|
+
const spend = refundFields(s, payFrom, idempotencyKey, detail);
|
|
969
1208
|
if (!confirm) {
|
|
970
|
-
const quote = createQuote('timeout', { taskId: detail.taskId });
|
|
1209
|
+
const quote = createQuote('timeout', { taskId: detail.taskId }, spend);
|
|
971
1210
|
return ok({
|
|
972
1211
|
quote: {
|
|
973
1212
|
action: 'claimTimeout',
|
|
@@ -979,22 +1218,26 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
979
1218
|
settlement: s.mode,
|
|
980
1219
|
// DISPUTE_WINDOW is enforced on-chain and disputedAt is not exposed
|
|
981
1220
|
// here, so a disputed task can still revert after this quote.
|
|
982
|
-
...(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.' } : {}),
|
|
1221
|
+
...(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. Delivered work that was sent for review never returns to you by timeout.' } : {}),
|
|
1222
|
+
...(status === 2 ? { note: 'The work was delivered before the deadline and never judged. The escrow sends it for review instead of refunding you: an admin rules, and with no ruling within 14 days the worker is paid. (An escrow from before that change refunds it.)' } : {}),
|
|
1223
|
+
...(status === 3 ? { note: "The work failed verification. This reverts with AppealWindowActive until the worker's 3-day appeal window after the verdict has passed." } : {}),
|
|
983
1224
|
quoteId: quote.quoteId,
|
|
984
1225
|
},
|
|
985
1226
|
next: `Re-call claim_timeout with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
|
|
986
1227
|
});
|
|
987
1228
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1229
|
+
const check = consumeQuote(quoteId, 'timeout', spend);
|
|
1230
|
+
if (!check.ok)
|
|
1231
|
+
return quoteRefused(check, 'claim_timeout');
|
|
991
1232
|
try {
|
|
992
1233
|
const record = {
|
|
993
1234
|
idempotencyKey,
|
|
994
1235
|
kind: 'timeout',
|
|
995
1236
|
stage: 'created',
|
|
996
1237
|
settlement: s.mode,
|
|
1238
|
+
chainId: s.chainId,
|
|
997
1239
|
taskId: Number(detail.taskId),
|
|
1240
|
+
fromStatus: status,
|
|
998
1241
|
taskHash: detail.taskHash,
|
|
999
1242
|
amountWei: detail.amount,
|
|
1000
1243
|
createdAt: new Date().toISOString(),
|
|
@@ -1002,6 +1245,9 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1002
1245
|
};
|
|
1003
1246
|
putSpend(record);
|
|
1004
1247
|
const done = await sendRefund(record);
|
|
1248
|
+
if (done.outcome === 'escalate') {
|
|
1249
|
+
return ok({ ...done, hint: 'Sent for review: nothing was refunded. An admin rules on the delivered work; with no ruling within 14 days the worker is paid.' });
|
|
1250
|
+
}
|
|
1005
1251
|
return ok({ ...done, refunded: refundAmount(detail), hint: 'Escrow returned to the posting wallet.' });
|
|
1006
1252
|
}
|
|
1007
1253
|
catch (err) {
|
|
@@ -1034,7 +1280,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1034
1280
|
title: 'Fetch a Task Brief',
|
|
1035
1281
|
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).",
|
|
1036
1282
|
inputSchema: {
|
|
1037
|
-
rootHash: z.string().min(32).max(80).describe('rootHash from accept_task
|
|
1283
|
+
rootHash: z.string().min(32).max(80).describe('rootHash from accept_task (browse_a2a_tasks also shows it for a public task)'),
|
|
1038
1284
|
wrappedKey: z.string().optional().describe('ECIES-wrapped AES key from accept_task (hex, no 0x). Required for private briefs.'),
|
|
1039
1285
|
},
|
|
1040
1286
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
@@ -1152,12 +1398,33 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1152
1398
|
}
|
|
1153
1399
|
const tx = sub.unsignedSubmitEvidence;
|
|
1154
1400
|
await verifyTarget(s, tx.to, 'submitEvidence');
|
|
1401
|
+
// A zero-value submitEvidence for this task, committing THIS output
|
|
1402
|
+
// (keccak256 of the JSON /submit hashes, backend/src/routes/a2a.ts).
|
|
1403
|
+
// /rebroadcast re-sends the first stored output, so there only the
|
|
1404
|
+
// task is checked.
|
|
1405
|
+
const evidence = keccak256(toUtf8Bytes(JSON.stringify({ output })));
|
|
1406
|
+
assertEscrowCall(tx, 'submitEvidence', (a) => a[0] === BigInt(detail.taskId) && (healed || String(a[1]).toLowerCase() === evidence), 'submitEvidence');
|
|
1407
|
+
// The chain it is signed for is this process's, never the backend's.
|
|
1408
|
+
const pinned = s.chainId ?? walletCtx?.chainId;
|
|
1409
|
+
if (tx.chainId !== undefined && pinned !== undefined && Number(tx.chainId) !== pinned) {
|
|
1410
|
+
return fail('CHAIN_MISMATCH', `The backend built submitEvidence for chain ${tx.chainId}, but this process settles ${s.mode} on chain ${pinned}. Nothing was sent.`);
|
|
1411
|
+
}
|
|
1155
1412
|
if (s.payment === 'relay-erc20') {
|
|
1156
1413
|
const sent = await relaySend(s, { to: tx.to, data: tx.data });
|
|
1157
1414
|
submitTxHash = sent.hash;
|
|
1158
1415
|
gas = sent.gas;
|
|
1159
1416
|
await waitRelayed(s, sent.hash, sent.isUserOp);
|
|
1160
1417
|
}
|
|
1418
|
+
else if (s.payment === 'local-erc20') {
|
|
1419
|
+
// Signed locally over this chain's RPC, like the 0G branch below
|
|
1420
|
+
// but with gas estimated: a revert fails here, unpaid.
|
|
1421
|
+
if (tx.from && tx.from.toLowerCase() !== s.payFrom.toLowerCase()) {
|
|
1422
|
+
return fail('WALLET_MISMATCH', `The backend assigned this task to ${tx.from} (the wallet behind BLINDMARKET_API_KEY), but BLINDMARKET_PRIVATE_KEY is ${s.payFrom}. submitEvidence is worker-only — set the private key of ${tx.from}.`);
|
|
1423
|
+
}
|
|
1424
|
+
const sent = await sendErc20(s, { to: tx.to, data: tx.data });
|
|
1425
|
+
submitTxHash = sent.hash;
|
|
1426
|
+
await waitRelayed(s, sent.hash, false);
|
|
1427
|
+
}
|
|
1161
1428
|
else {
|
|
1162
1429
|
// submitEvidence is onlyWorker: the backend built the tx for the
|
|
1163
1430
|
// API key's wallet (tx.from). If BLINDMARKET_PRIVATE_KEY is a
|
|
@@ -1167,12 +1434,13 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1167
1434
|
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}.`);
|
|
1168
1435
|
}
|
|
1169
1436
|
// 0G: sign locally, exactly as fundAndIndex does for createTask.
|
|
1170
|
-
// The
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1437
|
+
// The chain id is pinned from this process's settlement (checked
|
|
1438
|
+
// against the backend's above); ethers refuses to send it if this
|
|
1439
|
+
// wallet's provider is on a different network — the guard against
|
|
1440
|
+
// a Base tx reaching a 0G signer.
|
|
1173
1441
|
const tx0g = await walletCtx.wallet.sendTransaction({
|
|
1174
1442
|
to: tx.to, data: tx.data, gasLimit: GAS_LIMIT,
|
|
1175
|
-
...(
|
|
1443
|
+
...(pinned !== undefined ? { chainId: pinned } : {}),
|
|
1176
1444
|
});
|
|
1177
1445
|
submitTxHash = tx0g.hash;
|
|
1178
1446
|
await tx0g.wait();
|
|
@@ -1210,6 +1478,278 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1210
1478
|
return fail(err.code ?? 'COMPLETE_FAILED', err.message);
|
|
1211
1479
|
}
|
|
1212
1480
|
});
|
|
1481
|
+
// ── deploy_agent ──────────────────────────────────────────────────────────
|
|
1482
|
+
/** The env var each provider's API key is read from, so the key never passes through the conversation. */
|
|
1483
|
+
const PROVIDER_KEY_ENV = {
|
|
1484
|
+
openai: 'OPENAI_API_KEY',
|
|
1485
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
1486
|
+
groq: 'GROQ_API_KEY',
|
|
1487
|
+
gemini: 'GEMINI_API_KEY',
|
|
1488
|
+
};
|
|
1489
|
+
const coded = (code, message) => Object.assign(new Error(message), { code });
|
|
1490
|
+
/** The local wallet on `chain`, over BLINDMARKET_<CHAIN>_RPC_URL (else a
|
|
1491
|
+
* public RPC for that chain id), checked against the chain id the backend
|
|
1492
|
+
* names, and against `expectChainId` (the fee terms') when given. */
|
|
1493
|
+
async function walletOn(chain, expectChainId) {
|
|
1494
|
+
const res = await fetch(`${cfg.apiBase}/health/bridge`, { signal: AbortSignal.timeout(30_000) });
|
|
1495
|
+
const bridge = await res.json().catch(() => ({}));
|
|
1496
|
+
const entry = (bridge?.data ?? bridge)?.chains?.find((c) => c.chain === chain);
|
|
1497
|
+
const chainId = Number(entry?.chainId);
|
|
1498
|
+
if (!Number.isInteger(chainId) || chainId <= 0)
|
|
1499
|
+
throw coded('SETTLEMENT_UNKNOWN', `The backend lists no chain id for ${chain}.`);
|
|
1500
|
+
if (expectChainId !== undefined && expectChainId !== chainId) {
|
|
1501
|
+
throw coded('SETTLEMENT_UNKNOWN', `The deploy fee is on chain ${expectChainId} but the backend lists ${chain} as chain ${chainId}. Nothing was paid.`);
|
|
1502
|
+
}
|
|
1503
|
+
const envName = rpcEnvName(chain);
|
|
1504
|
+
const rpcUrl = rpcFor(chain, chainId, process.env);
|
|
1505
|
+
if (!rpcUrl)
|
|
1506
|
+
throw coded('RPC_UNKNOWN', `No RPC known for ${chain} (chainId ${chainId}) — set ${envName}.`);
|
|
1507
|
+
const provider = new JsonRpcProvider(rpcUrl, chainId, { staticNetwork: true });
|
|
1508
|
+
const served = Number(BigInt(await provider.send('eth_chainId', [])));
|
|
1509
|
+
if (served !== chainId)
|
|
1510
|
+
throw coded('WRONG_RPC', `${envName} serves chain ${served}, not ${chain} (${chainId}). Nothing was paid.`);
|
|
1511
|
+
return walletCtx.wallet.connect(provider);
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* The backend cannot find a deploy fee recorded before records kept its
|
|
1515
|
+
* chain id, so it may have been paid on another network. Its receipt on the
|
|
1516
|
+
* fee chain's RPC decides the answer, which never sends the caller back into
|
|
1517
|
+
* a retry that cannot succeed. Found there, the backend has not seen it yet:
|
|
1518
|
+
* the record takes that chain id, and the same key finishes the deploy.
|
|
1519
|
+
*/
|
|
1520
|
+
async function unchainedFeeNotFound(idempotencyKey, hash, fee) {
|
|
1521
|
+
let found = null;
|
|
1522
|
+
if (fee) {
|
|
1523
|
+
try {
|
|
1524
|
+
const w = await walletOn(fee.chain, fee.chainId);
|
|
1525
|
+
found = (await w.provider.getTransactionReceipt(hash))?.status === 1;
|
|
1526
|
+
}
|
|
1527
|
+
catch { /* not checkable from here */ }
|
|
1528
|
+
}
|
|
1529
|
+
if (fee && found) {
|
|
1530
|
+
if (fee.chainId !== undefined)
|
|
1531
|
+
updateSpend(idempotencyKey, { chainId: fee.chainId });
|
|
1532
|
+
return fail('DEPLOY_FEE_NOT_FOUND', `The deploy fee ${hash} is on ${fee.chain}, but the backend has not seen it yet. Retry with the same idempotencyKey in a minute; nothing is paid again.`);
|
|
1533
|
+
}
|
|
1534
|
+
if (fee && found === false) {
|
|
1535
|
+
return fail('SETTLEMENT_CHANGED', `The deploy fee ${hash} was recorded without its chain, and it is not on ${fee.chain}${fee.chainId !== undefined ? ` (chain ${fee.chainId})` : ''}, where the backend takes the fee now: it was paid on another network and does not count here. ` +
|
|
1536
|
+
`Nothing was paid again: deploy with a new idempotencyKey to pay on ${fee.chain}.`);
|
|
1537
|
+
}
|
|
1538
|
+
return fail('DEPLOY_FEE_NOT_FOUND', `The backend cannot find the deploy fee ${hash}, which was recorded without its chain, and it could not be checked here. It may have been paid on another network, where it does not count. ` +
|
|
1539
|
+
'Nothing was paid again: find where it landed before paying with a new idempotencyKey.');
|
|
1540
|
+
}
|
|
1541
|
+
server.registerTool('deploy_agent', {
|
|
1542
|
+
title: 'Deploy a Hosted Agent',
|
|
1543
|
+
description: "Deploy a hosted agent that runs on BlindMarket and takes tasks, owned by the API key's wallet. Deploying costs a fee (1 USDC on Arc on production), paid as one USDC transfer on Arc from the local wallet (BLINDMARKET_PRIVATE_KEY), which must be the API key's owner. The model provider's key is read from this server's environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY or GEMINI_API_KEY; none for 0g-compute), never passed as an argument. TWO-STEP quote/confirm like post_task; requires a unique idempotencyKey, and a retry with the same key never pays twice.",
|
|
1544
|
+
inputSchema: {
|
|
1545
|
+
name: z.string().min(1).max(80).describe('Agent name'),
|
|
1546
|
+
instructions: z.string().min(1).max(100_000).describe("The agent's instructions: what it does and how"),
|
|
1547
|
+
provider: z.enum(['openai', 'anthropic', 'groq', 'gemini', '0g-compute']).describe("LLM provider. '0g-compute' needs no API key: inference is billed to the agent's own wallet"),
|
|
1548
|
+
model: z.string().min(1).describe('Model id, e.g. gpt-4o-mini or claude-sonnet-4-5'),
|
|
1549
|
+
skillSlugs: z.array(z.string()).max(10).optional().describe('Public skills to install at deploy, by slug'),
|
|
1550
|
+
idempotencyKey: z.string().min(8).max(128).describe('Unique key for this deploy — reuse it on retries'),
|
|
1551
|
+
confirm: z.boolean().optional().describe('Set true (with quoteId) to pay the fee and deploy'),
|
|
1552
|
+
quoteId: z.string().optional().describe('From the quote step'),
|
|
1553
|
+
},
|
|
1554
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1555
|
+
}, async ({ name, instructions, provider, model, skillSlugs, idempotencyKey, confirm, quoteId }) => {
|
|
1556
|
+
if (!walletCtx) {
|
|
1557
|
+
return fail('NO_WALLET', 'deploy_agent pays the fee from, and encrypts the agent key to, the local wallet — set BLINDMARKET_PRIVATE_KEY to the key of the wallet that owns BLINDMARKET_API_KEY.');
|
|
1558
|
+
}
|
|
1559
|
+
let apiKey = '';
|
|
1560
|
+
if (provider !== '0g-compute') {
|
|
1561
|
+
const envName = PROVIDER_KEY_ENV[provider];
|
|
1562
|
+
apiKey = process.env[envName] ?? '';
|
|
1563
|
+
if (!apiKey)
|
|
1564
|
+
return fail('PROVIDER_KEY_MISSING', `Set ${envName} in this server's environment: the agent calls ${provider} with it. It is read there so it never passes through the conversation.`);
|
|
1565
|
+
}
|
|
1566
|
+
const body = {
|
|
1567
|
+
name, instructions, provider, model, apiKey,
|
|
1568
|
+
capabilities: [],
|
|
1569
|
+
skillSlugs: skillSlugs ?? [],
|
|
1570
|
+
// The agent's private key is encrypted to the local wallet's key.
|
|
1571
|
+
ownerPublicKey: derivePublicKeyHex(walletCtx.wallet.privateKey),
|
|
1572
|
+
};
|
|
1573
|
+
const summary = (a) => ({ agentId: a.id, name: a.name, walletAddress: a.walletAddress, started: a.started === true });
|
|
1574
|
+
/** POST /agents/deploy, asking again while the backend has not seen the fee confirm. */
|
|
1575
|
+
const deploy = async (feeTxHash) => {
|
|
1576
|
+
for (let attempt = 1;; attempt++) {
|
|
1577
|
+
try {
|
|
1578
|
+
return await api('POST', '/api/v1/agents/deploy', feeTxHash ? { ...body, feeTxHash } : body);
|
|
1579
|
+
}
|
|
1580
|
+
catch (err) {
|
|
1581
|
+
if (err.code !== 'DEPLOY_FEE_NOT_FOUND' || attempt >= 3)
|
|
1582
|
+
throw err;
|
|
1583
|
+
await new Promise((r) => setTimeout(r, Number(process.env.BLINDMARKET_DEPLOY_POLL_MS ?? 5000)));
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
/** The deploy's own checks, run before anything is quoted or paid. A
|
|
1588
|
+
* backend without the route (404) is not checked here; the deploy
|
|
1589
|
+
* still checks before it takes the fee. */
|
|
1590
|
+
const validate = async () => {
|
|
1591
|
+
try {
|
|
1592
|
+
await api('POST', '/api/v1/agents/deploy/validate', body);
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
catch (err) {
|
|
1596
|
+
const code = err.code;
|
|
1597
|
+
if (code === undefined && /failed: 404$/.test(err.message))
|
|
1598
|
+
return null;
|
|
1599
|
+
return fail(code ?? 'DEPLOY_INVALID', `${err.message}. Nothing was paid.`);
|
|
1600
|
+
}
|
|
1601
|
+
};
|
|
1602
|
+
const paidNote = () => {
|
|
1603
|
+
const rec = getSpend(idempotencyKey);
|
|
1604
|
+
return rec?.stage === 'sent' && rec.txHash
|
|
1605
|
+
? ` The fee is paid (${rec.txHash}): retry with the SAME idempotencyKey to deploy without paying again.`
|
|
1606
|
+
: '';
|
|
1607
|
+
};
|
|
1608
|
+
const existing = getSpend(idempotencyKey);
|
|
1609
|
+
if (existing && existing.kind !== 'deploy') {
|
|
1610
|
+
return fail('IDEMPOTENCY_KEY_IN_USE', `idempotencyKey ${idempotencyKey} belongs to a ${existing.kind} spend — use a new key for this deploy.`);
|
|
1611
|
+
}
|
|
1612
|
+
if (existing?.stage === 'confirmed') {
|
|
1613
|
+
return ok({ resumed: true, agentId: existing.agentId, feeTxHash: existing.txHash, hint: 'Already deployed with this idempotencyKey.' });
|
|
1614
|
+
}
|
|
1615
|
+
if (existing?.stage === 'sent' && existing.txHash) {
|
|
1616
|
+
// Paid before: finish the deploy with that payment. It counts only on
|
|
1617
|
+
// the network it was paid on, and a chain keeps its key when the
|
|
1618
|
+
// backend moves it to another one, so the chain ids decide.
|
|
1619
|
+
const current = await api('GET', '/api/v1/agents/deploy-fee').catch(() => null);
|
|
1620
|
+
const currentFee = current?.required && current.method === 'transfer' ? current : null;
|
|
1621
|
+
if (existing.chainId !== undefined && currentFee?.chainId !== undefined && currentFee.chainId !== existing.chainId) {
|
|
1622
|
+
return fail('SETTLEMENT_CHANGED', `The deploy fee for ${idempotencyKey} was paid on chain ${existing.chainId} (${existing.txHash}), but the backend takes it on chain ${currentFee.chainId} now, where that payment does not count. ` +
|
|
1623
|
+
`Nothing was paid again: deploy with a new idempotencyKey to pay on chain ${currentFee.chainId}.`);
|
|
1624
|
+
}
|
|
1625
|
+
try {
|
|
1626
|
+
const agent = await deploy(existing.txHash);
|
|
1627
|
+
updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
|
|
1628
|
+
return ok({ resumed: true, ...summary(agent), feeTxHash: existing.txHash });
|
|
1629
|
+
}
|
|
1630
|
+
catch (err) {
|
|
1631
|
+
const code = err.code;
|
|
1632
|
+
if (code === 'DEPLOY_FEE_NOT_FOUND' && existing.chainId === undefined) {
|
|
1633
|
+
return unchainedFeeNotFound(idempotencyKey, existing.txHash, currentFee);
|
|
1634
|
+
}
|
|
1635
|
+
return fail(code ?? 'DEPLOY_FAILED', `${err.message}.${paidNote()}`);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
let terms;
|
|
1639
|
+
try {
|
|
1640
|
+
terms = await api('GET', '/api/v1/agents/deploy-fee');
|
|
1641
|
+
}
|
|
1642
|
+
catch (err) {
|
|
1643
|
+
return fail(err.code ?? 'DEPLOY_FEE_UNKNOWN', err.message);
|
|
1644
|
+
}
|
|
1645
|
+
if (terms.required && terms.method !== 'transfer') {
|
|
1646
|
+
return fail('UNSUPPORTED_FEE_METHOD', 'This backend takes the deploy fee through AgentFactory only, which this server does not pay. Deploy from the web app.');
|
|
1647
|
+
}
|
|
1648
|
+
const fee = terms.required ? terms : null;
|
|
1649
|
+
const feeText = fee ? `${formatUnits(BigInt(fee.amountRaw), fee.decimals).replace(/\.0$/, '')} USDC` : 'none';
|
|
1650
|
+
// The fee terms and the agent this call would pay for, bound into the
|
|
1651
|
+
// quote: the confirm re-reads the terms, and pays only what was quoted.
|
|
1652
|
+
const spend = {
|
|
1653
|
+
idempotencyKey,
|
|
1654
|
+
payFrom: walletCtx.wallet.address.toLowerCase(),
|
|
1655
|
+
name,
|
|
1656
|
+
provider,
|
|
1657
|
+
model,
|
|
1658
|
+
instructions: sha256Hex(Buffer.from(instructions, 'utf8')),
|
|
1659
|
+
skills: JSON.stringify(skillSlugs ?? []),
|
|
1660
|
+
feeRequired: fee !== null,
|
|
1661
|
+
feeChain: fee?.chain ?? null,
|
|
1662
|
+
feeChainId: fee?.chainId ?? null,
|
|
1663
|
+
feeToken: fee ? String(fee.token).toLowerCase() : null,
|
|
1664
|
+
feeRecipient: fee ? String(fee.recipient).toLowerCase() : null,
|
|
1665
|
+
feeAmountRaw: fee ? String(fee.amountRaw) : null,
|
|
1666
|
+
feeDecimals: fee?.decimals ?? null,
|
|
1667
|
+
};
|
|
1668
|
+
if (!confirm) {
|
|
1669
|
+
const invalid = await validate();
|
|
1670
|
+
if (invalid)
|
|
1671
|
+
return invalid;
|
|
1672
|
+
let walletBalance;
|
|
1673
|
+
if (fee) {
|
|
1674
|
+
try {
|
|
1675
|
+
const w = await walletOn(fee.chain, fee.chainId);
|
|
1676
|
+
const bal = await new Contract(fee.token, ['function balanceOf(address) view returns (uint256)'], w).balanceOf(w.address);
|
|
1677
|
+
walletBalance = formatUnits(bal, fee.decimals);
|
|
1678
|
+
}
|
|
1679
|
+
catch (err) {
|
|
1680
|
+
if (['RPC_UNKNOWN', 'WRONG_RPC', 'SETTLEMENT_UNKNOWN'].includes(err.code ?? '')) {
|
|
1681
|
+
return fail(err.code, err.message);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
const quote = createQuote('deploy', { name, provider, model, fee: feeText }, spend);
|
|
1686
|
+
return ok({
|
|
1687
|
+
quote: {
|
|
1688
|
+
agent: { name, provider, model, skills: skillSlugs ?? [] },
|
|
1689
|
+
fee: feeText,
|
|
1690
|
+
chain: fee?.chain,
|
|
1691
|
+
payTo: fee?.recipient,
|
|
1692
|
+
payFrom: walletCtx.wallet.address,
|
|
1693
|
+
walletBalance,
|
|
1694
|
+
quoteId: quote.quoteId,
|
|
1695
|
+
},
|
|
1696
|
+
next: `Re-call deploy_agent with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to ${fee ? 'pay the fee and ' : ''}deploy.`,
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
const check = consumeQuote(quoteId, 'deploy', spend);
|
|
1700
|
+
if (!check.ok) {
|
|
1701
|
+
const feeChanged = check.code === 'QUOTE_MISMATCH' && check.changed.some((k) => k.startsWith('fee'))
|
|
1702
|
+
? `the deploy fee changed since the quote (quoted ${check.quote.summary.fee}, now ${feeText}${fee ? ` to ${fee.recipient} on ${fee.chain}` : ''})`
|
|
1703
|
+
: undefined;
|
|
1704
|
+
return quoteRefused(check, 'deploy_agent', feeChanged);
|
|
1705
|
+
}
|
|
1706
|
+
try {
|
|
1707
|
+
const now = new Date().toISOString();
|
|
1708
|
+
let feeTxHash;
|
|
1709
|
+
if (fee) {
|
|
1710
|
+
// The backend counts a fee only from the API key's owner: check before paying.
|
|
1711
|
+
const { address: owner } = await api('GET', '/api/v1/api-keys/whoami');
|
|
1712
|
+
if (String(owner).toLowerCase() !== walletCtx.wallet.address.toLowerCase()) {
|
|
1713
|
+
return fail('OWNER_MISMATCH', `BLINDMARKET_API_KEY belongs to ${owner} but BLINDMARKET_PRIVATE_KEY is ${walletCtx.wallet.address}. Nothing was paid: the backend counts a deploy fee only from the API key's owner.`);
|
|
1714
|
+
}
|
|
1715
|
+
const invalid = await validate();
|
|
1716
|
+
if (invalid)
|
|
1717
|
+
return invalid;
|
|
1718
|
+
const w = await walletOn(fee.chain, fee.chainId);
|
|
1719
|
+
putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', settlement: fee.chain, chainId: fee.chainId, token: fee.token, amountWei: fee.amountRaw, createdAt: now, updatedAt: now });
|
|
1720
|
+
const data = new Interface(['function transfer(address to, uint256 amount) returns (bool)'])
|
|
1721
|
+
.encodeFunctionData('transfer', [fee.recipient, BigInt(fee.amountRaw)]);
|
|
1722
|
+
const tx = await w.sendTransaction({ to: fee.token, data });
|
|
1723
|
+
// Recorded the moment it is broadcast: a retry resumes from here and never pays twice.
|
|
1724
|
+
updateSpend(idempotencyKey, { stage: 'sent', txHash: tx.hash });
|
|
1725
|
+
try {
|
|
1726
|
+
await tx.wait();
|
|
1727
|
+
}
|
|
1728
|
+
catch (err) {
|
|
1729
|
+
if (err.code === 'CALL_EXCEPTION') {
|
|
1730
|
+
updateSpend(idempotencyKey, { stage: 'created', txHash: undefined });
|
|
1731
|
+
return fail('FEE_REVERTED', `The fee transfer ${tx.hash} reverted, so nothing was paid. Check the wallet's USDC on ${fee.chain} and retry.`);
|
|
1732
|
+
}
|
|
1733
|
+
// Not confirmed yet: the backend waits for the receipt itself.
|
|
1734
|
+
}
|
|
1735
|
+
feeTxHash = tx.hash;
|
|
1736
|
+
}
|
|
1737
|
+
else {
|
|
1738
|
+
putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', createdAt: now, updatedAt: now });
|
|
1739
|
+
}
|
|
1740
|
+
const agent = await deploy(feeTxHash);
|
|
1741
|
+
updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
|
|
1742
|
+
return ok({
|
|
1743
|
+
...summary(agent),
|
|
1744
|
+
fee: feeText,
|
|
1745
|
+
feeTxHash,
|
|
1746
|
+
hint: agent.started ? 'The agent is running.' : 'The agent was created but did not start — start it with start_agent.',
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
catch (err) {
|
|
1750
|
+
return fail(err.code ?? 'DEPLOY_FAILED', `${err.message}.${paidNote()}`);
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1213
1753
|
return { settlement };
|
|
1214
1754
|
}
|
|
1215
1755
|
//# sourceMappingURL=rent.js.map
|