@projectsolo/solo-mission-mcp 0.21.13 → 0.22.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.
Files changed (34) hide show
  1. package/DEVELOPER_README.md +1 -1
  2. package/README.md +96 -12
  3. package/dist/chunk-NUGGTARW.js +196 -0
  4. package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
  5. package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
  6. package/dist/deployment-UCUWVIPJ.js +43 -0
  7. package/dist/escrowErrors-4XSAYLGT.js +56 -0
  8. package/dist/index.js +244 -162
  9. package/dist/verify-7EUVRUAS.js +445 -0
  10. package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
  11. package/dist/wire-RLDY4AJR.js +24 -0
  12. package/package.json +1 -1
  13. package/src/config.ts +11 -0
  14. package/src/index.ts +1 -1
  15. package/src/scripts/check-tools-against-spec.ts +3 -3
  16. package/src/solana/deployment.test.ts +61 -0
  17. package/src/solana/deployment.ts +89 -0
  18. package/src/solana/escrowErrors.ts +78 -0
  19. package/src/solana/fixtures/config-account.json +6 -0
  20. package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
  21. package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
  22. package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
  23. package/src/solana/verify.test.ts +605 -78
  24. package/src/solana/verify.ts +545 -84
  25. package/src/solana/wallet.test.ts +85 -2
  26. package/src/solana/wallet.ts +74 -31
  27. package/src/solana/wire.test.ts +129 -0
  28. package/src/solana/wire.ts +264 -0
  29. package/src/tools/missions.ts +56 -119
  30. package/src/tools/solana.test.ts +356 -0
  31. package/src/tools/solana.ts +309 -70
  32. package/src/tools/tracks.ts +1 -1
  33. package/dist/verify-KAETIGV5.js +0 -136
  34. /package/src/solana/fixtures/{funding-transaction.json → funding-transaction-v1.json} +0 -0
@@ -1,13 +1,9 @@
1
1
  /**
2
- * Solana funding tools.
2
+ * Solana funding tools — the only on-chain path this package exposes.
3
3
  *
4
- * Originally additive: every existing tool behaved identically, `create_mission` without `chain`
5
- * still created a Base mission, and an agent that never touched Solana needed no wallet configured.
6
- * That is no longer true as of the private-beta migration to Solana — the backend now returns 503
7
- * for any on-chain mission that resolves to `chain: 'base'`, including the pre-Solana default of
8
- * omitting `chain` entirely. An agent that wants a paid/on-chain mission must pass `chain: 'solana'`
9
- * explicitly and configure a wallet; only free off-chain missions (no `budget`) work with no chain
10
- * configuration at all. See create_mission's own description for the current, authoritative state.
4
+ * A paid mission (one with a `budget`) is escrowed on Solana: `create_mission` always sends
5
+ * `chain: 'solana'` for it, and funding or refunding it needs a wallet configured here. Free
6
+ * off-chain missions (no `budget`) need no wallet and no chain configuration at all.
11
7
  *
12
8
  * The funding flow is deliberately ONE tool, not three. Build → verify → sign → submit as separate
13
9
  * calls would let an agent skip the verify step, and the verify step is what makes signing an opaque
@@ -21,13 +17,13 @@ export const solanaTools: Tool[] = [
21
17
  {
22
18
  name: 'get_solana_config',
23
19
  description:
24
- 'Read the Solana escrow deployment: program id, cluster, RPC endpoint, accepted mints and their decimals, and the minimum first payout. Call this before funding so you use a whitelisted mint — a mint that is not whitelisted is rejected on chain, not by the API. Requires no wallet.',
20
+ 'Read the Solana escrow deployment: program id, cluster, RPC endpoint, accepted mints and their decimals, the minimum first payout, and escrow_interface — the program interface funding transactions use. This server verifies only escrow_interface \'v2\' and refuses to fund against anything else. Call this before funding so you use a whitelisted mint — a mint that is not whitelisted is rejected on chain, not by the API. Requires no wallet.',
25
21
  inputSchema: { type: 'object', properties: {} },
26
22
  },
27
23
  {
28
24
  name: 'get_solana_wallet',
29
25
  description:
30
- "Show your Solana wallet address and its balances. Reports SOL (needed for transaction fees and for account rent) and the token balance for a given mint (the mission budget). Use this before funding: an agent needs BOTH, and the rent line has no equivalent on Base. Rent is a refundable deposit, not a fee — most of it returns when the task is closed. If no wallet is configured this explains how to set one up.",
26
+ "Show your Solana wallet address and its balances. Reports SOL (needed for transaction fees and for account rent) and the token balance for a given mint (the mission budget). Use this before funding: an agent needs BOTH, and the rent line is easy to miss. Rent is a refundable deposit, not a fee — most of it returns when the task is closed. If no wallet is configured this explains how to set one up.",
31
27
  inputSchema: {
32
28
  type: 'object',
33
29
  properties: {
@@ -42,11 +38,11 @@ export const solanaTools: Tool[] = [
42
38
  {
43
39
  name: 'fund_solana_mission',
44
40
  description:
45
- "Fund a Solana mission end to end: the backend builds the escrow transaction, this tool DECODES AND VERIFIES it against the parameters you expect, signs it locally with your wallet, and submits it. Your key never leaves this process.\n\nVerification is not optional and cannot be skipped. On Solana the backend builds the transaction rather than publishing a parameter set for you to rebuild, so without a decode you would be signing bytes you cannot read. This tool refuses to sign if anything differs from what you expect — a substituted mint, an altered budget, an extra instruction, a vault that is not a program-derived address — and returns the discrepancies instead.\n\nCall create_mission with chain='solana' first; pass that mission's id here.",
41
+ "Fund a Solana mission end to end: the backend builds the escrow transaction, this tool DECODES AND VERIFIES it against the parameters you expect, signs it locally with your wallet, and submits it. Your key never leaves this process.\n\nVerification is not optional and cannot be skipped. On Solana the backend builds the transaction rather than publishing a parameter set for you to rebuild, so without a decode you would be signing bytes you cannot read. This tool refuses to sign if anything differs from what you expect — a substituted mint, an altered budget or deadline, an extra instruction, an account that is not the program-derived address, a raised fee ceiling, a lottery co-signer that is not the program's Operator — and returns the discrepancies instead.\n\nThe escrow program id is pinned in this package per cluster (SOLO_SOLANA_PROGRAM_ID can pin another) and the API's program_id must match it; the mint's decimals, which scale expected_budget, are read from chain.\n\nEscrow interface v2 (the only one this server verifies; get_solana_config reports it):\n- max_fee_bps: create_task carries the highest platform fee the escrow may take. It must equal the fee rate create_mission quoted you (mission.solana_quoted_fee_bps). If the program's fee has risen above it by the time the transaction lands, create_task fails with FeeAboveSponsorMax instead of charging you more; cancel the mission and create a new one for a fresh quote.\n- Lottery co-signature: a mission with lottery_winner_count > 0 must be co-signed by the platform Operator, so the transaction arrives already signed by it. This tool checks that the co-signer is the Operator in the program's on-chain Config (read directly from the cluster, not from the API), that its ed25519 signature is valid over the exact message, and that you, not the Operator, pay the fee. It then adds ONLY your signature and leaves the Operator's untouched; any change to the bytes would invalidate it. A non-lottery transaction must carry no co-signer.\n\nCall create_mission with a budget first and pass that mission's id here. Fund it within 24 hours: an unfunded mission is cancelled automatically at funding_params.expires_at. A media_review mission needs at least one ready track (add_mission_track) before funding, or this returns 409 before anything is signed.",
46
42
  inputSchema: {
47
43
  type: 'object',
48
44
  properties: {
49
- mission_id: { type: 'string', description: 'Mission created with chain="solana".' },
45
+ mission_id: { type: 'string', description: 'A paid (budget-bearing) mission from create_mission, still in pending_funding.' },
50
46
  expected_budget: {
51
47
  type: 'number',
52
48
  description:
@@ -55,7 +51,13 @@ export const solanaTools: Tool[] = [
55
51
  expected_mint: {
56
52
  type: 'string',
57
53
  description:
58
- 'The mint you expect the budget to be taken in. Verified before signing. Defaults to the deployment payout mint.',
54
+ "The mint you expect the budget to be taken in. Verified before signing. Defaults to the mint create_mission recorded for this mission (its funding_params.mint).",
55
+ },
56
+ expected_max_fee_bps: {
57
+ type: 'integer',
58
+ minimum: 0,
59
+ description:
60
+ "The platform fee rate, in basis points, that create_mission quoted you: its response's mission.solana_quoted_fee_bps. Verified against the transaction's max_fee_bps before signing. Defaults to the quote recorded on the mission.",
59
61
  },
60
62
  dry_run: {
61
63
  type: 'boolean',
@@ -69,7 +71,7 @@ export const solanaTools: Tool[] = [
69
71
  {
70
72
  name: 'refund_solana_mission',
71
73
  description:
72
- "Get a Solana mission's escrowed funds back to the sponsor. Covers all three routes: cancel (while funded, before hiring closes), emergency_refund (once the settlement deadline passes and the platform has not settled), and claim_refund (leftover budget after a partial settle).\n\nSame flow as funding: the backend builds the transaction, this tool VERIFIES it, signs locally, and submits. Your key never leaves this process.\n\nCall with no action to ask what is currently available — the response lists the legal actions for the mission's state rather than guessing. Without this tool a funded Solana mission's money was unreachable except by hand-assembling an Anchor instruction, for which Solana has no `cast send` equivalent.",
74
+ "Get a Solana mission's escrowed funds back to the sponsor. Covers all three routes:\n- cancel: while the task is funded and strictly before qualify_deadline (the end of the hiring window). The escrow program refuses a cancel from qualify_deadline on (TooLateToCancel), even one built earlier, because by then the participants' work is done.\n- emergency_refund: once settlement_deadline has passed and the platform has not settled. It stays available after the cancel cut-off, and a pause does not block it.\n- claim_refund: leftover budget after a settle (mission status refundable).\n\nSame flow as funding: the backend builds the transaction, this tool VERIFIES it, signs locally, and submits. Your key never leaves this process.\n\nCall with no action to ask what is currently available — the response lists the legal actions for the mission's state rather than guessing. If the backend or the chain rejects an action, the response says which one did, names the program error, and lists the actions that remain.",
73
75
  inputSchema: {
74
76
  type: 'object',
75
77
  properties: {
@@ -113,26 +115,64 @@ export function toRawAmount(amount: number, decimals: number): string {
113
115
 
114
116
  export const SOLANA_TOOL_NAMES = new Set(solanaTools.map((t) => t.name));
115
117
 
118
+ interface SolanaConfigResponse {
119
+ cluster?: string;
120
+ program_id: string;
121
+ rpc_url: string;
122
+ mints: Record<string, string>;
123
+ decimals: Record<string, number>;
124
+ escrow_interface?: string;
125
+ }
126
+
127
+ /** The mission fields the Solana tools read, as `GET /agent/missions/:id` returns them. */
128
+ interface SolanaMissionRecord {
129
+ token_address?: string;
130
+ onchain_status?: string;
131
+ qualify_deadline?: number;
132
+ settlement_deadline?: number;
133
+ lottery_winner_count?: number;
134
+ lottery_prize_per_winner_raw?: string;
135
+ seed_commit?: string;
136
+ solana_quoted_fee_bps?: number;
137
+ }
138
+
139
+ /** The body of an API error (`ApiResponseError.data`), with the shapes other clients use kept. */
140
+ function apiErrorBody(e: any): { message?: string; available_actions?: string[]; [k: string]: unknown } {
141
+ return e?.data ?? e?.response?.data ?? e?.body ?? {};
142
+ }
143
+
144
+ const CANCEL_CUTOFF_RULE =
145
+ 'cancel is refused on chain from qualify_deadline on (TooLateToCancel, error 6125); ' +
146
+ 'emergency_refund opens once settlement_deadline has passed without a settlement.';
147
+
116
148
  export async function handleSolanaTool(
117
149
  name: string,
118
150
  args: Record<string, any>,
119
151
  ): Promise<unknown> {
120
152
  const { apiGet, apiPost } = await import('../api/client.js');
153
+ const { resolvePinnedProgram, resolveTrustedRpc, solanaRpcUrl } = await import(
154
+ '../solana/deployment.js'
155
+ );
121
156
 
122
157
  switch (name) {
123
158
  case 'get_solana_config':
124
159
  return apiGet('/agent/solana/config');
125
160
 
126
161
  case 'get_solana_wallet': {
127
- const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import(
128
- '../solana/wallet.js'
129
- );
162
+ const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress, KEY_STORAGE_GUIDANCE } =
163
+ await import('../solana/wallet.js');
130
164
  if (!hasSolanaWallet()) {
131
165
  return {
132
166
  configured: false,
133
167
  how_to_configure: {
134
- option_1: 'SOLO_SOLANA_KEYPAIR — JSON byte array, as `solana-keygen new` writes it',
135
- option_2: 'SOLO_SOLANA_KEYPAIR_PATH — path to that file, e.g. ~/.config/solana/id.json',
168
+ recommended:
169
+ 'SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE — a keyfile ' +
170
+ 'encrypted with `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256`, plus a ' +
171
+ 'separate passphrase file (chmod 600 both)',
172
+ alternative:
173
+ 'SOLO_SOLANA_KEYPAIR_PATH — path to a plaintext `solana-keygen` keyfile, e.g. ' +
174
+ '~/.config/solana/id.json (chmod 600)',
175
+ key_storage: KEY_STORAGE_GUIDANCE,
136
176
  },
137
177
  what_you_need:
138
178
  'SOL for transaction fees and account rent, plus the payout token for the budget. ' +
@@ -143,11 +183,7 @@ export async function handleSolanaTool(
143
183
  }
144
184
 
145
185
  const wallet = await loadSolanaWallet();
146
- const cfg = (await apiGet('/agent/solana/config')) as {
147
- rpc_url: string;
148
- mints: Record<string, string>;
149
- decimals: Record<string, number>;
150
- };
186
+ const cfg = (await apiGet('/agent/solana/config')) as SolanaConfigResponse;
151
187
  const symbol = Object.keys(cfg.mints)[0];
152
188
  const mint = args.mint ?? cfg.mints[symbol];
153
189
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
@@ -155,7 +191,7 @@ export async function handleSolanaTool(
155
191
  // Read balances directly from the cluster rather than through our API. An agent checking
156
192
  // whether it can afford a mission should not have to trust us for the answer.
157
193
  const rpc = async (method: string, params: unknown[]) => {
158
- const res = await fetch(cfg.rpc_url, {
194
+ const res = await fetch(solanaRpcUrl(cfg), {
159
195
  method: 'POST',
160
196
  headers: { 'Content-Type': 'application/json' },
161
197
  body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
@@ -192,17 +228,79 @@ export async function handleSolanaTool(
192
228
  const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import(
193
229
  '../solana/wallet.js'
194
230
  );
195
- const { verifyFundingTransaction } = await import('../solana/verify.js');
231
+ const { verifyFundingTransaction, readEscrowConfig, readMintDecimals, SUPPORTED_ESCROW_INTERFACE } =
232
+ await import('../solana/verify.js');
196
233
 
197
234
  const wallet = await loadSolanaWallet();
198
- const cfg = (await apiGet('/agent/solana/config')) as {
199
- program_id: string;
200
- mints: Record<string, string>;
201
- decimals: Record<string, number>;
235
+ const cfg = (await apiGet('/agent/solana/config')) as SolanaConfigResponse;
236
+
237
+ // Decoding against the wrong layout would compare the wrong bytes. Refuse before anything is
238
+ // built rather than verify a transaction this version cannot read.
239
+ if (cfg.escrow_interface !== SUPPORTED_ESCROW_INTERFACE) {
240
+ return {
241
+ funded: false,
242
+ refused_to_sign: true,
243
+ problems: [
244
+ `the deployment reports escrow_interface ${JSON.stringify(cfg.escrow_interface)}; this ` +
245
+ `version of @projectsolo/solo-mission-mcp verifies only '${SUPPORTED_ESCROW_INTERFACE}' ` +
246
+ 'funding transactions',
247
+ ],
248
+ what_this_means:
249
+ 'Nothing was built, signed or escrowed. This server cannot decode the funding ' +
250
+ 'transaction the deployment would build, so it will not sign one. Use a version of ' +
251
+ 'this package that supports the reported escrow interface.',
252
+ };
253
+ }
254
+
255
+ // The program is pinned here, not taken from the API: every derived address and the
256
+ // co-signer check hang off it, and the API is the party being checked.
257
+ const pin = resolvePinnedProgram(cfg);
258
+ const rpc = resolveTrustedRpc(cfg);
259
+ if ('problem' in pin || 'problem' in rpc) {
260
+ return {
261
+ funded: false,
262
+ refused_to_sign: true,
263
+ problems: [
264
+ ...('problem' in pin ? [pin.problem] : []),
265
+ ...('problem' in rpc ? [rpc.problem] : []),
266
+ ],
267
+ what_this_means: 'Nothing was built, signed or escrowed.',
268
+ };
269
+ }
270
+ const rpcUrl = rpc.rpc_url;
271
+
272
+ // The mission as create_mission recorded it — the quote the agent was shown, including the
273
+ // fee rate (solana_quoted_fee_bps) that max_fee_bps must equal, and the mint.
274
+ const { mission } = (await apiGet(`/agent/missions/${args.mission_id}`)) as {
275
+ mission?: SolanaMissionRecord;
202
276
  };
203
- const symbol = Object.keys(cfg.mints)[0];
204
- const mint = args.expected_mint ?? cfg.mints[symbol];
205
- const decimals = cfg.decimals[symbol] ?? 6;
277
+ if (!mission) {
278
+ return {
279
+ funded: false,
280
+ error: `Could not read mission ${args.mission_id} — call get_mission to check it exists.`,
281
+ };
282
+ }
283
+
284
+ // The mission's own mint unless the agent names one: get_solana_config lists both USDC and
285
+ // TEST_USDC, and the backend can be configured to create missions in either, so the first
286
+ // listed mint is a guess that refuses a mission created in the other.
287
+ const mint: string =
288
+ args.expected_mint ?? mission.token_address ?? cfg.mints[Object.keys(cfg.mints)[0]];
289
+ // Scales expected_budget, so it is read from the mint account, not from the API.
290
+ let decimals: number;
291
+ try {
292
+ decimals = await readMintDecimals(rpcUrl, mint);
293
+ } catch (e) {
294
+ return {
295
+ funded: false,
296
+ refused_to_sign: true,
297
+ problems: [
298
+ `cannot read mint ${mint}'s decimals from chain, which expected_budget is scaled by: ` +
299
+ (e as Error).message,
300
+ ],
301
+ what_this_means: 'Nothing was built, signed or escrowed.',
302
+ };
303
+ }
206
304
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
207
305
 
208
306
  const built = (await apiPost(
@@ -213,14 +311,43 @@ export async function handleSolanaTool(
213
311
  task_id: string;
214
312
  declared: Record<string, any>;
215
313
  accounts: Record<string, string>;
314
+ escrow_interface?: string;
315
+ message_sha256?: string;
216
316
  };
217
317
 
318
+ // Config straight from the cluster, at the PDA derived from the pinned program id: a lottery
319
+ // co-signer must be Config.operator, and the API is not the one to vouch for that.
320
+ let onchainConfig = null;
321
+ let onchainConfigError: string | undefined;
322
+ try {
323
+ onchainConfig = await readEscrowConfig(rpcUrl, pin.program_id);
324
+ } catch (e) {
325
+ onchainConfigError = (e as Error).message;
326
+ }
327
+
328
+ let quotedFeeBps: number | undefined;
329
+ let feeQuoteSource: string;
330
+ if (args.expected_max_fee_bps !== undefined) {
331
+ quotedFeeBps = Number(args.expected_max_fee_bps);
332
+ feeQuoteSource = 'expected_max_fee_bps argument';
333
+ } else if (typeof mission.solana_quoted_fee_bps === 'number') {
334
+ quotedFeeBps = mission.solana_quoted_fee_bps;
335
+ feeQuoteSource = 'mission.solana_quoted_fee_bps (the create_mission quote)';
336
+ } else {
337
+ // A mission created before quotes were recorded: the backend then quotes the live rate.
338
+ quotedFeeBps = onchainConfig?.fee_bps;
339
+ feeQuoteSource = 'on-chain Config.fee_bps (the mission has no recorded quote)';
340
+ }
341
+
218
342
  const expectedBudgetRaw = toRawAmount(args.expected_budget, decimals);
219
343
 
220
344
  const verdict = await verifyFundingTransaction({
221
345
  transaction_base64: built.transaction_base64,
346
+ escrow_interface: built.escrow_interface,
347
+ task_id: String(built.task_id),
222
348
  declared: built.declared as any,
223
349
  accounts: built.accounts as any,
350
+ message_sha256: built.message_sha256,
224
351
  expected: {
225
352
  budget_raw: expectedBudgetRaw,
226
353
  // base_pool is derived by the backend from reward × max_humans. The agent's check on it
@@ -228,14 +355,21 @@ export async function handleSolanaTool(
228
355
  // asserting a locally recomputed figure would require duplicating that arithmetic here
229
356
  // and would fail on a legitimately rounded reward.
230
357
  base_pool_raw: String(built.declared.base_pool),
231
- lottery_winner_count: Number(built.declared.lottery_winner_count),
232
- lottery_prize_per_winner_raw: String(built.declared.lottery_prize_per_winner),
233
- qualify_deadline: Number(built.declared.qualify_deadline),
234
- settlement_deadline: Number(built.declared.settlement_deadline),
358
+ // The rest is what create_mission recorded, not what the funding response says: whether
359
+ // this is a lottery decides whether a co-signer is required at all.
360
+ lottery_winner_count: Number(mission.lottery_winner_count ?? 0),
361
+ lottery_prize_per_winner_raw: String(mission.lottery_prize_per_winner_raw ?? '0'),
362
+ qualify_deadline: Number(mission.qualify_deadline),
363
+ settlement_deadline: Number(mission.settlement_deadline),
364
+ seed_commit: mission.seed_commit,
365
+ max_fee_bps: quotedFeeBps,
235
366
  mint,
236
367
  sponsor: wallet.publicKey,
368
+ sponsor_token_account: tokenAccount,
237
369
  },
238
- expected_program_id: cfg.program_id,
370
+ expected_program_id: pin.program_id,
371
+ onchain_config: onchainConfig,
372
+ onchain_config_error: onchainConfigError,
239
373
  });
240
374
 
241
375
  if (!verdict.ok) {
@@ -245,7 +379,9 @@ export async function handleSolanaTool(
245
379
  funded: false,
246
380
  refused_to_sign: true,
247
381
  problems: verdict.problems,
382
+ warnings: verdict.warnings,
248
383
  summary: verdict.summary,
384
+ fee_quote_source: feeQuoteSource,
249
385
  what_this_means:
250
386
  'The transaction does not match what you asked for, so it was NOT signed and nothing ' +
251
387
  'was escrowed. This is the verifier doing its job. Do not retry blindly — the ' +
@@ -260,38 +396,63 @@ export async function handleSolanaTool(
260
396
  verified: true,
261
397
  task_id: built.task_id,
262
398
  summary: verdict.summary,
399
+ warnings: verdict.warnings,
400
+ fee_quote_source: feeQuoteSource,
263
401
  would_escrow: `${args.expected_budget} (${expectedBudgetRaw} raw) of ${mint}`,
264
402
  };
265
403
  }
266
404
 
405
+ // Adds only our signature; a lottery's Operator co-signature is carried through unchanged.
267
406
  const signed = await signTransaction(built.transaction_base64, wallet);
268
- const confirmed = await apiPost(
269
- `/agent/solana/missions/${args.mission_id}/confirm-funding`,
270
- { signed_transaction: signed, task_id: built.task_id },
271
- );
407
+ let confirmed: unknown;
408
+ try {
409
+ confirmed = await apiPost(`/agent/solana/missions/${args.mission_id}/confirm-funding`, {
410
+ signed_transaction: signed,
411
+ task_id: built.task_id,
412
+ });
413
+ } catch (e: any) {
414
+ const { escrowErrorFromMessage } = await import('../solana/escrowErrors.js');
415
+ const body = apiErrorBody(e);
416
+ const message = body.message ?? e?.message ?? String(e);
417
+ return {
418
+ funded: false,
419
+ verified: true,
420
+ signed: true,
421
+ status: e?.status,
422
+ message,
423
+ program_error: escrowErrorFromMessage(message) ?? undefined,
424
+ ...(body.funding_mismatch ? { funding_mismatch: body.funding_mismatch } : {}),
425
+ ...(body.task_id ? { task_id: body.task_id } : {}),
426
+ };
427
+ }
272
428
 
273
- return { funded: true, verified: true, ...(confirmed as Record<string, unknown>) };
429
+ return {
430
+ funded: true,
431
+ verified: true,
432
+ lottery: verdict.summary.lottery,
433
+ operator_cosigned: verdict.summary.operator_signature === 'valid',
434
+ max_fee_bps: verdict.summary.max_fee_bps,
435
+ ...(confirmed as Record<string, unknown>),
436
+ };
274
437
  }
275
438
 
276
439
  case 'refund_solana_mission': {
277
440
  const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import(
278
441
  '../solana/wallet.js'
279
442
  );
443
+ const { escrowErrorFromMessage } = await import('../solana/escrowErrors.js');
280
444
  const wallet = await loadSolanaWallet();
281
- const cfg = (await apiGet('/agent/solana/config')) as {
282
- program_id: string;
283
- mints: Record<string, string>;
284
- };
445
+ const cfg = (await apiGet('/agent/solana/config')) as SolanaConfigResponse;
285
446
  // The mission's ACTUAL mint, not a guessed default — get_solana_config's mints always
286
447
  // includes both the production payout mint (USDC) and TEST_USDC, and a mission is funded
287
448
  // in exactly one of them. Guessing (the previous code preferred TEST_USDC whenever present)
288
449
  // computes the wrong associated-token-account for any mission funded in the other one, and
289
450
  // the refund/cancel/emergency-refund transaction then fails on-chain with
290
451
  // AccountNotInitialized — confirmed live against a real USDC-funded mission.
291
- const mission = (await apiGet(`/agent/missions/${args.mission_id}`)) as {
292
- mission?: { token_address?: string };
452
+ const { mission } = (await apiGet(`/agent/missions/${args.mission_id}`)) as {
453
+ mission?: SolanaMissionRecord;
293
454
  };
294
- const mint = mission.mission?.token_address;
455
+ const mint = mission?.token_address;
295
456
  if (!mint) {
296
457
  return {
297
458
  refunded: false,
@@ -299,6 +460,13 @@ export async function handleSolanaTool(
299
460
  };
300
461
  }
301
462
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
463
+ const deadlines = {
464
+ qualify_deadline: mission?.qualify_deadline,
465
+ settlement_deadline: mission?.settlement_deadline,
466
+ ...(mission?.qualify_deadline !== undefined
467
+ ? { cancel_cutoff: new Date(mission.qualify_deadline * 1000).toISOString() }
468
+ : {}),
469
+ };
302
470
 
303
471
  // No action: ask what is legal rather than guessing and getting a 409. Uses a deliberately
304
472
  // invalid action so the backend answers with available_actions for this mission's state.
@@ -314,41 +482,91 @@ export async function handleSolanaTool(
314
482
  // ApiResponseError carries the parsed body on `.data`; the other shapes are kept for
315
483
  // any client that wraps differently. Reading only the latter silently yielded an empty
316
484
  // available_actions, i.e. a false "nothing is refundable" on a mission that could cancel.
317
- const body: any = e?.data ?? e?.response?.data ?? e?.body ?? {};
485
+ const body = apiErrorBody(e);
318
486
  return {
319
487
  available_actions: body.available_actions ?? [],
320
488
  why_not_claim_refund: body.message,
489
+ ...deadlines,
321
490
  note:
322
491
  (body.available_actions?.length ?? 0) === 0
323
- ? 'Nothing is refundable right now. cancel needs the hiring window still open; ' +
324
- 'emergency_refund needs the settlement deadline to have passed.'
325
- : 'Re-run with one of available_actions.',
492
+ ? `Nothing is refundable right now. ${CANCEL_CUTOFF_RULE}`
493
+ : `Re-run with one of available_actions. ${CANCEL_CUTOFF_RULE}`,
326
494
  };
327
495
  }
328
496
  }
329
497
 
330
- const built = (await apiPost(
331
- `/agent/solana/missions/${args.mission_id}/refund-transaction`,
332
- {
498
+ // The program refuses a cancel from qualify_deadline on. Checked here too, so a cancel is
499
+ // never signed only to be rejected on chain.
500
+ const nowSec = Math.floor(Date.now() / 1000);
501
+ if (
502
+ args.action === 'cancel' &&
503
+ typeof mission?.qualify_deadline === 'number' &&
504
+ nowSec >= mission.qualify_deadline
505
+ ) {
506
+ return {
507
+ refunded: false,
508
+ rejected_by: 'client',
509
+ action: 'cancel',
510
+ message:
511
+ `qualify_deadline (${deadlines.cancel_cutoff}) has passed by this machine's clock, so the ` +
512
+ 'escrow program would refuse this cancel (TooLateToCancel). Nothing was built or signed.',
513
+ ...deadlines,
514
+ what_to_do: CANCEL_CUTOFF_RULE,
515
+ };
516
+ }
517
+
518
+ // The refund must be for the pinned program, not whichever one the API names.
519
+ const pin = resolvePinnedProgram(cfg);
520
+ if ('problem' in pin) {
521
+ return {
522
+ refunded: false,
523
+ refused_to_sign: true,
524
+ problems: [pin.problem],
525
+ what_this_means: 'Nothing was built, signed or moved.',
526
+ };
527
+ }
528
+
529
+ let built: { transaction_base64: string; task_id: string; cancel_deadline?: string };
530
+ try {
531
+ built = (await apiPost(
532
+ `/agent/solana/missions/${args.mission_id}/refund-transaction`,
533
+ {
534
+ action: args.action,
535
+ sponsor_wallet: wallet.publicKey,
536
+ sponsor_token_account: tokenAccount,
537
+ },
538
+ )) as { transaction_base64: string; task_id: string; cancel_deadline?: string };
539
+ } catch (e: any) {
540
+ const body = apiErrorBody(e);
541
+ const message = body.message ?? e?.message ?? String(e);
542
+ return {
543
+ refunded: false,
544
+ rejected_by: 'backend',
333
545
  action: args.action,
334
- sponsor_wallet: wallet.publicKey,
335
- sponsor_token_account: tokenAccount,
336
- },
337
- )) as { transaction_base64: string; task_id: string };
546
+ status: e?.status,
547
+ message,
548
+ available_actions: body.available_actions ?? [],
549
+ program_error: escrowErrorFromMessage(message) ?? undefined,
550
+ ...deadlines,
551
+ what_to_do: CANCEL_CUTOFF_RULE,
552
+ };
553
+ }
338
554
 
339
555
  // Verify before signing, same rule as funding: a headless agent has no wallet UI, so an
340
556
  // unverified blob is bytes it cannot read. A refund moves the WHOLE escrow, so the checks
341
557
  // that matter are that it is our program, we are the only signer, and the tokens land in our
342
- // own token account.
343
- const { Transaction, PublicKey } = await import('@solana/web3.js');
558
+ // own token account. The three refund instructions kept their v1 layout in the v2 interface
559
+ // (no arguments; sponsor, config, task, vault, sponsor_token_account, token_program and the
560
+ // two event accounts), so these checks are unchanged.
561
+ const { Transaction } = await import('@solana/web3.js');
344
562
  const tx = Transaction.from(Buffer.from(built.transaction_base64, 'base64'));
345
563
  const problems: string[] = [];
346
564
  if (tx.instructions.length !== 1) {
347
565
  problems.push(`expected 1 instruction, found ${tx.instructions.length}`);
348
566
  }
349
567
  const ix = tx.instructions[0];
350
- if (ix?.programId?.toBase58() !== cfg.program_id) {
351
- problems.push(`program is ${ix?.programId?.toBase58()}, expected ${cfg.program_id}`);
568
+ if (ix?.programId?.toBase58() !== pin.program_id) {
569
+ problems.push(`program is ${ix?.programId?.toBase58()}, expected ${pin.program_id}`);
352
570
  }
353
571
  const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
354
572
  if (signers.length !== 1 || signers[0] !== wallet.publicKey) {
@@ -365,7 +583,6 @@ export async function handleSolanaTool(
365
583
  if ((ix?.data?.length ?? 0) !== 8) {
366
584
  problems.push(`instruction data is ${ix?.data?.length} bytes, expected 8`);
367
585
  }
368
- void PublicKey;
369
586
 
370
587
  if (problems.length > 0) {
371
588
  return {
@@ -386,14 +603,36 @@ export async function handleSolanaTool(
386
603
  action: args.action,
387
604
  task_id: built.task_id,
388
605
  destination: tokenAccount,
606
+ ...(built.cancel_deadline ? { cancel_deadline: built.cancel_deadline } : {}),
389
607
  };
390
608
  }
391
609
 
392
610
  const signed = await signTransaction(built.transaction_base64, wallet);
393
- const confirmed = await apiPost(
394
- `/agent/solana/missions/${args.mission_id}/confirm-refund`,
395
- { signed_transaction: signed, action: args.action },
396
- );
611
+ let confirmed: unknown;
612
+ try {
613
+ confirmed = await apiPost(`/agent/solana/missions/${args.mission_id}/confirm-refund`, {
614
+ signed_transaction: signed,
615
+ action: args.action,
616
+ });
617
+ } catch (e: any) {
618
+ // The backend maps a cancel landing at or after qualify_deadline to 409 TooLateToCancel
619
+ // with available_actions; any other on-chain failure arrives as 400 "refund transaction
620
+ // failed: <RPC error>", which is where the program error code is.
621
+ const body = apiErrorBody(e);
622
+ const message = body.message ?? e?.message ?? String(e);
623
+ const programError = escrowErrorFromMessage(message);
624
+ return {
625
+ refunded: false,
626
+ rejected_by: programError || /transaction failed/i.test(message) ? 'chain' : 'backend',
627
+ action: args.action,
628
+ status: e?.status,
629
+ message,
630
+ program_error: programError ?? undefined,
631
+ available_actions: body.available_actions,
632
+ ...deadlines,
633
+ what_to_do: programError?.what_to_do ?? CANCEL_CUTOFF_RULE,
634
+ };
635
+ }
397
636
  return { refunded: true, verified: true, ...(confirmed as Record<string, unknown>) };
398
637
  }
399
638
 
@@ -11,7 +11,7 @@ export const trackTools: Tool[] = [
11
11
  'multi-MB file as base64 in the tool call can exceed the calling agent\'s own tool-call or context limits, ' +
12
12
  'causing a silent client-side failure before any request reaches the API) OR inline as file_base64 (fine for ' +
13
13
  'small images). Exactly one of the two is required. ' +
14
- 'For on-chain missions, call this BEFORE confirm_funding — uploads are blocked once the mission is active. ' +
14
+ 'For on-chain missions, call this BEFORE fund_solana_mission — uploads are blocked once the mission is active. ' +
15
15
  'For off-chain missions, call while the mission is active and before any participant is hired. ' +
16
16
  'The item becomes visible to hired participants once confirmed.',
17
17
  inputSchema: {