@blindmarket/mcp-server 0.5.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 +52 -10
- package/dist/rent.d.ts.map +1 -1
- package/dist/rent.js +298 -47
- package/dist/rent.js.map +1 -1
- package/dist/settlement.d.ts +2 -1
- package/dist/settlement.d.ts.map +1 -1
- package/dist/settlement.js +3 -1
- package/dist/settlement.js.map +1 -1
- package/dist/state.d.ts +43 -2
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +23 -6
- package/dist/state.js.map +1 -1
- package/package.json +2 -2
package/dist/rent.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { Contract, Interface, JsonRpcProvider, formatUnits, parseUnits } from 'ethers';
|
|
2
|
+
import { Contract, Interface, JsonRpcProvider, formatUnits, keccak256, parseUnits, toUtf8Bytes } from 'ethers';
|
|
3
3
|
import { aesDecrypt, aesEncrypt, derivePublicKeyHex, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
|
|
4
|
-
import { createQuote, consumeQuote, getSpend, putSpend, updateSpend } from './state.js';
|
|
4
|
+
import { createQuote, consumeQuote, getSpend, putSpend, updateSpend, } from './state.js';
|
|
5
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;
|
|
@@ -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
|
|
@@ -253,7 +325,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
253
325
|
/** Wait for a relayed tx to land. A plain hash can be polled for its receipt;
|
|
254
326
|
* a user-op hash cannot (getTransactionReceipt is always null for it), so
|
|
255
327
|
* that case returns at once and the caller confirms by on-chain STATE —
|
|
256
|
-
* see ensureAllowance and
|
|
328
|
+
* see ensureAllowance and waitLanded. */
|
|
257
329
|
async function waitRelayed(s, hash, isUserOp) {
|
|
258
330
|
if (isUserOp)
|
|
259
331
|
return;
|
|
@@ -345,6 +417,14 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
345
417
|
e.code = 'SETTLEMENT_CHANGED';
|
|
346
418
|
throw e;
|
|
347
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
|
+
}
|
|
348
428
|
const refused = await notPostingChain(s);
|
|
349
429
|
if (refused)
|
|
350
430
|
throw refused;
|
|
@@ -374,6 +454,15 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
374
454
|
throw e;
|
|
375
455
|
}
|
|
376
456
|
await verifyTarget(s, unsignedTx.to, 'createTask');
|
|
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);
|
|
377
466
|
if (isErc20Settlement(s)) {
|
|
378
467
|
const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data }, nonce);
|
|
379
468
|
// Persist BEFORE waiting, same reasoning as the 0G branch below.
|
|
@@ -442,7 +531,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
442
531
|
// ── rent_service ──────────────────────────────────────────────────────────
|
|
443
532
|
server.registerTool('rent_service', {
|
|
444
533
|
title: 'Rent an Agent Service',
|
|
445
|
-
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 confirm=true and that quoteId to actually spend. Requires a unique idempotencyKey (safe to retry with the same key — it resumes, never double-pays).',
|
|
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).',
|
|
446
535
|
inputSchema: {
|
|
447
536
|
serviceId: z.number().int().positive().describe('Service id from browse_services / get_service'),
|
|
448
537
|
prompt: z.string().min(1).max(100_000).describe('What you want the agent to do'),
|
|
@@ -496,8 +585,22 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
496
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.`);
|
|
497
586
|
}
|
|
498
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
|
+
};
|
|
499
602
|
if (!confirm) {
|
|
500
|
-
const quote = createQuote('rent', { serviceId, price, currency: s.symbol });
|
|
603
|
+
const quote = createQuote('rent', { serviceId, price, currency: s.symbol }, spend);
|
|
501
604
|
return ok({
|
|
502
605
|
quote: {
|
|
503
606
|
service: { id: service.id, name: service.name, agent: service.agent_address },
|
|
@@ -512,8 +615,12 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
512
615
|
next: `Re-call rent_service with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to execute this spend.`,
|
|
513
616
|
});
|
|
514
617
|
}
|
|
515
|
-
|
|
516
|
-
|
|
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);
|
|
517
624
|
}
|
|
518
625
|
try {
|
|
519
626
|
// Prepare the brief blob (the canonical script's steps 1-3).
|
|
@@ -550,8 +657,10 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
550
657
|
verificationMode: 'auto',
|
|
551
658
|
verificationCriteria: RENTAL_VERIFICATION_CRITERIA,
|
|
552
659
|
requiredCapabilities: [],
|
|
553
|
-
|
|
660
|
+
// The quoted price, which the binding above just matched.
|
|
661
|
+
amountWei: priceRaw.toString(),
|
|
554
662
|
settlement: s.mode,
|
|
663
|
+
chainId: s.chainId,
|
|
555
664
|
token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
|
|
556
665
|
durationSecs: 3600,
|
|
557
666
|
createdAt: new Date().toISOString(),
|
|
@@ -560,7 +669,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
560
669
|
putSpend(record);
|
|
561
670
|
const done = await fundAndIndex(record);
|
|
562
671
|
const polled = await pollPosted(done.taskHash, waitSeconds ?? 45);
|
|
563
|
-
return ok({ ...done, ...polled });
|
|
672
|
+
return ok({ ...done, escrowed: { amount: price, amountRaw: priceRaw.toString(), currency: s.symbol }, ...polled });
|
|
564
673
|
}
|
|
565
674
|
catch (err) {
|
|
566
675
|
const code = err.code;
|
|
@@ -573,7 +682,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
573
682
|
// ── post_task ─────────────────────────────────────────────────────────────
|
|
574
683
|
server.registerTool('post_task', {
|
|
575
684
|
title: 'Post a Task to the Open Market',
|
|
576
|
-
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; requires a unique idempotencyKey.',
|
|
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.',
|
|
577
686
|
inputSchema: {
|
|
578
687
|
instructions: z.string().min(1).max(100_000).describe('The task brief'),
|
|
579
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'),
|
|
@@ -619,8 +728,19 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
619
728
|
catch {
|
|
620
729
|
return fail('AMOUNT_INVALID', `"${amountStr}" is not a valid ${s.symbol} amount — at most ${s.decimals} decimal places.`);
|
|
621
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
|
+
};
|
|
622
742
|
if (!confirm) {
|
|
623
|
-
const quote = createQuote('post', { amount: amountStr, currency: s.symbol });
|
|
743
|
+
const quote = createQuote('post', { amount: amountStr, currency: s.symbol }, spend);
|
|
624
744
|
return ok({
|
|
625
745
|
quote: {
|
|
626
746
|
escrow: amountStr,
|
|
@@ -635,9 +755,9 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
635
755
|
next: `Re-call post_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to execute this spend.`,
|
|
636
756
|
});
|
|
637
757
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
758
|
+
const check = consumeQuote(quoteId, 'post', spend);
|
|
759
|
+
if (!check.ok)
|
|
760
|
+
return quoteRefused(check, 'post_task');
|
|
641
761
|
try {
|
|
642
762
|
const plaintext = Buffer.from(instructions, 'utf8');
|
|
643
763
|
let blobB64;
|
|
@@ -684,14 +804,21 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
684
804
|
requiredCapabilities: capabilities ?? [],
|
|
685
805
|
amountWei: amountWei.toString(),
|
|
686
806
|
settlement: s.mode,
|
|
807
|
+
chainId: s.chainId,
|
|
687
808
|
token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
|
|
688
|
-
durationSecs
|
|
809
|
+
durationSecs,
|
|
689
810
|
createdAt: new Date().toISOString(),
|
|
690
811
|
updatedAt: new Date().toISOString(),
|
|
691
812
|
};
|
|
692
813
|
putSpend(record);
|
|
693
814
|
const done = await fundAndIndex(record);
|
|
694
|
-
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
|
+
});
|
|
695
822
|
}
|
|
696
823
|
catch (err) {
|
|
697
824
|
const code = err.code;
|
|
@@ -722,6 +849,11 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
722
849
|
// Disputed (6) → claimTimeout, but only once
|
|
723
850
|
// DISPUTE_WINDOW has elapsed
|
|
724
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
|
+
//
|
|
725
857
|
// So the quote step reads the live status first and refuses a path the
|
|
726
858
|
// contract would reject, rather than letting the caller burn gas to find out.
|
|
727
859
|
//
|
|
@@ -810,18 +942,30 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
810
942
|
function refundAmount(detail) {
|
|
811
943
|
return formatUnits(BigInt(detail.amount), detail.decimals ?? 18);
|
|
812
944
|
}
|
|
813
|
-
/**
|
|
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
|
|
814
958
|
* state rather than a receipt is what makes a relayed user-op decidable
|
|
815
959
|
* (there is no receipt to poll), and it is a cheap truth check for the
|
|
816
|
-
* local-signing path too. */
|
|
817
|
-
async function
|
|
960
|
+
* local-signing path too. Resolves to the status it read. */
|
|
961
|
+
async function waitLanded(s, taskId, settled) {
|
|
818
962
|
for (let i = 0; i < 30; i++) {
|
|
819
963
|
const detail = await loadTask(s, String(taskId)).catch(() => null);
|
|
820
|
-
if (detail && Number(detail.status)
|
|
821
|
-
return;
|
|
964
|
+
if (detail && settled.includes(Number(detail.status)))
|
|
965
|
+
return Number(detail.status);
|
|
822
966
|
await new Promise((r) => setTimeout(r, 3000));
|
|
823
967
|
}
|
|
824
|
-
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`);
|
|
825
969
|
e.code = 'REFUND_PENDING';
|
|
826
970
|
throw e;
|
|
827
971
|
}
|
|
@@ -839,6 +983,18 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
839
983
|
e.code = 'SETTLEMENT_CHANGED';
|
|
840
984
|
throw e;
|
|
841
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}.`));
|
|
995
|
+
e.code = 'SETTLEMENT_CHANGED';
|
|
996
|
+
throw e;
|
|
997
|
+
}
|
|
842
998
|
if (record.stage === 'created') {
|
|
843
999
|
const route = record.kind === 'cancel' ? 'cancel' : 'timeout';
|
|
844
1000
|
// Task ids repeat across chains: name this one (the backend knows
|
|
@@ -849,6 +1005,8 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
849
1005
|
// broadcasts on, stop here — relaying a 0G refund onto another chain
|
|
850
1006
|
// lands on an address with no escrow and burns the gas.
|
|
851
1007
|
await verifyTarget(s, unsignedTx.to, `${route}Task`);
|
|
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`);
|
|
852
1010
|
if (isErc20Settlement(s)) {
|
|
853
1011
|
const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data });
|
|
854
1012
|
// Persist BEFORE waiting, same reasoning as the 0G branch below.
|
|
@@ -881,9 +1039,12 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
881
1039
|
else {
|
|
882
1040
|
throw new Error(`Spend record ${record.idempotencyKey} is at stage '${record.stage}' with no txHash — cannot resume safely`);
|
|
883
1041
|
}
|
|
884
|
-
await
|
|
885
|
-
|
|
886
|
-
|
|
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 };
|
|
887
1048
|
}
|
|
888
1049
|
/** Tell the backend the refund landed (POST /tasks/:id/confirm-tx): it
|
|
889
1050
|
* checks the receipt and takes the task off the market, which otherwise
|
|
@@ -919,7 +1080,10 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
919
1080
|
resumed: true,
|
|
920
1081
|
taskId: existing.taskId,
|
|
921
1082
|
txHash: existing.txHash,
|
|
922
|
-
|
|
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.',
|
|
923
1087
|
});
|
|
924
1088
|
}
|
|
925
1089
|
try {
|
|
@@ -961,8 +1125,11 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
961
1125
|
: ' The escrow is already settled — nothing to reclaim.';
|
|
962
1126
|
return fail('WRONG_REFUND_PATH', `Task ${detail.taskId} is ${statusName(status)}, and cancelTask only accepts Funded tasks.${alt}`);
|
|
963
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);
|
|
964
1131
|
if (!confirm) {
|
|
965
|
-
const quote = createQuote('cancel', { taskId: detail.taskId });
|
|
1132
|
+
const quote = createQuote('cancel', { taskId: detail.taskId }, spend);
|
|
966
1133
|
return ok({
|
|
967
1134
|
quote: {
|
|
968
1135
|
action: 'cancelTask',
|
|
@@ -976,15 +1143,16 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
976
1143
|
next: `Re-call cancel_task with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
|
|
977
1144
|
});
|
|
978
1145
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1146
|
+
const check = consumeQuote(quoteId, 'cancel', spend);
|
|
1147
|
+
if (!check.ok)
|
|
1148
|
+
return quoteRefused(check, 'cancel_task');
|
|
982
1149
|
try {
|
|
983
1150
|
const record = {
|
|
984
1151
|
idempotencyKey,
|
|
985
1152
|
kind: 'cancel',
|
|
986
1153
|
stage: 'created',
|
|
987
1154
|
settlement: s.mode,
|
|
1155
|
+
chainId: s.chainId,
|
|
988
1156
|
taskId: Number(detail.taskId),
|
|
989
1157
|
taskHash: detail.taskHash,
|
|
990
1158
|
amountWei: detail.amount,
|
|
@@ -1001,7 +1169,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1001
1169
|
});
|
|
1002
1170
|
server.registerTool('claim_timeout', {
|
|
1003
1171
|
title: 'Reclaim Escrow After the Deadline',
|
|
1004
|
-
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.',
|
|
1005
1173
|
inputSchema: {
|
|
1006
1174
|
task: z.string().min(1).describe('Task id (e.g. "51") or the 0x task hash returned by post_task'),
|
|
1007
1175
|
idempotencyKey: z.string().min(8).max(128).describe('Unique key for this refund — reuse it on retries'),
|
|
@@ -1036,8 +1204,9 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1036
1204
|
if (now < deadline) {
|
|
1037
1205
|
return fail('DEADLINE_NOT_REACHED', `Task ${detail.taskId} is still live until ${new Date(Number(deadline) * 1000).toISOString()} — claimTimeout reverts before then.`);
|
|
1038
1206
|
}
|
|
1207
|
+
const spend = refundFields(s, payFrom, idempotencyKey, detail);
|
|
1039
1208
|
if (!confirm) {
|
|
1040
|
-
const quote = createQuote('timeout', { taskId: detail.taskId });
|
|
1209
|
+
const quote = createQuote('timeout', { taskId: detail.taskId }, spend);
|
|
1041
1210
|
return ok({
|
|
1042
1211
|
quote: {
|
|
1043
1212
|
action: 'claimTimeout',
|
|
@@ -1049,22 +1218,26 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1049
1218
|
settlement: s.mode,
|
|
1050
1219
|
// DISPUTE_WINDOW is enforced on-chain and disputedAt is not exposed
|
|
1051
1220
|
// here, so a disputed task can still revert after this quote.
|
|
1052
|
-
...(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." } : {}),
|
|
1053
1224
|
quoteId: quote.quoteId,
|
|
1054
1225
|
},
|
|
1055
1226
|
next: `Re-call claim_timeout with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to send it.`,
|
|
1056
1227
|
});
|
|
1057
1228
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1229
|
+
const check = consumeQuote(quoteId, 'timeout', spend);
|
|
1230
|
+
if (!check.ok)
|
|
1231
|
+
return quoteRefused(check, 'claim_timeout');
|
|
1061
1232
|
try {
|
|
1062
1233
|
const record = {
|
|
1063
1234
|
idempotencyKey,
|
|
1064
1235
|
kind: 'timeout',
|
|
1065
1236
|
stage: 'created',
|
|
1066
1237
|
settlement: s.mode,
|
|
1238
|
+
chainId: s.chainId,
|
|
1067
1239
|
taskId: Number(detail.taskId),
|
|
1240
|
+
fromStatus: status,
|
|
1068
1241
|
taskHash: detail.taskHash,
|
|
1069
1242
|
amountWei: detail.amount,
|
|
1070
1243
|
createdAt: new Date().toISOString(),
|
|
@@ -1072,6 +1245,9 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1072
1245
|
};
|
|
1073
1246
|
putSpend(record);
|
|
1074
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
|
+
}
|
|
1075
1251
|
return ok({ ...done, refunded: refundAmount(detail), hint: 'Escrow returned to the posting wallet.' });
|
|
1076
1252
|
}
|
|
1077
1253
|
catch (err) {
|
|
@@ -1104,7 +1280,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1104
1280
|
title: 'Fetch a Task Brief',
|
|
1105
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).",
|
|
1106
1282
|
inputSchema: {
|
|
1107
|
-
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)'),
|
|
1108
1284
|
wrappedKey: z.string().optional().describe('ECIES-wrapped AES key from accept_task (hex, no 0x). Required for private briefs.'),
|
|
1109
1285
|
},
|
|
1110
1286
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
@@ -1222,6 +1398,17 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1222
1398
|
}
|
|
1223
1399
|
const tx = sub.unsignedSubmitEvidence;
|
|
1224
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
|
+
}
|
|
1225
1412
|
if (s.payment === 'relay-erc20') {
|
|
1226
1413
|
const sent = await relaySend(s, { to: tx.to, data: tx.data });
|
|
1227
1414
|
submitTxHash = sent.hash;
|
|
@@ -1247,12 +1434,13 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1247
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}.`);
|
|
1248
1435
|
}
|
|
1249
1436
|
// 0G: sign locally, exactly as fundAndIndex does for createTask.
|
|
1250
|
-
// The
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
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.
|
|
1253
1441
|
const tx0g = await walletCtx.wallet.sendTransaction({
|
|
1254
1442
|
to: tx.to, data: tx.data, gasLimit: GAS_LIMIT,
|
|
1255
|
-
...(
|
|
1443
|
+
...(pinned !== undefined ? { chainId: pinned } : {}),
|
|
1256
1444
|
});
|
|
1257
1445
|
submitTxHash = tx0g.hash;
|
|
1258
1446
|
await tx0g.wait();
|
|
@@ -1322,6 +1510,34 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1322
1510
|
throw coded('WRONG_RPC', `${envName} serves chain ${served}, not ${chain} (${chainId}). Nothing was paid.`);
|
|
1323
1511
|
return walletCtx.wallet.connect(provider);
|
|
1324
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
|
+
}
|
|
1325
1541
|
server.registerTool('deploy_agent', {
|
|
1326
1542
|
title: 'Deploy a Hosted Agent',
|
|
1327
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.",
|
|
@@ -1397,14 +1613,26 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1397
1613
|
return ok({ resumed: true, agentId: existing.agentId, feeTxHash: existing.txHash, hint: 'Already deployed with this idempotencyKey.' });
|
|
1398
1614
|
}
|
|
1399
1615
|
if (existing?.stage === 'sent' && existing.txHash) {
|
|
1400
|
-
// Paid before: finish the deploy with that payment.
|
|
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
|
+
}
|
|
1401
1625
|
try {
|
|
1402
1626
|
const agent = await deploy(existing.txHash);
|
|
1403
1627
|
updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
|
|
1404
1628
|
return ok({ resumed: true, ...summary(agent), feeTxHash: existing.txHash });
|
|
1405
1629
|
}
|
|
1406
1630
|
catch (err) {
|
|
1407
|
-
|
|
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()}`);
|
|
1408
1636
|
}
|
|
1409
1637
|
}
|
|
1410
1638
|
let terms;
|
|
@@ -1419,6 +1647,24 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1419
1647
|
}
|
|
1420
1648
|
const fee = terms.required ? terms : null;
|
|
1421
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
|
+
};
|
|
1422
1668
|
if (!confirm) {
|
|
1423
1669
|
const invalid = await validate();
|
|
1424
1670
|
if (invalid)
|
|
@@ -1436,7 +1682,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1436
1682
|
}
|
|
1437
1683
|
}
|
|
1438
1684
|
}
|
|
1439
|
-
const quote = createQuote('deploy', { name, provider, model, fee: feeText });
|
|
1685
|
+
const quote = createQuote('deploy', { name, provider, model, fee: feeText }, spend);
|
|
1440
1686
|
return ok({
|
|
1441
1687
|
quote: {
|
|
1442
1688
|
agent: { name, provider, model, skills: skillSlugs ?? [] },
|
|
@@ -1450,8 +1696,12 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1450
1696
|
next: `Re-call deploy_agent with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to ${fee ? 'pay the fee and ' : ''}deploy.`,
|
|
1451
1697
|
});
|
|
1452
1698
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
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);
|
|
1455
1705
|
}
|
|
1456
1706
|
try {
|
|
1457
1707
|
const now = new Date().toISOString();
|
|
@@ -1466,7 +1716,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1466
1716
|
if (invalid)
|
|
1467
1717
|
return invalid;
|
|
1468
1718
|
const w = await walletOn(fee.chain, fee.chainId);
|
|
1469
|
-
putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', settlement: fee.chain, token: fee.token, amountWei: fee.amountRaw, createdAt: now, updatedAt: now });
|
|
1719
|
+
putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', settlement: fee.chain, chainId: fee.chainId, token: fee.token, amountWei: fee.amountRaw, createdAt: now, updatedAt: now });
|
|
1470
1720
|
const data = new Interface(['function transfer(address to, uint256 amount) returns (bool)'])
|
|
1471
1721
|
.encodeFunctionData('transfer', [fee.recipient, BigInt(fee.amountRaw)]);
|
|
1472
1722
|
const tx = await w.sendTransaction({ to: fee.token, data });
|
|
@@ -1491,6 +1741,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1491
1741
|
updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
|
|
1492
1742
|
return ok({
|
|
1493
1743
|
...summary(agent),
|
|
1744
|
+
fee: feeText,
|
|
1494
1745
|
feeTxHash,
|
|
1495
1746
|
hint: agent.started ? 'The agent is running.' : 'The agent was created but did not start — start it with start_agent.',
|
|
1496
1747
|
});
|