@blindmarket/mcp-server 0.3.6 → 0.5.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 +65 -23
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/rent.d.ts.map +1 -1
- package/dist/rent.js +451 -95
- package/dist/rent.js.map +1 -1
- package/dist/settlement.d.ts +102 -13
- package/dist/settlement.d.ts.map +1 -1
- package/dist/settlement.js +168 -14
- package/dist/settlement.js.map +1 -1
- package/dist/state.d.ts +9 -4
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js.map +1 -1
- package/dist/tools.d.ts +11 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +63 -27
- package/dist/tools.js.map +1 -1
- package/dist/wallet.d.ts.map +1 -1
- package/dist/wallet.js +7 -5
- package/dist/wallet.js.map +1 -1
- package/package.json +3 -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';
|
|
2
|
+
import { Contract, Interface, JsonRpcProvider, formatUnits, parseUnits } from 'ethers';
|
|
3
|
+
import { aesDecrypt, aesEncrypt, derivePublicKeyHex, eciesDecrypt, eciesEncrypt, generateAesKey, sha256Hex } from './crypto.js';
|
|
4
4
|
import { createQuote, consumeQuote, getSpend, putSpend, updateSpend } from './state.js';
|
|
5
|
-
import { createSettlementResolver } from './settlement.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.
|
|
@@ -93,10 +93,16 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
93
93
|
}
|
|
94
94
|
return json.data;
|
|
95
95
|
}
|
|
96
|
-
const settlement = createSettlementResolver({
|
|
96
|
+
const settlement = createSettlementResolver({
|
|
97
|
+
apiBase: cfg.apiBase ?? 'https://api.blindmarket.xyz',
|
|
98
|
+
api,
|
|
99
|
+
localWallet: walletCtx?.wallet.address,
|
|
100
|
+
});
|
|
97
101
|
/** How this process pays. On 0G that is the local wallet, which must exist.
|
|
98
|
-
* On Base nothing signs locally — the relay signs from the
|
|
99
|
-
* wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an
|
|
102
|
+
* On a relay chain (Base) nothing signs locally — the relay signs from the
|
|
103
|
+
* API key's owner wallet — so a missing BLINDMARKET_PRIVATE_KEY is not an
|
|
104
|
+
* error there. On an ERC-20 chain without a relay (Arc) discovery only
|
|
105
|
+
* succeeds with the local wallet, already checked to be the key's owner. */
|
|
100
106
|
async function requireFunding() {
|
|
101
107
|
let s;
|
|
102
108
|
try {
|
|
@@ -105,26 +111,62 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
105
111
|
catch (err) {
|
|
106
112
|
return { error: fail(err.code ?? 'SETTLEMENT_UNKNOWN', err.message) };
|
|
107
113
|
}
|
|
108
|
-
if (s
|
|
114
|
+
if (isErc20Settlement(s))
|
|
109
115
|
return { s, payFrom: s.payFrom };
|
|
110
116
|
if (!walletCtx) {
|
|
111
117
|
return { error: fail('NO_WALLET', 'Spending on 0G needs a local funding wallet — set BLINDMARKET_PRIVATE_KEY (see wallet_status)') };
|
|
112
118
|
}
|
|
119
|
+
// The escrow address is compared before every send, but not the chain:
|
|
120
|
+
// a testnet backend's escrow address, paid on the mainnet RPC, is some
|
|
121
|
+
// other account there. A backend that names its 0G chain id settles it.
|
|
122
|
+
if (s.chainId !== undefined && walletCtx.chainId !== s.chainId) {
|
|
123
|
+
return {
|
|
124
|
+
error: fail('CHAIN_MISMATCH', `The backend settles 0G on chain ${s.chainId}, but BLINDMARKET_PRIVATE_KEY signs on chain ${walletCtx.chainId} (BLINDMARKET_RPC_URL / BLINDMARKET_CHAIN_ID). Native value sent there would not reach this backend's escrow. Point both at chain ${s.chainId}.`),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
113
127
|
return { s, payFrom: walletCtx.wallet.address };
|
|
114
128
|
}
|
|
129
|
+
/** A new escrow is funded where POST /api/v1/tasks builds: the backend's
|
|
130
|
+
* posting chain. A process forced onto another chain (to finish or refund
|
|
131
|
+
* tasks already there) cannot post. Unknown on an older backend. */
|
|
132
|
+
async function notPostingChain(s) {
|
|
133
|
+
let posting = s.postingChain;
|
|
134
|
+
// Forced to 0G, discovery never asked the backend. Ask now, before a quote:
|
|
135
|
+
// a backend that posts elsewhere refuses the native-0G createTask only
|
|
136
|
+
// after the brief is uploaded and its hash claimed.
|
|
137
|
+
if (posting === undefined && s.mode === '0g') {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${cfg.apiBase}/health/settlement`, { signal: AbortSignal.timeout(15_000) });
|
|
140
|
+
const json = await res.json();
|
|
141
|
+
if (json?.success && typeof json.data?.postingChain === 'string')
|
|
142
|
+
posting = json.data.postingChain;
|
|
143
|
+
}
|
|
144
|
+
catch { /* an older backend or a blip: keep the old behaviour, the escrow check still guards the send */ }
|
|
145
|
+
}
|
|
146
|
+
if (posting === undefined || s.mode === posting)
|
|
147
|
+
return null;
|
|
148
|
+
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.`);
|
|
149
|
+
e.code = 'NOT_POSTING_CHAIN';
|
|
150
|
+
return e;
|
|
151
|
+
}
|
|
115
152
|
const ERC20 = new Interface([
|
|
116
153
|
'function allowance(address owner, address spender) view returns (uint256)',
|
|
117
154
|
'function approve(address spender, uint256 amount) returns (bool)',
|
|
118
155
|
'function balanceOf(address owner) view returns (uint256)',
|
|
119
156
|
]);
|
|
120
|
-
function
|
|
121
|
-
return new Contract(s.
|
|
157
|
+
function settlementToken(s) {
|
|
158
|
+
return new Contract(s.token.address, ERC20, s.provider);
|
|
159
|
+
}
|
|
160
|
+
/** The local wallet on a no-relay ERC-20 chain, over that chain's RPC
|
|
161
|
+
* (checked at discovery to serve the chain the backend names). */
|
|
162
|
+
function localSigner(s) {
|
|
163
|
+
return walletCtx.wallet.connect(s.provider);
|
|
122
164
|
}
|
|
123
165
|
/** Spendable balance of whoever pays, in the settlement token's units. */
|
|
124
166
|
async function payFromBalance(s, payFrom) {
|
|
125
167
|
try {
|
|
126
|
-
const raw = s
|
|
127
|
-
? await
|
|
168
|
+
const raw = isErc20Settlement(s)
|
|
169
|
+
? await settlementToken(s).balanceOf(payFrom)
|
|
128
170
|
: await walletCtx.provider.getBalance(payFrom);
|
|
129
171
|
return formatUnits(raw, s.decimals);
|
|
130
172
|
}
|
|
@@ -136,19 +178,22 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
136
178
|
* the task, and the tx carries no chainId. Discovery is only a hint about
|
|
137
179
|
* that chain (see settlement.ts). So before broadcasting, check the tx
|
|
138
180
|
* targets the escrow THIS mode expects — otherwise native value goes to a
|
|
139
|
-
*
|
|
140
|
-
* mismatch also drops the cached mode so the next call
|
|
181
|
+
* relay chain's address on the 0G RPC, or a 0G refund is relayed onto
|
|
182
|
+
* another chain. A mismatch also drops the cached mode so the next call
|
|
183
|
+
* re-asks. */
|
|
141
184
|
async function verifyTarget(s, to, what) {
|
|
142
185
|
const target = String(to).toLowerCase();
|
|
143
|
-
const expected = s.
|
|
186
|
+
const expected = s.escrowAddress;
|
|
144
187
|
if (expected) {
|
|
145
188
|
if (target === expected.toLowerCase())
|
|
146
189
|
return;
|
|
147
190
|
settlement.invalidate();
|
|
148
191
|
const e = new Error(`backend built ${what} for ${to} but this process is in ${s.mode} mode expecting escrow ${expected}. ` +
|
|
149
|
-
(s.
|
|
150
|
-
?
|
|
151
|
-
:
|
|
192
|
+
(s.escrowChains
|
|
193
|
+
? `This task is escrowed on another chain — set BLINDMARKET_SETTLEMENT to the chain that holds it (this backend has escrows on ${s.escrowChains.join(', ')}; 0G needs a local key), or use the web app.`
|
|
194
|
+
: s.payment === 'relay-erc20'
|
|
195
|
+
? 'This task is escrowed on 0G — handle it with BLINDMARKET_SETTLEMENT=0g and a local key, or from the web app.'
|
|
196
|
+
: 'The backend is building Base transactions — re-run and discovery will re-check, or set BLINDMARKET_SETTLEMENT=base.'));
|
|
152
197
|
e.code = 'ESCROW_MISMATCH';
|
|
153
198
|
throw e;
|
|
154
199
|
}
|
|
@@ -191,6 +236,20 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
191
236
|
// default it applies to this body: user-pays.
|
|
192
237
|
return { hash: r.hash, isUserOp: r.isUserOp === true, gas: r.gas ?? 'user-pays' };
|
|
193
238
|
}
|
|
239
|
+
/** Send a tx on an ERC-20 settlement: through the relay, or signed by the
|
|
240
|
+
* local wallet on a chain the relay does not serve. Either way the caller
|
|
241
|
+
* persists the hash before waiting (waitRelayed polls the chain's own RPC
|
|
242
|
+
* for a plain hash, which a local send always is). `nonce` pins a local
|
|
243
|
+
* send right after one this process made: an RPC can answer the next nonce
|
|
244
|
+
* lookup from before the earlier tx landed. */
|
|
245
|
+
async function sendErc20(s, tx, nonce) {
|
|
246
|
+
if (s.payment === 'relay-erc20')
|
|
247
|
+
return relaySend(s, tx);
|
|
248
|
+
// No gasLimit: estimation runs first, so a predictable revert (a stale
|
|
249
|
+
// allowance, a passed deadline) fails here without being mined and paid for.
|
|
250
|
+
const sent = await localSigner(s).sendTransaction({ to: tx.to, data: tx.data, ...(nonce !== undefined ? { nonce } : {}) });
|
|
251
|
+
return { hash: sent.hash, isUserOp: false, nonce: sent.nonce };
|
|
252
|
+
}
|
|
194
253
|
/** Wait for a relayed tx to land. A plain hash can be polled for its receipt;
|
|
195
254
|
* a user-op hash cannot (getTransactionReceipt is always null for it), so
|
|
196
255
|
* that case returns at once and the caller confirms by on-chain STATE —
|
|
@@ -202,7 +261,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
202
261
|
const receipt = await s.provider.getTransactionReceipt(hash).catch(() => null);
|
|
203
262
|
if (receipt) {
|
|
204
263
|
if (receipt.status === 0) {
|
|
205
|
-
const e = new Error(`
|
|
264
|
+
const e = new Error(`tx ${hash} reverted`);
|
|
206
265
|
e.code = 'TX_REVERTED';
|
|
207
266
|
throw e;
|
|
208
267
|
}
|
|
@@ -210,21 +269,26 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
210
269
|
}
|
|
211
270
|
await new Promise((r) => setTimeout(r, 3000));
|
|
212
271
|
}
|
|
213
|
-
const e = new Error(`
|
|
272
|
+
const e = new Error(`tx ${hash} not confirmed after 90s — retry with the same idempotencyKey to resume`);
|
|
214
273
|
e.code = 'TX_PENDING';
|
|
215
274
|
throw e;
|
|
216
275
|
}
|
|
217
|
-
/**
|
|
218
|
-
* allowance first. Confirmed by re-reading allowance()
|
|
219
|
-
* receipt, which is what makes the user-op case decidable.
|
|
276
|
+
/** ERC-20 chains: createTask pulls the token via transferFrom, so the
|
|
277
|
+
* escrow needs an allowance first. Confirmed by re-reading allowance()
|
|
278
|
+
* rather than by receipt, which is what makes the user-op case decidable.
|
|
279
|
+
* Returns the nonce the createTask should use when this call sent a local
|
|
280
|
+
* approve, else undefined. */
|
|
220
281
|
async function ensureAllowance(s, record) {
|
|
221
282
|
const need = BigInt(record.amountWei);
|
|
222
|
-
const token =
|
|
283
|
+
const token = settlementToken(s);
|
|
223
284
|
if ((await token.allowance(s.payFrom, s.escrowAddress)) >= need)
|
|
224
|
-
return;
|
|
285
|
+
return undefined;
|
|
286
|
+
let nextNonce;
|
|
225
287
|
const approve = async () => {
|
|
226
288
|
const data = ERC20.encodeFunctionData('approve', [s.escrowAddress, need]);
|
|
227
|
-
const { hash } = await
|
|
289
|
+
const { hash, nonce } = await sendErc20(s, { to: s.token.address, data });
|
|
290
|
+
if (nonce !== undefined)
|
|
291
|
+
nextNonce = nonce + 1;
|
|
228
292
|
// Persist BEFORE waiting: a crash here must resume into the poll below.
|
|
229
293
|
updateSpend(record.idempotencyKey, { stage: 'approved', approveTxHash: hash });
|
|
230
294
|
record.stage = 'approved';
|
|
@@ -241,47 +305,53 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
241
305
|
if (record.stage === 'created') {
|
|
242
306
|
await approve();
|
|
243
307
|
if (await settled())
|
|
244
|
-
return;
|
|
308
|
+
return nextNonce;
|
|
245
309
|
}
|
|
246
310
|
// Resumed at 'approved' (or the fresh approve never landed): the earlier
|
|
247
311
|
// approve was dropped or reverted. Sending another is safe — ERC-20
|
|
248
312
|
// approve SETS the allowance, it does not add — and it is the only way
|
|
249
313
|
// out of this stage, so do it rather than leave the record stuck.
|
|
250
314
|
if (await settled())
|
|
251
|
-
return;
|
|
315
|
+
return nextNonce;
|
|
252
316
|
await approve();
|
|
253
317
|
if (await settled())
|
|
254
|
-
return;
|
|
255
|
-
const e = new Error(
|
|
318
|
+
return nextNonce;
|
|
319
|
+
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`);
|
|
256
320
|
e.code = 'APPROVE_PENDING';
|
|
257
321
|
throw e;
|
|
258
322
|
}
|
|
259
323
|
/** Fund escrow + index — the shared tail of rent_service and post_task.
|
|
260
324
|
* Resumable at every stage via the spend ledger.
|
|
261
325
|
*
|
|
262
|
-
* Two funding paths, chosen by
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* createTask the backend built,
|
|
266
|
-
* no local signing at all. The
|
|
267
|
-
* check it is the one we approved. */
|
|
326
|
+
* Two funding paths, chosen by `payment`:
|
|
327
|
+
* local-native — native 0G from the local wallet, signed and sent here.
|
|
328
|
+
* relay-erc20 — the ERC-20 (USDC on Base) via transferFrom: approve
|
|
329
|
+
* first (ensureAllowance), then the createTask the backend built,
|
|
330
|
+
* both through the Privy relay with no local signing at all. The
|
|
331
|
+
* backend picks the escrow; we only check it is the one we approved. */
|
|
268
332
|
async function fundAndIndex(record) {
|
|
269
|
-
const s = await settlement();
|
|
270
333
|
let { txHash } = record;
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
if (record.settlement && record.settlement !== s.mode) {
|
|
275
|
-
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`);
|
|
276
|
-
e.code = 'SETTLEMENT_CHANGED';
|
|
277
|
-
throw e;
|
|
278
|
-
}
|
|
334
|
+
// Nothing left to sign once the escrow is funded: /a2a/tasks/index finds
|
|
335
|
+
// the receipt on whichever chain holds it. So a funded spend finishes
|
|
336
|
+
// even when settlement cannot be discovered right now, or has moved.
|
|
279
337
|
if (record.stage === 'created' || record.stage === 'approved') {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
338
|
+
const s = await settlement();
|
|
339
|
+
// A record remembers the chain it started on. If the backend flips mode
|
|
340
|
+
// between attempts, re-funding through the other path would double-fund
|
|
341
|
+
// or send native value into a USDC transferFrom — refuse instead.
|
|
342
|
+
if (record.settlement && record.settlement !== s.mode) {
|
|
343
|
+
const e = new Error(`spend ${record.idempotencyKey} started on ${record.settlement} but this process now settles on ${s.mode}. ` +
|
|
344
|
+
`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.`);
|
|
345
|
+
e.code = 'SETTLEMENT_CHANGED';
|
|
346
|
+
throw e;
|
|
347
|
+
}
|
|
348
|
+
const refused = await notPostingChain(s);
|
|
349
|
+
if (refused)
|
|
350
|
+
throw refused;
|
|
351
|
+
const nonce = isErc20Settlement(s) ? await ensureAllowance(s, record) : undefined;
|
|
352
|
+
const { unsignedTx, chain: builtChain, chainId: builtChainId } = await api('POST', '/api/v1/tasks', {
|
|
283
353
|
taskHash: record.taskHash,
|
|
284
|
-
token: s
|
|
354
|
+
token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
|
|
285
355
|
amount: record.amountWei,
|
|
286
356
|
locationZone: 'global',
|
|
287
357
|
duration: String(record.durationSecs ?? 3600),
|
|
@@ -292,11 +362,20 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
292
362
|
rootHash: record.rootHash,
|
|
293
363
|
wrappedKeys: record.privacy === 'public' ? undefined : record.wrappedKeys,
|
|
294
364
|
});
|
|
295
|
-
// Either branch: the tx must target the escrow this mode expects. On
|
|
296
|
-
//
|
|
365
|
+
// Either branch: the tx must target the escrow this mode expects. On a
|
|
366
|
+
// relay chain that is also the escrow the allowance above was granted to.
|
|
367
|
+
// A chain-aware backend also names the chain it built for (older ones
|
|
368
|
+
// return only the tx, and are checked by address alone, as before).
|
|
369
|
+
if (s.postingChain !== undefined && ((builtChain !== undefined && builtChain !== s.mode) ||
|
|
370
|
+
(builtChainId !== undefined && s.chainId !== undefined && Number(builtChainId) !== s.chainId))) {
|
|
371
|
+
settlement.invalidate();
|
|
372
|
+
const e = new Error(`backend built createTask for ${builtChain ?? '?'} (chain ${builtChainId ?? '?'}) but this process settles on ${s.mode} (chain ${s.chainId ?? '?'}) — re-run and discovery will re-check`);
|
|
373
|
+
e.code = 'ESCROW_MISMATCH';
|
|
374
|
+
throw e;
|
|
375
|
+
}
|
|
297
376
|
await verifyTarget(s, unsignedTx.to, 'createTask');
|
|
298
|
-
if (s
|
|
299
|
-
const { hash, isUserOp, gas } = await
|
|
377
|
+
if (isErc20Settlement(s)) {
|
|
378
|
+
const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data }, nonce);
|
|
300
379
|
// Persist BEFORE waiting, same reasoning as the 0G branch below.
|
|
301
380
|
updateSpend(record.idempotencyKey, { stage: 'funded', txHash: hash, isUserOp, gas });
|
|
302
381
|
record.gas = gas;
|
|
@@ -310,6 +389,8 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
310
389
|
data: unsignedTx.data,
|
|
311
390
|
value: BigInt(record.amountWei),
|
|
312
391
|
gasLimit: GAS_LIMIT,
|
|
392
|
+
// ethers refuses to send when its provider is on another chain.
|
|
393
|
+
...(s.chainId !== undefined ? { chainId: s.chainId } : {}),
|
|
313
394
|
});
|
|
314
395
|
// Persist the tx hash BEFORE waiting: if we crash mid-confirmation the
|
|
315
396
|
// resume path re-runs /tasks/index with this hash instead of re-funding.
|
|
@@ -361,7 +442,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
361
442
|
// ── rent_service ──────────────────────────────────────────────────────────
|
|
362
443
|
server.registerTool('rent_service', {
|
|
363
444
|
title: 'Rent an Agent Service',
|
|
364
|
-
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
|
|
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).',
|
|
365
446
|
inputSchema: {
|
|
366
447
|
serviceId: z.number().int().positive().describe('Service id from browse_services / get_service'),
|
|
367
448
|
prompt: z.string().min(1).max(100_000).describe('What you want the agent to do'),
|
|
@@ -373,11 +454,8 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
373
454
|
},
|
|
374
455
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
375
456
|
}, async ({ serviceId, prompt, idempotencyKey, privacy, confirm, quoteId, waitSeconds }) => {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
return f.error;
|
|
379
|
-
const { s, payFrom } = f;
|
|
380
|
-
// Resume path — this key already spent (or partially spent).
|
|
457
|
+
// Resume path — this key already spent (or partially spent). Checked
|
|
458
|
+
// before settlement: a funded spend finishes without it.
|
|
381
459
|
const existing = getSpend(idempotencyKey);
|
|
382
460
|
if (existing) {
|
|
383
461
|
if (existing.stage === 'indexed') {
|
|
@@ -393,6 +471,13 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
393
471
|
return fail(err.code ?? 'RESUME_FAILED', err.message);
|
|
394
472
|
}
|
|
395
473
|
}
|
|
474
|
+
const f = await requireFunding();
|
|
475
|
+
if ('error' in f)
|
|
476
|
+
return f.error;
|
|
477
|
+
const { s, payFrom } = f;
|
|
478
|
+
const offPosting = await notPostingChain(s);
|
|
479
|
+
if (offPosting)
|
|
480
|
+
return fail(offPosting.code, offPosting.message);
|
|
396
481
|
const service = await api('GET', `/api/v1/marketplace/services/${serviceId}`);
|
|
397
482
|
const isPublic = privacy === 'public';
|
|
398
483
|
if (!isPublic && !service.agent_public_key) {
|
|
@@ -405,10 +490,10 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
405
490
|
// A service priced in 18-decimal units on a 6-decimal chain would quote
|
|
406
491
|
// as a trillion USDC and relay an approve for it before createTask
|
|
407
492
|
// failed. The backend converts such prices since migration 31, but an
|
|
408
|
-
// older backend may still serve one. 1,000,000
|
|
493
|
+
// older backend may still serve one. 1,000,000 tokens per call is far
|
|
409
494
|
// above any real listing — refuse.
|
|
410
|
-
if (s.
|
|
411
|
-
return fail('PRICE_UNITS_SUSPECT', `service ${serviceId} lists price_raw=${priceRaw} which is ${formatUnits(priceRaw,
|
|
495
|
+
if (s.decimals < 18 && priceRaw > 1000000n * 10n ** BigInt(s.decimals)) {
|
|
496
|
+
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.`);
|
|
412
497
|
}
|
|
413
498
|
const price = formatUnits(priceRaw, s.decimals);
|
|
414
499
|
if (!confirm) {
|
|
@@ -467,7 +552,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
467
552
|
requiredCapabilities: [],
|
|
468
553
|
amountWei: String(service.price_raw),
|
|
469
554
|
settlement: s.mode,
|
|
470
|
-
token: s
|
|
555
|
+
token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
|
|
471
556
|
durationSecs: 3600,
|
|
472
557
|
createdAt: new Date().toISOString(),
|
|
473
558
|
updatedAt: new Date().toISOString(),
|
|
@@ -480,7 +565,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
480
565
|
catch (err) {
|
|
481
566
|
const code = err.code;
|
|
482
567
|
if (code === 'NOT_TASK_AGENT') {
|
|
483
|
-
return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow.
|
|
568
|
+
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.');
|
|
484
569
|
}
|
|
485
570
|
return fail(code ?? 'RENT_FAILED', err.message);
|
|
486
571
|
}
|
|
@@ -488,7 +573,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
488
573
|
// ── post_task ─────────────────────────────────────────────────────────────
|
|
489
574
|
server.registerTool('post_task', {
|
|
490
575
|
title: 'Post a Task to the Open Market',
|
|
491
|
-
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
|
|
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.',
|
|
492
577
|
inputSchema: {
|
|
493
578
|
instructions: z.string().min(1).max(100_000).describe('The task brief'),
|
|
494
579
|
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'),
|
|
@@ -502,10 +587,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
502
587
|
},
|
|
503
588
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
504
589
|
}, async ({ instructions, amount, amount0G, idempotencyKey, capabilities, durationSeconds, privacy, confirm, quoteId }) => {
|
|
505
|
-
|
|
506
|
-
if ('error' in f)
|
|
507
|
-
return f.error;
|
|
508
|
-
const { s, payFrom } = f;
|
|
590
|
+
// Checked before settlement: a funded spend finishes without it.
|
|
509
591
|
const existing = getSpend(idempotencyKey);
|
|
510
592
|
if (existing) {
|
|
511
593
|
if (existing.stage === 'indexed') {
|
|
@@ -518,6 +600,13 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
518
600
|
return fail(err.code ?? 'RESUME_FAILED', err.message);
|
|
519
601
|
}
|
|
520
602
|
}
|
|
603
|
+
const f = await requireFunding();
|
|
604
|
+
if ('error' in f)
|
|
605
|
+
return f.error;
|
|
606
|
+
const { s, payFrom } = f;
|
|
607
|
+
const offPosting = await notPostingChain(s);
|
|
608
|
+
if (offPosting)
|
|
609
|
+
return fail(offPosting.code, offPosting.message);
|
|
521
610
|
const isPublic = privacy === 'public';
|
|
522
611
|
const amountStr = amount ?? amount0G;
|
|
523
612
|
if (!amountStr) {
|
|
@@ -595,7 +684,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
595
684
|
requiredCapabilities: capabilities ?? [],
|
|
596
685
|
amountWei: amountWei.toString(),
|
|
597
686
|
settlement: s.mode,
|
|
598
|
-
token: s
|
|
687
|
+
token: isErc20Settlement(s) ? s.token.address : ZERO_TOKEN,
|
|
599
688
|
durationSecs: durationSeconds ?? 86400,
|
|
600
689
|
createdAt: new Date().toISOString(),
|
|
601
690
|
updatedAt: new Date().toISOString(),
|
|
@@ -607,7 +696,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
607
696
|
catch (err) {
|
|
608
697
|
const code = err.code;
|
|
609
698
|
if (code === 'NOT_TASK_AGENT') {
|
|
610
|
-
return fail(code, 'The API key\'s owner wallet does not match the wallet that funded escrow.
|
|
699
|
+
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.');
|
|
611
700
|
}
|
|
612
701
|
return fail(code ?? 'POST_FAILED', err.message);
|
|
613
702
|
}
|
|
@@ -643,7 +732,8 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
643
732
|
//
|
|
644
733
|
// Chain note: the backend resolves which chain holds the task
|
|
645
734
|
// (resolveTaskChainById) and builds the unsigned tx for it; this side only
|
|
646
|
-
// decides who signs — the local wallet on 0G, the Privy relay on
|
|
735
|
+
// decides who signs — the local wallet on 0G, the Privy relay on a relay
|
|
736
|
+
// chain (Base) — and
|
|
647
737
|
// verifies the tx targets the escrow that mode expects before sending
|
|
648
738
|
// (verifyTarget). Task state is read from the chain the mode names, not
|
|
649
739
|
// from GET /tasks/:id, which is bound to the 0G escrow.
|
|
@@ -653,32 +743,55 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
653
743
|
}
|
|
654
744
|
/** Resolve a task id-or-hash to its live on-chain state.
|
|
655
745
|
*
|
|
656
|
-
* GET /api/v1/tasks/:id accepts either form — but
|
|
657
|
-
* only (escrowService.getTask
|
|
658
|
-
* struct is for whatever 0G task happens to share the
|
|
659
|
-
* use it just to turn a hash into an id, then read
|
|
660
|
-
*
|
|
746
|
+
* GET /api/v1/tasks/:id accepts either form — but older backends read the
|
|
747
|
+
* 0G escrow only (escrowService.getTask was bound to the 0G contract), so
|
|
748
|
+
* on a relay chain its struct is for whatever 0G task happens to share the
|
|
749
|
+
* id. On a relay chain we use it just to turn a hash into an id, then read
|
|
750
|
+
* the struct from that chain's escrow ourselves over the read-only
|
|
751
|
+
* provider. */
|
|
661
752
|
async function loadTask(s, task) {
|
|
662
|
-
|
|
663
|
-
|
|
753
|
+
/** The backend said the task is on another chain: its id means a different task here. */
|
|
754
|
+
const onOtherChain = (taskId, chain) => {
|
|
755
|
+
const label = s.chain === 'base' ? 'Base' : s.chain;
|
|
756
|
+
const e = new Error(`task ${taskId} is a ${chain} task, and this process settles on ${label} — handle it with BLINDMARKET_SETTLEMENT=${chain}`);
|
|
757
|
+
// The code names the chain; on Base it is the TASK_NOT_ON_BASE it always was.
|
|
758
|
+
e.code = `TASK_NOT_ON_${s.chain.toUpperCase().replace(/-/g, '_')}`;
|
|
759
|
+
return e;
|
|
760
|
+
};
|
|
761
|
+
if (!isErc20Settlement(s)) {
|
|
762
|
+
const detail = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
|
|
763
|
+
if (detail.chain && detail.chain !== s.chain)
|
|
764
|
+
throw onOtherChain(detail.taskId, detail.chain);
|
|
765
|
+
return detail;
|
|
664
766
|
}
|
|
665
|
-
//
|
|
767
|
+
// Relay chain: the escrow is the authority, and we can read it directly. Only ask
|
|
666
768
|
// the backend when we need a hash resolved to an id — that endpoint makes
|
|
667
769
|
// several Redis round trips and is the slowest thing in this path, so a
|
|
668
770
|
// numeric id must not pay for it.
|
|
669
771
|
let taskId;
|
|
772
|
+
let backendChain;
|
|
670
773
|
if (/^\d+$/.test(task)) {
|
|
671
774
|
taskId = task;
|
|
672
775
|
}
|
|
673
776
|
else {
|
|
674
777
|
const viaBackend = await api('GET', `/api/v1/tasks/${encodeURIComponent(task)}`);
|
|
675
778
|
taskId = viaBackend.taskId;
|
|
779
|
+
backendChain = viaBackend.chain;
|
|
780
|
+
// The same id can exist on this chain's escrow too (another task of the
|
|
781
|
+
// same poster): reading it would act on the wrong task.
|
|
782
|
+
if (backendChain && backendChain !== s.chain)
|
|
783
|
+
throw onOtherChain(taskId, backendChain);
|
|
676
784
|
}
|
|
677
785
|
const escrow = new Contract(s.escrowAddress, ESCROW_READ_ABI, s.provider);
|
|
678
786
|
const t = await escrow.getTask(BigInt(taskId));
|
|
679
787
|
if (String(t.agent).toLowerCase() === ZERO_TOKEN) {
|
|
680
|
-
const
|
|
681
|
-
|
|
788
|
+
const label = s.chain === 'base' ? 'Base' : s.chain;
|
|
789
|
+
const hint = s.escrowChains
|
|
790
|
+
? `it is on another chain; set BLINDMARKET_SETTLEMENT to the one that holds it (this backend has escrows on ${s.escrowChains.join(', ')})`
|
|
791
|
+
: 'it is probably a 0G task; handle it with BLINDMARKET_SETTLEMENT=0g';
|
|
792
|
+
const e = new Error(`task ${taskId} does not exist on the ${label} escrow ${s.escrowAddress} — ${hint}`);
|
|
793
|
+
// The code names the chain; on Base it is the TASK_NOT_ON_BASE it always was.
|
|
794
|
+
e.code = `TASK_NOT_ON_${s.chain.toUpperCase().replace(/-/g, '_')}`;
|
|
682
795
|
throw e;
|
|
683
796
|
}
|
|
684
797
|
return {
|
|
@@ -692,8 +805,8 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
692
805
|
decimals: s.decimals,
|
|
693
806
|
};
|
|
694
807
|
}
|
|
695
|
-
/** Amount formatted against the task's OWN decimals — a
|
|
696
|
-
* USDC
|
|
808
|
+
/** Amount formatted against the task's OWN decimals — a relay-chain task
|
|
809
|
+
* settles in its ERC-20 (USDC: 6), not the wallet's native 18. */
|
|
697
810
|
function refundAmount(detail) {
|
|
698
811
|
return formatUnits(BigInt(detail.amount), detail.decimals ?? 18);
|
|
699
812
|
}
|
|
@@ -714,7 +827,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
714
827
|
}
|
|
715
828
|
/** Broadcast the refund and wait for it — the shared tail of both tools.
|
|
716
829
|
* Resumable: a record past 'created' waits on the tx it already saved.
|
|
717
|
-
* Same two paths as fundAndIndex: local wallet on 0G, Privy relay on
|
|
830
|
+
* Same two paths as fundAndIndex: local wallet on 0G, Privy relay on a relay chain.
|
|
718
831
|
* The backend resolves which chain holds the task and builds the tx for
|
|
719
832
|
* it; this only decides who signs. */
|
|
720
833
|
async function sendRefund(record) {
|
|
@@ -722,20 +835,22 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
722
835
|
const taskId = record.taskId;
|
|
723
836
|
let { txHash } = record;
|
|
724
837
|
if (record.settlement && record.settlement !== s.mode) {
|
|
725
|
-
const e = new Error(`refund ${record.idempotencyKey} started on ${record.settlement} but
|
|
838
|
+
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`);
|
|
726
839
|
e.code = 'SETTLEMENT_CHANGED';
|
|
727
840
|
throw e;
|
|
728
841
|
}
|
|
729
842
|
if (record.stage === 'created') {
|
|
730
843
|
const route = record.kind === 'cancel' ? 'cancel' : 'timeout';
|
|
731
|
-
|
|
844
|
+
// Task ids repeat across chains: name this one (the backend knows
|
|
845
|
+
// 'base' and 'arc'; a 0G task is resolved by ownership as before).
|
|
846
|
+
const { unsignedTx } = await api('POST', `/api/v1/tasks/${taskId}/${route}`, isErc20Settlement(s) ? { chain: s.mode } : undefined);
|
|
732
847
|
// The backend resolves the chain that holds the task and builds for it;
|
|
733
848
|
// the tx carries no chainId. If that chain is not the one this mode
|
|
734
|
-
// broadcasts on, stop here — relaying a 0G refund onto
|
|
735
|
-
// address with no escrow and burns the gas.
|
|
849
|
+
// broadcasts on, stop here — relaying a 0G refund onto another chain
|
|
850
|
+
// lands on an address with no escrow and burns the gas.
|
|
736
851
|
await verifyTarget(s, unsignedTx.to, `${route}Task`);
|
|
737
|
-
if (s
|
|
738
|
-
const { hash, isUserOp, gas } = await
|
|
852
|
+
if (isErc20Settlement(s)) {
|
|
853
|
+
const { hash, isUserOp, gas } = await sendErc20(s, { to: unsignedTx.to, data: unsignedTx.data });
|
|
739
854
|
// Persist BEFORE waiting, same reasoning as the 0G branch below.
|
|
740
855
|
updateSpend(record.idempotencyKey, { stage: 'sent', txHash: hash, isUserOp, gas });
|
|
741
856
|
record.gas = gas;
|
|
@@ -748,6 +863,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
748
863
|
to: unsignedTx.to,
|
|
749
864
|
data: unsignedTx.data,
|
|
750
865
|
gasLimit: GAS_LIMIT,
|
|
866
|
+
...(s.chainId !== undefined ? { chainId: s.chainId } : {}),
|
|
751
867
|
});
|
|
752
868
|
// Persist the hash BEFORE waiting, same reasoning as fundAndIndex: a
|
|
753
869
|
// crash mid-confirmation must resume onto THIS tx, not broadcast another.
|
|
@@ -757,7 +873,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
757
873
|
}
|
|
758
874
|
}
|
|
759
875
|
else if (txHash) {
|
|
760
|
-
if (s
|
|
876
|
+
if (isErc20Settlement(s))
|
|
761
877
|
await waitRelayed(s, txHash, record.isUserOp ?? false);
|
|
762
878
|
else
|
|
763
879
|
await walletCtx.provider.waitForTransaction(txHash);
|
|
@@ -767,7 +883,28 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
767
883
|
}
|
|
768
884
|
await waitCancelled(s, taskId);
|
|
769
885
|
updateSpend(record.idempotencyKey, { stage: 'confirmed' });
|
|
770
|
-
return { taskId, txHash: txHash, gas: record.gas };
|
|
886
|
+
return { taskId, txHash: txHash, gas: record.gas, listingClosed: await confirmRefund(s, taskId, txHash, record.isUserOp ?? false) };
|
|
887
|
+
}
|
|
888
|
+
/** Tell the backend the refund landed (POST /tasks/:id/confirm-tx): it
|
|
889
|
+
* checks the receipt and takes the task off the market, which otherwise
|
|
890
|
+
* keeps listing it as open until its deadline. Best effort, since the
|
|
891
|
+
* money has already moved. A relayed user-op hash has no receipt to check. */
|
|
892
|
+
async function confirmRefund(s, taskId, txHash, isUserOp) {
|
|
893
|
+
if (isUserOp)
|
|
894
|
+
return false;
|
|
895
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
896
|
+
try {
|
|
897
|
+
await api('POST', `/api/v1/tasks/${taskId}/confirm-tx`, { txHash, ...(isErc20Settlement(s) ? { chain: s.mode } : {}) });
|
|
898
|
+
return true;
|
|
899
|
+
}
|
|
900
|
+
catch (err) {
|
|
901
|
+
// The backend's RPC can lag the receipt this side just saw.
|
|
902
|
+
if (err.code !== 'NOT_CONFIRMED' || attempt === 3)
|
|
903
|
+
return false;
|
|
904
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return false;
|
|
771
908
|
}
|
|
772
909
|
/** Shared resume arm: an idempotencyKey that has already moved. The kind
|
|
773
910
|
* guard matters — the ledger is one namespace shared with rent/post, and
|
|
@@ -1029,7 +1166,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1029
1166
|
return f.error;
|
|
1030
1167
|
const { s, payFrom } = f;
|
|
1031
1168
|
// requireFunding returns an error on 0G without a wallet, so walletCtx is
|
|
1032
|
-
// non-null whenever s.
|
|
1169
|
+
// non-null whenever s.payment is 'local-native' from here on.
|
|
1033
1170
|
let detail;
|
|
1034
1171
|
try {
|
|
1035
1172
|
detail = await loadTask(s, task);
|
|
@@ -1085,12 +1222,22 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1085
1222
|
}
|
|
1086
1223
|
const tx = sub.unsignedSubmitEvidence;
|
|
1087
1224
|
await verifyTarget(s, tx.to, 'submitEvidence');
|
|
1088
|
-
if (s.
|
|
1225
|
+
if (s.payment === 'relay-erc20') {
|
|
1089
1226
|
const sent = await relaySend(s, { to: tx.to, data: tx.data });
|
|
1090
1227
|
submitTxHash = sent.hash;
|
|
1091
1228
|
gas = sent.gas;
|
|
1092
1229
|
await waitRelayed(s, sent.hash, sent.isUserOp);
|
|
1093
1230
|
}
|
|
1231
|
+
else if (s.payment === 'local-erc20') {
|
|
1232
|
+
// Signed locally over this chain's RPC, like the 0G branch below
|
|
1233
|
+
// but with gas estimated: a revert fails here, unpaid.
|
|
1234
|
+
if (tx.from && tx.from.toLowerCase() !== s.payFrom.toLowerCase()) {
|
|
1235
|
+
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}.`);
|
|
1236
|
+
}
|
|
1237
|
+
const sent = await sendErc20(s, { to: tx.to, data: tx.data });
|
|
1238
|
+
submitTxHash = sent.hash;
|
|
1239
|
+
await waitRelayed(s, sent.hash, false);
|
|
1240
|
+
}
|
|
1094
1241
|
else {
|
|
1095
1242
|
// submitEvidence is onlyWorker: the backend built the tx for the
|
|
1096
1243
|
// API key's wallet (tx.from). If BLINDMARKET_PRIVATE_KEY is a
|
|
@@ -1130,7 +1277,7 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1130
1277
|
paidTo: done ? payFrom : undefined,
|
|
1131
1278
|
submissionAttempts: after.submissionAttempts,
|
|
1132
1279
|
hint: done
|
|
1133
|
-
? `Escrow released: ${formatUnits(BigInt(after.amount), after.decimals ??
|
|
1280
|
+
? `Escrow released: ${formatUnits(BigInt(after.amount), after.decimals ?? s.decimals)} ${s.symbol} minus the marketplace fee is now in ${payFrom}.`
|
|
1134
1281
|
: fin.awaitingPosterApproval
|
|
1135
1282
|
? 'Manual-verification task: the poster must approve via verify_task before the escrow releases.'
|
|
1136
1283
|
: `Verification did not pass (${(fin.verificationResult?.reasons ?? []).join('; ') || 'no reasons given'}). ` +
|
|
@@ -1143,6 +1290,215 @@ export function registerRentTools(server, cfg, walletCtx) {
|
|
|
1143
1290
|
return fail(err.code ?? 'COMPLETE_FAILED', err.message);
|
|
1144
1291
|
}
|
|
1145
1292
|
});
|
|
1293
|
+
// ── deploy_agent ──────────────────────────────────────────────────────────
|
|
1294
|
+
/** The env var each provider's API key is read from, so the key never passes through the conversation. */
|
|
1295
|
+
const PROVIDER_KEY_ENV = {
|
|
1296
|
+
openai: 'OPENAI_API_KEY',
|
|
1297
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
1298
|
+
groq: 'GROQ_API_KEY',
|
|
1299
|
+
gemini: 'GEMINI_API_KEY',
|
|
1300
|
+
};
|
|
1301
|
+
const coded = (code, message) => Object.assign(new Error(message), { code });
|
|
1302
|
+
/** The local wallet on `chain`, over BLINDMARKET_<CHAIN>_RPC_URL (else a
|
|
1303
|
+
* public RPC for that chain id), checked against the chain id the backend
|
|
1304
|
+
* names, and against `expectChainId` (the fee terms') when given. */
|
|
1305
|
+
async function walletOn(chain, expectChainId) {
|
|
1306
|
+
const res = await fetch(`${cfg.apiBase}/health/bridge`, { signal: AbortSignal.timeout(30_000) });
|
|
1307
|
+
const bridge = await res.json().catch(() => ({}));
|
|
1308
|
+
const entry = (bridge?.data ?? bridge)?.chains?.find((c) => c.chain === chain);
|
|
1309
|
+
const chainId = Number(entry?.chainId);
|
|
1310
|
+
if (!Number.isInteger(chainId) || chainId <= 0)
|
|
1311
|
+
throw coded('SETTLEMENT_UNKNOWN', `The backend lists no chain id for ${chain}.`);
|
|
1312
|
+
if (expectChainId !== undefined && expectChainId !== chainId) {
|
|
1313
|
+
throw coded('SETTLEMENT_UNKNOWN', `The deploy fee is on chain ${expectChainId} but the backend lists ${chain} as chain ${chainId}. Nothing was paid.`);
|
|
1314
|
+
}
|
|
1315
|
+
const envName = rpcEnvName(chain);
|
|
1316
|
+
const rpcUrl = rpcFor(chain, chainId, process.env);
|
|
1317
|
+
if (!rpcUrl)
|
|
1318
|
+
throw coded('RPC_UNKNOWN', `No RPC known for ${chain} (chainId ${chainId}) — set ${envName}.`);
|
|
1319
|
+
const provider = new JsonRpcProvider(rpcUrl, chainId, { staticNetwork: true });
|
|
1320
|
+
const served = Number(BigInt(await provider.send('eth_chainId', [])));
|
|
1321
|
+
if (served !== chainId)
|
|
1322
|
+
throw coded('WRONG_RPC', `${envName} serves chain ${served}, not ${chain} (${chainId}). Nothing was paid.`);
|
|
1323
|
+
return walletCtx.wallet.connect(provider);
|
|
1324
|
+
}
|
|
1325
|
+
server.registerTool('deploy_agent', {
|
|
1326
|
+
title: 'Deploy a Hosted Agent',
|
|
1327
|
+
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.",
|
|
1328
|
+
inputSchema: {
|
|
1329
|
+
name: z.string().min(1).max(80).describe('Agent name'),
|
|
1330
|
+
instructions: z.string().min(1).max(100_000).describe("The agent's instructions: what it does and how"),
|
|
1331
|
+
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"),
|
|
1332
|
+
model: z.string().min(1).describe('Model id, e.g. gpt-4o-mini or claude-sonnet-4-5'),
|
|
1333
|
+
skillSlugs: z.array(z.string()).max(10).optional().describe('Public skills to install at deploy, by slug'),
|
|
1334
|
+
idempotencyKey: z.string().min(8).max(128).describe('Unique key for this deploy — reuse it on retries'),
|
|
1335
|
+
confirm: z.boolean().optional().describe('Set true (with quoteId) to pay the fee and deploy'),
|
|
1336
|
+
quoteId: z.string().optional().describe('From the quote step'),
|
|
1337
|
+
},
|
|
1338
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1339
|
+
}, async ({ name, instructions, provider, model, skillSlugs, idempotencyKey, confirm, quoteId }) => {
|
|
1340
|
+
if (!walletCtx) {
|
|
1341
|
+
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.');
|
|
1342
|
+
}
|
|
1343
|
+
let apiKey = '';
|
|
1344
|
+
if (provider !== '0g-compute') {
|
|
1345
|
+
const envName = PROVIDER_KEY_ENV[provider];
|
|
1346
|
+
apiKey = process.env[envName] ?? '';
|
|
1347
|
+
if (!apiKey)
|
|
1348
|
+
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.`);
|
|
1349
|
+
}
|
|
1350
|
+
const body = {
|
|
1351
|
+
name, instructions, provider, model, apiKey,
|
|
1352
|
+
capabilities: [],
|
|
1353
|
+
skillSlugs: skillSlugs ?? [],
|
|
1354
|
+
// The agent's private key is encrypted to the local wallet's key.
|
|
1355
|
+
ownerPublicKey: derivePublicKeyHex(walletCtx.wallet.privateKey),
|
|
1356
|
+
};
|
|
1357
|
+
const summary = (a) => ({ agentId: a.id, name: a.name, walletAddress: a.walletAddress, started: a.started === true });
|
|
1358
|
+
/** POST /agents/deploy, asking again while the backend has not seen the fee confirm. */
|
|
1359
|
+
const deploy = async (feeTxHash) => {
|
|
1360
|
+
for (let attempt = 1;; attempt++) {
|
|
1361
|
+
try {
|
|
1362
|
+
return await api('POST', '/api/v1/agents/deploy', feeTxHash ? { ...body, feeTxHash } : body);
|
|
1363
|
+
}
|
|
1364
|
+
catch (err) {
|
|
1365
|
+
if (err.code !== 'DEPLOY_FEE_NOT_FOUND' || attempt >= 3)
|
|
1366
|
+
throw err;
|
|
1367
|
+
await new Promise((r) => setTimeout(r, Number(process.env.BLINDMARKET_DEPLOY_POLL_MS ?? 5000)));
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
/** The deploy's own checks, run before anything is quoted or paid. A
|
|
1372
|
+
* backend without the route (404) is not checked here; the deploy
|
|
1373
|
+
* still checks before it takes the fee. */
|
|
1374
|
+
const validate = async () => {
|
|
1375
|
+
try {
|
|
1376
|
+
await api('POST', '/api/v1/agents/deploy/validate', body);
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
catch (err) {
|
|
1380
|
+
const code = err.code;
|
|
1381
|
+
if (code === undefined && /failed: 404$/.test(err.message))
|
|
1382
|
+
return null;
|
|
1383
|
+
return fail(code ?? 'DEPLOY_INVALID', `${err.message}. Nothing was paid.`);
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
const paidNote = () => {
|
|
1387
|
+
const rec = getSpend(idempotencyKey);
|
|
1388
|
+
return rec?.stage === 'sent' && rec.txHash
|
|
1389
|
+
? ` The fee is paid (${rec.txHash}): retry with the SAME idempotencyKey to deploy without paying again.`
|
|
1390
|
+
: '';
|
|
1391
|
+
};
|
|
1392
|
+
const existing = getSpend(idempotencyKey);
|
|
1393
|
+
if (existing && existing.kind !== 'deploy') {
|
|
1394
|
+
return fail('IDEMPOTENCY_KEY_IN_USE', `idempotencyKey ${idempotencyKey} belongs to a ${existing.kind} spend — use a new key for this deploy.`);
|
|
1395
|
+
}
|
|
1396
|
+
if (existing?.stage === 'confirmed') {
|
|
1397
|
+
return ok({ resumed: true, agentId: existing.agentId, feeTxHash: existing.txHash, hint: 'Already deployed with this idempotencyKey.' });
|
|
1398
|
+
}
|
|
1399
|
+
if (existing?.stage === 'sent' && existing.txHash) {
|
|
1400
|
+
// Paid before: finish the deploy with that payment.
|
|
1401
|
+
try {
|
|
1402
|
+
const agent = await deploy(existing.txHash);
|
|
1403
|
+
updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
|
|
1404
|
+
return ok({ resumed: true, ...summary(agent), feeTxHash: existing.txHash });
|
|
1405
|
+
}
|
|
1406
|
+
catch (err) {
|
|
1407
|
+
return fail(err.code ?? 'DEPLOY_FAILED', `${err.message}.${paidNote()}`);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
let terms;
|
|
1411
|
+
try {
|
|
1412
|
+
terms = await api('GET', '/api/v1/agents/deploy-fee');
|
|
1413
|
+
}
|
|
1414
|
+
catch (err) {
|
|
1415
|
+
return fail(err.code ?? 'DEPLOY_FEE_UNKNOWN', err.message);
|
|
1416
|
+
}
|
|
1417
|
+
if (terms.required && terms.method !== 'transfer') {
|
|
1418
|
+
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.');
|
|
1419
|
+
}
|
|
1420
|
+
const fee = terms.required ? terms : null;
|
|
1421
|
+
const feeText = fee ? `${formatUnits(BigInt(fee.amountRaw), fee.decimals).replace(/\.0$/, '')} USDC` : 'none';
|
|
1422
|
+
if (!confirm) {
|
|
1423
|
+
const invalid = await validate();
|
|
1424
|
+
if (invalid)
|
|
1425
|
+
return invalid;
|
|
1426
|
+
let walletBalance;
|
|
1427
|
+
if (fee) {
|
|
1428
|
+
try {
|
|
1429
|
+
const w = await walletOn(fee.chain, fee.chainId);
|
|
1430
|
+
const bal = await new Contract(fee.token, ['function balanceOf(address) view returns (uint256)'], w).balanceOf(w.address);
|
|
1431
|
+
walletBalance = formatUnits(bal, fee.decimals);
|
|
1432
|
+
}
|
|
1433
|
+
catch (err) {
|
|
1434
|
+
if (['RPC_UNKNOWN', 'WRONG_RPC', 'SETTLEMENT_UNKNOWN'].includes(err.code ?? '')) {
|
|
1435
|
+
return fail(err.code, err.message);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
const quote = createQuote('deploy', { name, provider, model, fee: feeText });
|
|
1440
|
+
return ok({
|
|
1441
|
+
quote: {
|
|
1442
|
+
agent: { name, provider, model, skills: skillSlugs ?? [] },
|
|
1443
|
+
fee: feeText,
|
|
1444
|
+
chain: fee?.chain,
|
|
1445
|
+
payTo: fee?.recipient,
|
|
1446
|
+
payFrom: walletCtx.wallet.address,
|
|
1447
|
+
walletBalance,
|
|
1448
|
+
quoteId: quote.quoteId,
|
|
1449
|
+
},
|
|
1450
|
+
next: `Re-call deploy_agent with confirm=true, quoteId="${quote.quoteId}", and the SAME idempotencyKey to ${fee ? 'pay the fee and ' : ''}deploy.`,
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
if (!quoteId || !consumeQuote(quoteId, 'deploy')) {
|
|
1454
|
+
return fail('QUOTE_REQUIRED', 'Get a quote first (call without confirm), then re-call with confirm=true and the returned quoteId (quotes are single-use and expire after 10 minutes)');
|
|
1455
|
+
}
|
|
1456
|
+
try {
|
|
1457
|
+
const now = new Date().toISOString();
|
|
1458
|
+
let feeTxHash;
|
|
1459
|
+
if (fee) {
|
|
1460
|
+
// The backend counts a fee only from the API key's owner: check before paying.
|
|
1461
|
+
const { address: owner } = await api('GET', '/api/v1/api-keys/whoami');
|
|
1462
|
+
if (String(owner).toLowerCase() !== walletCtx.wallet.address.toLowerCase()) {
|
|
1463
|
+
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.`);
|
|
1464
|
+
}
|
|
1465
|
+
const invalid = await validate();
|
|
1466
|
+
if (invalid)
|
|
1467
|
+
return invalid;
|
|
1468
|
+
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 });
|
|
1470
|
+
const data = new Interface(['function transfer(address to, uint256 amount) returns (bool)'])
|
|
1471
|
+
.encodeFunctionData('transfer', [fee.recipient, BigInt(fee.amountRaw)]);
|
|
1472
|
+
const tx = await w.sendTransaction({ to: fee.token, data });
|
|
1473
|
+
// Recorded the moment it is broadcast: a retry resumes from here and never pays twice.
|
|
1474
|
+
updateSpend(idempotencyKey, { stage: 'sent', txHash: tx.hash });
|
|
1475
|
+
try {
|
|
1476
|
+
await tx.wait();
|
|
1477
|
+
}
|
|
1478
|
+
catch (err) {
|
|
1479
|
+
if (err.code === 'CALL_EXCEPTION') {
|
|
1480
|
+
updateSpend(idempotencyKey, { stage: 'created', txHash: undefined });
|
|
1481
|
+
return fail('FEE_REVERTED', `The fee transfer ${tx.hash} reverted, so nothing was paid. Check the wallet's USDC on ${fee.chain} and retry.`);
|
|
1482
|
+
}
|
|
1483
|
+
// Not confirmed yet: the backend waits for the receipt itself.
|
|
1484
|
+
}
|
|
1485
|
+
feeTxHash = tx.hash;
|
|
1486
|
+
}
|
|
1487
|
+
else {
|
|
1488
|
+
putSpend({ idempotencyKey, kind: 'deploy', stage: 'created', createdAt: now, updatedAt: now });
|
|
1489
|
+
}
|
|
1490
|
+
const agent = await deploy(feeTxHash);
|
|
1491
|
+
updateSpend(idempotencyKey, { stage: 'confirmed', agentId: agent.id });
|
|
1492
|
+
return ok({
|
|
1493
|
+
...summary(agent),
|
|
1494
|
+
feeTxHash,
|
|
1495
|
+
hint: agent.started ? 'The agent is running.' : 'The agent was created but did not start — start it with start_agent.',
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
catch (err) {
|
|
1499
|
+
return fail(err.code ?? 'DEPLOY_FAILED', `${err.message}.${paidNote()}`);
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1146
1502
|
return { settlement };
|
|
1147
1503
|
}
|
|
1148
1504
|
//# sourceMappingURL=rent.js.map
|