@projectsolo/solo-mission-mcp 0.21.14 → 0.22.1

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 (33) hide show
  1. package/README.md +99 -15
  2. package/dist/chunk-NUGGTARW.js +196 -0
  3. package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
  4. package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
  5. package/dist/deployment-UCUWVIPJ.js +43 -0
  6. package/dist/escrowErrors-4XSAYLGT.js +56 -0
  7. package/dist/index.js +239 -160
  8. package/dist/verify-7EUVRUAS.js +445 -0
  9. package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
  10. package/dist/wire-RLDY4AJR.js +24 -0
  11. package/package.json +2 -2
  12. package/src/config.ts +11 -0
  13. package/src/index.ts +1 -1
  14. package/src/scripts/check-tools-against-spec.ts +3 -3
  15. package/src/solana/deployment.test.ts +61 -0
  16. package/src/solana/deployment.ts +89 -0
  17. package/src/solana/escrowErrors.ts +78 -0
  18. package/src/solana/fixtures/config-account.json +6 -0
  19. package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
  20. package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
  21. package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
  22. package/src/solana/verify.test.ts +605 -78
  23. package/src/solana/verify.ts +545 -84
  24. package/src/solana/wallet.test.ts +85 -2
  25. package/src/solana/wallet.ts +74 -31
  26. package/src/solana/wire.test.ts +129 -0
  27. package/src/solana/wire.ts +264 -0
  28. package/src/tools/missions.ts +45 -117
  29. package/src/tools/solana.test.ts +356 -0
  30. package/src/tools/solana.ts +309 -70
  31. package/src/tools/tracks.ts +1 -1
  32. package/dist/verify-KAETIGV5.js +0 -136
  33. /package/src/solana/fixtures/{funding-transaction.json → funding-transaction-v1.json} +0 -0
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  apiPatch,
6
6
  apiPost,
7
7
  publicApiPost
8
- } from "./chunk-TKRT2V2W.js";
8
+ } from "./chunk-OP4QYTL4.js";
9
9
 
10
10
  // src/index.ts
11
11
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -61,7 +61,7 @@ var RESPONSE_SCHEMA_PROPERTY = {
61
61
  var missionTools = [
62
62
  {
63
63
  name: "create_mission",
64
- description: "Create a new mission. Off-chain missions (no budget field) are always free \u2014 no payment, no escrow. For paid missions, include the budget field AND chain: 'solana' \u2014 the response will contain funding_params for fund_solana_mission. Base (EscrowVault) on-chain missions are closed to new creation during the Solana private beta migration: any budget-bearing request that resolves to chain 'base' (including the pre-Solana default of omitting chain) now returns 503, not a created mission \u2014 always pass chain: 'solana' explicitly for a paid mission.",
64
+ description: "Create a new mission. Off-chain missions (no budget field) are always free \u2014 no payment, no escrow. Include the budget field for a paid mission escrowed on Solana: it is created in pending_funding, and you then fund it with fund_solana_mission (pass the returned mission id). Solana is the only chain (the backend also defaults a missing chain to solana, and this tool always sends it). A paid mission's response includes funding_params: chain ('solana'), cluster, program_id, rpc_url, mint, token_address (same as mint), token_decimals, amount_raw, base_pool, lottery_reward_per_winner_raw, lottery_winner_count, qualify_deadline, settlement_deadline, seed_commit and expires_at. There is no escrow address or task id yet: the program assigns those when the mission is funded. expires_at is creation + 24 hours \u2014 a mission still unfunded then is cancelled automatically. You do not need to build anything from funding_params; fund_solana_mission does the whole funding step. The response's mission.solana_quoted_fee_bps is the platform fee rate you were quoted: the escrow transaction carries it as max_fee_bps, the most the program may charge, and fund_solana_mission refuses to sign any other value. A paid lottery (lottery_winner_count > 0) is funded with a transaction the platform Operator has already co-signed; fund_solana_mission verifies that co-signature before adding yours. Paid-mission deadlines follow the escrow program's rules \u2014 see hiring_duration_hours and work_duration_hours.",
65
65
  inputSchema: {
66
66
  type: "object",
67
67
  properties: {
@@ -72,8 +72,8 @@ var missionTools = [
72
72
  },
73
73
  chain: {
74
74
  type: "string",
75
- enum: ["base", "solana"],
76
- description: "Which chain escrows a paid (budget-bearing) mission. 'base' is currently closed to new creation and returns 503 \u2014 pass 'solana' for any on-chain mission. Ignored for off-chain missions (no budget field)."
75
+ enum: ["solana"],
76
+ description: "Optional. Solana is the only chain: a paid (budget-bearing) mission is always sent with chain 'solana' whether or not you set this. Ignored for off-chain missions (no budget field)."
77
77
  },
78
78
  title: { type: "string", description: "Mission title (max 100 chars)" },
79
79
  description: { type: "string", description: "Detailed mission description (max 2000 chars). Supports Markdown \u2014 use ## headings, - bullet lists, and **bold** to structure your content. The platform renders it as formatted text." },
@@ -90,14 +90,14 @@ var missionTools = [
90
90
  reward_usdt: { type: "number", minimum: 0, description: "Deprecated \u2014 ignored for off-chain missions (which are always free). Has no effect. Omit this field." },
91
91
  max_participants: { type: "number", minimum: 1, description: "Maximum number of participants" },
92
92
  expires_in_hours: { type: "number", minimum: 1, description: "Hours until mission expires" },
93
- budget: { type: "number", description: "Total mission budget in USDC. Enables on-chain escrow \u2014 pair with chain: 'solana' (Base is closed to new creation, see chain). Must satisfy: base_reward * max_humans + lottery_prize_per_winner * lottery_winner_count <= budget." },
93
+ budget: { type: "number", description: "Total mission budget in USDC. Enables on-chain escrow on Solana (fund it afterwards with fund_solana_mission). Must also cover the platform fee, which is reserved out of the budget: base_reward * max_humans + lottery_prize_per_winner * lottery_winner_count + floor(budget * fee_bps / 10000) <= budget (equality passes). fee_bps is read from the Solana escrow program's on-chain config, not a fixed rate (currently 0 on devnet). A 400 for this rule states the rate in use; a 503 means the rate could not be read, nothing was created, and you should retry shortly. Both the backend and the Solana escrow program enforce this." },
94
94
  max_humans: { type: "number", minimum: 1, description: "On-chain: maximum number of participants (both base-reward and lottery entrants)." },
95
95
  base_reward: { type: "number", minimum: 0, description: "Per-participant base reward in USDC paid to every qualified human. Defaults to 0. Set to 0 for pure-lottery missions." },
96
96
  reward_per_human: { type: "number", minimum: 0, description: "Deprecated \u2014 use base_reward instead." },
97
- lottery_winner_count: { type: "integer", minimum: 1, description: "Number of winners randomly selected from all qualified participants. Must be <= max_humans. Must be paired with lottery_prize_per_winner. Winners are chosen deterministically from the on-chain seed reveal \u2014 auditable by anyone." },
97
+ lottery_winner_count: { type: "integer", minimum: 1, description: "Number of winners randomly selected from all qualified participants. Must be <= max_humans. Must be paired with lottery_prize_per_winner. For a paid mission the draw happens on chain at settlement: final_entropy = keccak256(seed_reveal \u2016 entropy_hash), where seed_reveal opens the seed_commit fixed at funding and entropy_hash is the SlotHashes sysvar bank hash of the first produced slot after finalize_qualification, stored on the Task by record_entropy (settle captures it if nobody recorded it). It is NOT the blockhash getBlock returns for that slot, so recompute a draw from the Task account's entropy_hash, not from getBlock. The platform Operator co-signs a lottery's funding transaction (the escrow program requires it)." },
98
98
  lottery_prize_per_winner: { type: "number", minimum: 0, description: "Additional prize in USDC paid to each lottery winner on top of base_reward. Must be paired with lottery_winner_count." },
99
- hiring_duration_hours: { type: "number", minimum: 0.0166, description: "How long (hours) the mission accepts applications and the agent hires/rejects. The hiring window closes at now + hiring_duration_hours. Finalize-qualification cannot be called before this. Backend floor is 60s (0.0166h), same for every chain \u2014 see work_duration_hours for the reasoning." },
100
- work_duration_hours: { type: "number", minimum: 0.0166, description: "How long (hours) hired participants have to complete the work. Agent must call settle_mission before this period ends. Backend floor is 60s (0.0166h) for every chain \u2014 this is a flat sanity check against a near-zero window, not a guarantee of a legal settlement window on every chain: Base's own EscrowVault contract separately enforces its own fixed 1-hour minimum regardless of what this floor allows through, so a too-short Base mission still fails downstream (at finalize_qualification, or on-chain) instead of being caught at creation." },
99
+ hiring_duration_hours: { type: "number", minimum: 0.0166, description: "How long (hours) the mission accepts applications and the agent hires/rejects. The hiring window closes at now + hiring_duration_hours. Finalize-qualification cannot be called before this. Closing hiring does NOT end the work: hired humans keep voting and submitting answers after it (see work_duration_hours). Backend floor is 60s (0.0166h) \u2014 see work_duration_hours for what that floor does and does not guarantee. For a paid mission the end of the hiring window is the escrow's qualify_deadline: it may be at most 180 days ahead, and it is the cancel cut-off \u2014 refund_solana_mission's cancel is refused on chain from qualify_deadline on (TooLateToCancel)." },
100
+ work_duration_hours: { type: "number", minimum: 0.0166, description: "How long (hours) hired participants have to complete the work: settlement_deadline = end of the hiring window + work_duration_hours. Agent must call settle_mission before this period ends. Humans can vote and submit answers while the mission is active: on-chain until max(qualify_deadline, settlement_deadline \u2212 1260s), i.e. about 21 minutes before settlement_deadline; off-chain until finalize. Either way, finalize_qualification closes submissions. Backend floor is 60s (0.0166h) \u2014 a sanity check against a near-zero window, not a guarantee the escrow accepts it. For a paid mission the Solana escrow program separately requires settlement_deadline >= qualify_deadline + min_review_window + finalize grace, and settlement_deadline <= qualify_deadline + 90 days. min_review_window is the program's configured review window and the finalize grace equals its floor (10 seconds each on the current devnet build; the floor is 1 hour on a production build). create_mission checks both against the live program config and returns 400 on a violation; the program enforces them again when the escrow is funded, and finalize_qualification must still leave min_review_window before settlement_deadline." },
101
101
  auto_accept_applicants: { type: "boolean", description: "Defaults to true \u2014 applicants are automatically hired when they apply, no manual hire_participant call needed, first-come first-served up to max_humans, face verification still required. Pass false to opt into manual review instead (see hire_participant for how unreviewed applicants are still handled once the hiring window closes)." },
102
102
  response_schema: RESPONSE_SCHEMA_PROPERTY
103
103
  },
@@ -143,21 +143,9 @@ var missionTools = [
143
143
  required: ["mission_id"]
144
144
  }
145
145
  },
146
- {
147
- name: "confirm_funding",
148
- description: `Base (EscrowVault) only \u2014 Solana missions use fund_solana_mission instead, which confirms as part of the same call. Base is currently closed to new mission creation, so this only applies to a Base mission created before that change. After calling createTask() on the EscrowVault contract, confirm the funding via the SOLO API. The backend verifies the transaction on-chain. Mission transitions from pending_funding \u2192 active. tx_hash is optional \u2014 backend reconciles from the contract if omitted. For media_review missions: returns 409 if no tracks are confirmed yet \u2014 call add_mission_track first. Also returns 409 if the on-chain task's budget, lottery, deadline, or seed_commit values do not match what create_mission originally quoted (e.g. createTask() was called with hand-typed or re-derived values instead of the exact funding_params fields) \u2014 unlike the "not yet FUNDED" 409, this one is NOT retryable: the task_id can never be confirmed. Call cancelTask() on-chain to reclaim the full escrow, then call create_mission again.`,
149
- inputSchema: {
150
- type: "object",
151
- properties: {
152
- mission_id: { type: "string" },
153
- tx_hash: { type: "string", description: "Transaction hash of createTask() call (optional)" }
154
- },
155
- required: ["mission_id"]
156
- }
157
- },
158
146
  {
159
147
  name: "hire_participant",
160
- description: "Accept a human applicant for a mission. Only applied humans can be hired. Hired humans can start work via conversations. Only valid while mission is active. IMPORTANT: silence is not neutral \u2014 an applicant you never call this (or reject_participant) on is automatically hired once the hiring window closes (up to max_humans, oldest applied_at first). If you do not want someone, you must call reject_participant before the deadline.",
148
+ description: "Accept a human applicant for a mission. Only applied humans can be hired. Hired humans can start work via conversations. Only valid while mission is active. IMPORTANT: silence is not neutral \u2014 an applicant you never call this (or reject_participant) on is automatically hired (up to max_humans, oldest applied_at first). That does NOT happen when the hiring window closes. For an on-chain mission it happens in the window just before settlement_deadline (roughly 21 to 1 minutes before it), in the same pass that auto-finalizes and settles. For an off-chain mission it happens at expires_at. If you do not want someone, call reject_participant before then.",
161
149
  inputSchema: {
162
150
  type: "object",
163
151
  properties: {
@@ -169,7 +157,7 @@ var missionTools = [
169
157
  },
170
158
  {
171
159
  name: "reject_participant",
172
- description: "Reject a human applicant or hired participant. Valid for applied or hired status, before finalize_qualification is called. This is the ONLY way to exclude someone \u2014 the platform auto-hires overdue applicants and auto-qualifies overdue hired participants by default (silence = go), so if you do not explicitly reject someone before their deadline, they end up hired/qualified/paid.",
160
+ description: "Reject a human applicant or hired participant. Valid for applied or hired status, before finalize_qualification is called. This is the ONLY way to exclude someone \u2014 the platform auto-hires remaining applicants and auto-qualifies hired participants by default (silence = go), so anyone you do not explicitly reject ends up hired, qualified and paid. That happens in the window just before settlement_deadline for an on-chain mission (roughly 21 to 1 minutes before it) and at expires_at for an off-chain mission, not when the hiring window closes.",
173
161
  inputSchema: {
174
162
  type: "object",
175
163
  properties: {
@@ -181,7 +169,7 @@ var missionTools = [
181
169
  },
182
170
  {
183
171
  name: "finalize_qualification",
184
- description: "Lock in the qualified participants after reviewing their work. If the mission has a response_schema (media_review always does, by default or explicitly; any other type only if you set one via create_mission/update_mission_questions), pass an empty body \u2014 qualified_human_uids is ignored and the backend auto-qualifies anyone who completed it (every ready track for media_review, a full submission for other types). Otherwise provide an explicit list of UIDs whose work was accepted. For on-chain missions, the backend calls finalizeQualification() on EscrowVault. Mission transitions to qualifying. If you never call this, the platform does it for you shortly after the hiring window closes, qualifying every hired participant you did not explicitly reject_participant (silence = go) \u2014 call reject_participant first for anyone whose work should NOT be paid.",
172
+ description: "Lock in the qualified participants after reviewing their work. If the mission has a response_schema (media_review always does, by default or explicitly; any other type only if you set one via create_mission/update_mission_questions), pass an empty body \u2014 qualified_human_uids is ignored and the backend auto-qualifies anyone who completed it (every ready track for media_review, a full submission for other types). Otherwise provide an explicit list of UIDs whose work was accepted. For on-chain missions, the backend records the qualified set on the Solana escrow program. Mission transitions to qualifying. If you never call this, the platform does it for you (silence = go), qualifying every hired participant you did not explicitly reject_participant. It does NOT happen when the hiring window closes. For an on-chain mission it happens in a short window just before settlement_deadline (roughly 21 to 1 minutes before it), where the platform also auto-hires any remaining applicants, finalizes and settles in one pass. For an off-chain mission it happens at expires_at. Call reject_participant first for anyone whose work should NOT be paid.",
185
173
  inputSchema: {
186
174
  type: "object",
187
175
  properties: {
@@ -197,7 +185,7 @@ var missionTools = [
197
185
  },
198
186
  {
199
187
  name: "settle_mission",
200
- description: "Settle the mission after finalize_qualification. For on-chain missions, calls settleTask() on EscrowVault only (aggregate payout numbers, no per-wallet computation); mission transitions to completed or refundable. IMPORTANT: status alone cannot tell you whether anyone was paid \u2014 a mission with 8/10 slots filled (2 slots' budget refunded) and one with 0/10 filled (the whole budget refunded) both land on 'refundable'/'refunded'. Check the settlement_outcome field on the response instead: 'completed' (fully spent, no refund), 'completed_refundable' (real participants were paid, only the unfilled slots refund), or 'no_payout_refunded' (nobody qualified, the entire budget bounces). Rewards are NOT immediately claimable \u2014 a separate batched process publishes the Merkle root that makes them claimable, gated by a review window currently defaulting to about 10 seconds (minimized pre-launch; will lengthen to an hour or more once real funds are at stake). Check GET /human/rewards for actual claimable status rather than assuming a fixed delay. For free (off-chain) missions, marks qualified participants as completed \u2014 no payment is involved; settlement_outcome is always 'completed' there. If you never call this, the platform settles on your behalf shortly after finalize_qualification (auto or manual) \u2014 you do not get a second chance to change who is qualified at that point, so reject anyone unwanted before finalize, not after.",
188
+ description: "Settle the mission after finalize_qualification. For on-chain missions, the backend settles the Solana escrow with aggregate payout numbers only (no per-wallet computation); mission transitions to completed or refundable. A 'refundable' mission is holding leftover budget for you: claim it with refund_solana_mission, action: 'claim_refund' (the mission then moves to refunded). IMPORTANT: status alone cannot tell you whether anyone was paid \u2014 a mission with 8/10 slots filled (2 slots' budget refunded) and one with 0/10 filled (the whole budget refunded) both land on 'refundable'/'refunded'. Check the settlement_outcome field on the response instead: 'completed' (fully spent, no refund), 'completed_refundable' (real participants were paid, only the unfilled slots refund), or 'no_payout_refunded' (nobody qualified, the entire budget bounces). Rewards are NOT immediately claimable \u2014 a separate batched process publishes the Merkle root that makes them claimable, gated by a review window currently defaulting to about 10 seconds (minimized pre-launch; will lengthen to an hour or more once real funds are at stake). Check GET /human/solana/rewards for actual claimable status rather than assuming a fixed delay. For free (off-chain) missions, marks qualified participants as completed \u2014 no payment is involved; settlement_outcome is always 'completed' there. You may settle at any time after finalize_qualification and before settlement_deadline; there is no need to wait. If you never call this, the platform settles on your behalf, but NOT right after finalize: for an on-chain mission it happens in the window just before settlement_deadline (roughly 21 to 1 minutes before it), and for an off-chain mission at expires_at. Who is qualified is fixed at finalize, so reject anyone unwanted before finalize, not after.",
201
189
  inputSchema: {
202
190
  type: "object",
203
191
  properties: {
@@ -208,7 +196,7 @@ var missionTools = [
208
196
  },
209
197
  {
210
198
  name: "cancel_mission",
211
- description: "Cancel an off-chain mission directly. Valid when status is active or qualifying. For on-chain missions, use get_cancel_params instead.",
199
+ description: "Cancel a mission that holds no escrowed funds: an off-chain mission (status active or qualifying), or an unfunded Solana mission still in pending_funding. For a Solana mission the backend first checks the chain: if it finds an escrow that was created but never confirmed, it returns 409 with that task_id and does not cancel \u2014 use refund_solana_mission with action: 'cancel' then. For a funded mission also use refund_solana_mission with action: 'cancel': it returns the escrowed budget to you and moves the mission to cancelled, but only strictly before qualify_deadline (the end of the hiring window) \u2014 the escrow program refuses a cancel from then on (TooLateToCancel), and the remaining exit is emergency_refund once settlement_deadline passes without a settlement. An unfunded Solana mission you leave alone is cancelled automatically at its funding_params.expires_at (creation + 24 hours).",
212
200
  inputSchema: {
213
201
  type: "object",
214
202
  properties: {
@@ -217,78 +205,9 @@ var missionTools = [
217
205
  required: ["mission_id"]
218
206
  }
219
207
  },
220
- {
221
- name: "get_cancel_params",
222
- description: "Get on-chain transaction parameters to cancel a funded mission via cancelTask() on EscrowVault. Only valid before qualify_deadline. After qualify_deadline, use get_emergency_refund_params instead.",
223
- inputSchema: {
224
- type: "object",
225
- properties: {
226
- mission_id: { type: "string" }
227
- },
228
- required: ["mission_id"]
229
- }
230
- },
231
- {
232
- name: "confirm_cancel",
233
- description: "After executing cancelTask() on EscrowVault, confirm the cancellation on the SOLO platform. Mission transitions to cancelled.",
234
- inputSchema: {
235
- type: "object",
236
- properties: {
237
- mission_id: { type: "string" },
238
- tx_hash: { type: "string", description: "Transaction hash of cancelTask() (optional)" }
239
- },
240
- required: ["mission_id"]
241
- }
242
- },
243
- {
244
- name: "get_emergency_refund_params",
245
- description: "Get on-chain transaction parameters to force-refund a mission after the settlement_deadline has passed without settlement. Returns eligible: true with params if eligible, or eligible: false with retry_after if not yet past the deadline.",
246
- inputSchema: {
247
- type: "object",
248
- properties: {
249
- mission_id: { type: "string" }
250
- },
251
- required: ["mission_id"]
252
- }
253
- },
254
- {
255
- name: "confirm_emergency_refund",
256
- description: "After executing emergencyRefund() on EscrowVault, confirm on the SOLO platform. Mission transitions to cancelled.",
257
- inputSchema: {
258
- type: "object",
259
- properties: {
260
- mission_id: { type: "string" },
261
- tx_hash: { type: "string", description: "Transaction hash of emergencyRefund() (optional)" }
262
- },
263
- required: ["mission_id"]
264
- }
265
- },
266
- {
267
- name: "get_refund_params",
268
- description: "Get on-chain transaction parameters to claim unused budget via claimRefund() on EscrowVault. Only valid when mission is in refundable state (settled with leftover budget).",
269
- inputSchema: {
270
- type: "object",
271
- properties: {
272
- mission_id: { type: "string" }
273
- },
274
- required: ["mission_id"]
275
- }
276
- },
277
- {
278
- name: "confirm_refund",
279
- description: "After executing claimRefund() on EscrowVault, confirm the refund on the SOLO platform. Mission transitions to refunded.",
280
- inputSchema: {
281
- type: "object",
282
- properties: {
283
- mission_id: { type: "string" },
284
- tx_hash: { type: "string", description: "Transaction hash of claimRefund() (optional)" }
285
- },
286
- required: ["mission_id"]
287
- }
288
- },
289
208
  {
290
209
  name: "rate_participant",
291
- description: "Leave a rating and optional comment for a mission participant. Requires the mission to be settled (completed, refundable, or refunded) and must be submitted within 7 days of mission completion. Limited to one rating per participant per mission; calling again overwrites the previous rating/comment.",
210
+ description: "Leave a rating and optional comment for a mission participant. Allowed for every settled outcome (mission status completed, refundable or refunded), within 7 days of settlement. Limited to one rating per participant per mission; calling again overwrites the previous rating/comment.",
292
211
  inputSchema: {
293
212
  type: "object",
294
213
  properties: {
@@ -303,8 +222,10 @@ var missionTools = [
303
222
  ];
304
223
  async function handleMissionTool(name, args) {
305
224
  switch (name) {
306
- case "create_mission":
307
- return apiPost("/agent/missions", args);
225
+ case "create_mission": {
226
+ const { chain: _chain, ...body } = args;
227
+ return apiPost("/agent/missions", body.budget !== void 0 ? { ...body, chain: "solana" } : body);
228
+ }
308
229
  case "list_missions": {
309
230
  const params = {};
310
231
  if (args.status) params.status = args.status;
@@ -316,8 +237,6 @@ async function handleMissionTool(name, args) {
316
237
  return apiGet(`/agent/missions/${args.mission_id}`);
317
238
  case "update_mission_questions":
318
239
  return apiPatch(`/agent/missions/${args.mission_id}/questions`, { response_schema: args.response_schema });
319
- case "confirm_funding":
320
- return apiPost(`/agent/missions/${args.mission_id}/confirm-funding`, { tx_hash: args.tx_hash });
321
240
  case "hire_participant":
322
241
  return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/hire`);
323
242
  case "reject_participant":
@@ -330,18 +249,6 @@ async function handleMissionTool(name, args) {
330
249
  return apiPost(`/agent/missions/${args.mission_id}/settle`);
331
250
  case "cancel_mission":
332
251
  return apiPost(`/agent/missions/${args.mission_id}/cancel`);
333
- case "get_cancel_params":
334
- return apiGet(`/agent/missions/${args.mission_id}/cancel-params`);
335
- case "confirm_cancel":
336
- return apiPost(`/agent/missions/${args.mission_id}/confirm-cancel`, { tx_hash: args.tx_hash });
337
- case "get_emergency_refund_params":
338
- return apiGet(`/agent/missions/${args.mission_id}/emergency-refund-params`);
339
- case "confirm_emergency_refund":
340
- return apiPost(`/agent/missions/${args.mission_id}/confirm-emergency-refund`, { tx_hash: args.tx_hash });
341
- case "get_refund_params":
342
- return apiGet(`/agent/missions/${args.mission_id}/refund-params`);
343
- case "confirm_refund":
344
- return apiPost(`/agent/missions/${args.mission_id}/confirm-refund`, { tx_hash: args.tx_hash });
345
252
  case "rate_participant":
346
253
  return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/comment`, {
347
254
  rating: args.rating,
@@ -882,7 +789,7 @@ import { readFile } from "fs/promises";
882
789
  var trackTools = [
883
790
  {
884
791
  name: "add_mission_track",
885
- description: "Upload a media item (audio, image, or video) to a media_review mission. Provide the file EITHER via file_path (the MCP server reads it from local disk and uploads it directly \u2014 use this for audio/video, since inlining a multi-MB file as base64 in the tool call can exceed the calling agent's own tool-call or context limits, causing a silent client-side failure before any request reaches the API) OR inline as file_base64 (fine for small images). Exactly one of the two is required. For on-chain missions, call this BEFORE confirm_funding \u2014 uploads are blocked once the mission is active. For off-chain missions, call while the mission is active and before any participant is hired. The item becomes visible to hired participants once confirmed.",
792
+ description: "Upload a media item (audio, image, or video) to a media_review mission. Provide the file EITHER via file_path (the MCP server reads it from local disk and uploads it directly \u2014 use this for audio/video, since inlining a multi-MB file as base64 in the tool call can exceed the calling agent's own tool-call or context limits, causing a silent client-side failure before any request reaches the API) OR inline as file_base64 (fine for small images). Exactly one of the two is required. For on-chain missions, call this BEFORE fund_solana_mission \u2014 uploads are blocked once the mission is active. For off-chain missions, call while the mission is active and before any participant is hired. The item becomes visible to hired participants once confirmed.",
886
793
  inputSchema: {
887
794
  type: "object",
888
795
  properties: {
@@ -1005,12 +912,12 @@ async function handleTrackTool(name, args) {
1005
912
  var solanaTools = [
1006
913
  {
1007
914
  name: "get_solana_config",
1008
- description: "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 \u2014 a mint that is not whitelisted is rejected on chain, not by the API. Requires no wallet.",
915
+ description: "Read the Solana escrow deployment: program id, cluster, RPC endpoint, accepted mints and their decimals, the minimum first payout, and escrow_interface \u2014 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 \u2014 a mint that is not whitelisted is rejected on chain, not by the API. Requires no wallet.",
1009
916
  inputSchema: { type: "object", properties: {} }
1010
917
  },
1011
918
  {
1012
919
  name: "get_solana_wallet",
1013
- description: "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 \u2014 most of it returns when the task is closed. If no wallet is configured this explains how to set one up.",
920
+ description: "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 \u2014 most of it returns when the task is closed. If no wallet is configured this explains how to set one up.",
1014
921
  inputSchema: {
1015
922
  type: "object",
1016
923
  properties: {
@@ -1023,18 +930,23 @@ var solanaTools = [
1023
930
  },
1024
931
  {
1025
932
  name: "fund_solana_mission",
1026
- description: "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 \u2014 a substituted mint, an altered budget, an extra instruction, a vault that is not a program-derived address \u2014 and returns the discrepancies instead.\n\nCall create_mission with chain='solana' first; pass that mission's id here.",
933
+ description: "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 \u2014 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 \u2014 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.",
1027
934
  inputSchema: {
1028
935
  type: "object",
1029
936
  properties: {
1030
- mission_id: { type: "string", description: 'Mission created with chain="solana".' },
937
+ mission_id: { type: "string", description: "A paid (budget-bearing) mission from create_mission, still in pending_funding." },
1031
938
  expected_budget: {
1032
939
  type: "number",
1033
940
  description: "The total budget in whole tokens (e.g. 10 for 10 USDC) you expect to escrow. Verified against the transaction before signing. Pass what you intended, NOT what the API told you \u2014 comparing the API to itself proves nothing."
1034
941
  },
1035
942
  expected_mint: {
1036
943
  type: "string",
1037
- description: "The mint you expect the budget to be taken in. Verified before signing. Defaults to the deployment payout mint."
944
+ description: "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)."
945
+ },
946
+ expected_max_fee_bps: {
947
+ type: "integer",
948
+ minimum: 0,
949
+ description: "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."
1038
950
  },
1039
951
  dry_run: {
1040
952
  type: "boolean",
@@ -1046,7 +958,7 @@ var solanaTools = [
1046
958
  },
1047
959
  {
1048
960
  name: "refund_solana_mission",
1049
- description: "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 \u2014 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.",
961
+ description: "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 \u2014 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.",
1050
962
  inputSchema: {
1051
963
  type: "object",
1052
964
  properties: {
@@ -1079,19 +991,25 @@ function toRawAmount(amount, decimals) {
1079
991
  return (whole + frac.padEnd(decimals, "0")).replace(/^0+(?=\d)/, "");
1080
992
  }
1081
993
  var SOLANA_TOOL_NAMES = new Set(solanaTools.map((t) => t.name));
994
+ function apiErrorBody(e) {
995
+ return e?.data ?? e?.response?.data ?? e?.body ?? {};
996
+ }
997
+ var CANCEL_CUTOFF_RULE = "cancel is refused on chain from qualify_deadline on (TooLateToCancel, error 6125); emergency_refund opens once settlement_deadline has passed without a settlement.";
1082
998
  async function handleSolanaTool(name, args) {
1083
- const { apiGet: apiGet2, apiPost: apiPost2 } = await import("./client-LDWK5HLP.js");
999
+ const { apiGet: apiGet2, apiPost: apiPost2 } = await import("./client-NS6J6IO2.js");
1000
+ const { resolvePinnedProgram, resolveTrustedRpc, solanaRpcUrl } = await import("./deployment-UCUWVIPJ.js");
1084
1001
  switch (name) {
1085
1002
  case "get_solana_config":
1086
1003
  return apiGet2("/agent/solana/config");
1087
1004
  case "get_solana_wallet": {
1088
- const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-V4T4NTCM.js");
1005
+ const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress, KEY_STORAGE_GUIDANCE } = await import("./wallet-LX7SI5RE.js");
1089
1006
  if (!hasSolanaWallet()) {
1090
1007
  return {
1091
1008
  configured: false,
1092
1009
  how_to_configure: {
1093
- option_1: "SOLO_SOLANA_KEYPAIR \u2014 JSON byte array, as `solana-keygen new` writes it",
1094
- option_2: "SOLO_SOLANA_KEYPAIR_PATH \u2014 path to that file, e.g. ~/.config/solana/id.json"
1010
+ recommended: "SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE \u2014 a keyfile encrypted with `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256`, plus a separate passphrase file (chmod 600 both)",
1011
+ alternative: "SOLO_SOLANA_KEYPAIR_PATH \u2014 path to a plaintext `solana-keygen` keyfile, e.g. ~/.config/solana/id.json (chmod 600)",
1012
+ key_storage: KEY_STORAGE_GUIDANCE
1095
1013
  },
1096
1014
  what_you_need: "SOL for transaction fees and account rent, plus the payout token for the budget. Rent is a refundable deposit, not a fee \u2014 most of it returns on close_task. Roughly $0.58 is locked per mission and about $0.24 is permanent, which buys the on-chain record that makes the escrow verifiable by anyone."
1097
1015
  };
@@ -1102,7 +1020,7 @@ async function handleSolanaTool(name, args) {
1102
1020
  const mint = args.mint ?? cfg.mints[symbol];
1103
1021
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
1104
1022
  const rpc = async (method, params) => {
1105
- const res = await fetch(cfg.rpc_url, {
1023
+ const res = await fetch(solanaRpcUrl(cfg), {
1106
1024
  method: "POST",
1107
1025
  headers: { "Content-Type": "application/json" },
1108
1026
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params })
@@ -1131,23 +1049,87 @@ async function handleSolanaTool(name, args) {
1131
1049
  };
1132
1050
  }
1133
1051
  case "fund_solana_mission": {
1134
- const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
1135
- const { verifyFundingTransaction } = await import("./verify-KAETIGV5.js");
1052
+ const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-LX7SI5RE.js");
1053
+ const { verifyFundingTransaction, readEscrowConfig, readMintDecimals, SUPPORTED_ESCROW_INTERFACE } = await import("./verify-7EUVRUAS.js");
1136
1054
  const wallet = await loadSolanaWallet();
1137
1055
  const cfg = await apiGet2("/agent/solana/config");
1138
- const symbol = Object.keys(cfg.mints)[0];
1139
- const mint = args.expected_mint ?? cfg.mints[symbol];
1140
- const decimals = cfg.decimals[symbol] ?? 6;
1056
+ if (cfg.escrow_interface !== SUPPORTED_ESCROW_INTERFACE) {
1057
+ return {
1058
+ funded: false,
1059
+ refused_to_sign: true,
1060
+ problems: [
1061
+ `the deployment reports escrow_interface ${JSON.stringify(cfg.escrow_interface)}; this version of @projectsolo/solo-mission-mcp verifies only '${SUPPORTED_ESCROW_INTERFACE}' funding transactions`
1062
+ ],
1063
+ what_this_means: "Nothing was built, signed or escrowed. This server cannot decode the funding transaction the deployment would build, so it will not sign one. Use a version of this package that supports the reported escrow interface."
1064
+ };
1065
+ }
1066
+ const pin = resolvePinnedProgram(cfg);
1067
+ const rpc = resolveTrustedRpc(cfg);
1068
+ if ("problem" in pin || "problem" in rpc) {
1069
+ return {
1070
+ funded: false,
1071
+ refused_to_sign: true,
1072
+ problems: [
1073
+ ..."problem" in pin ? [pin.problem] : [],
1074
+ ..."problem" in rpc ? [rpc.problem] : []
1075
+ ],
1076
+ what_this_means: "Nothing was built, signed or escrowed."
1077
+ };
1078
+ }
1079
+ const rpcUrl = rpc.rpc_url;
1080
+ const { mission } = await apiGet2(`/agent/missions/${args.mission_id}`);
1081
+ if (!mission) {
1082
+ return {
1083
+ funded: false,
1084
+ error: `Could not read mission ${args.mission_id} \u2014 call get_mission to check it exists.`
1085
+ };
1086
+ }
1087
+ const mint = args.expected_mint ?? mission.token_address ?? cfg.mints[Object.keys(cfg.mints)[0]];
1088
+ let decimals;
1089
+ try {
1090
+ decimals = await readMintDecimals(rpcUrl, mint);
1091
+ } catch (e) {
1092
+ return {
1093
+ funded: false,
1094
+ refused_to_sign: true,
1095
+ problems: [
1096
+ `cannot read mint ${mint}'s decimals from chain, which expected_budget is scaled by: ` + e.message
1097
+ ],
1098
+ what_this_means: "Nothing was built, signed or escrowed."
1099
+ };
1100
+ }
1141
1101
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
1142
1102
  const built = await apiPost2(
1143
1103
  `/agent/solana/missions/${args.mission_id}/funding-transaction`,
1144
1104
  { sponsor_wallet: wallet.publicKey, sponsor_token_account: tokenAccount }
1145
1105
  );
1106
+ let onchainConfig = null;
1107
+ let onchainConfigError;
1108
+ try {
1109
+ onchainConfig = await readEscrowConfig(rpcUrl, pin.program_id);
1110
+ } catch (e) {
1111
+ onchainConfigError = e.message;
1112
+ }
1113
+ let quotedFeeBps;
1114
+ let feeQuoteSource;
1115
+ if (args.expected_max_fee_bps !== void 0) {
1116
+ quotedFeeBps = Number(args.expected_max_fee_bps);
1117
+ feeQuoteSource = "expected_max_fee_bps argument";
1118
+ } else if (typeof mission.solana_quoted_fee_bps === "number") {
1119
+ quotedFeeBps = mission.solana_quoted_fee_bps;
1120
+ feeQuoteSource = "mission.solana_quoted_fee_bps (the create_mission quote)";
1121
+ } else {
1122
+ quotedFeeBps = onchainConfig?.fee_bps;
1123
+ feeQuoteSource = "on-chain Config.fee_bps (the mission has no recorded quote)";
1124
+ }
1146
1125
  const expectedBudgetRaw = toRawAmount(args.expected_budget, decimals);
1147
1126
  const verdict = await verifyFundingTransaction({
1148
1127
  transaction_base64: built.transaction_base64,
1128
+ escrow_interface: built.escrow_interface,
1129
+ task_id: String(built.task_id),
1149
1130
  declared: built.declared,
1150
1131
  accounts: built.accounts,
1132
+ message_sha256: built.message_sha256,
1151
1133
  expected: {
1152
1134
  budget_raw: expectedBudgetRaw,
1153
1135
  // base_pool is derived by the backend from reward × max_humans. The agent's check on it
@@ -1155,21 +1137,30 @@ async function handleSolanaTool(name, args) {
1155
1137
  // asserting a locally recomputed figure would require duplicating that arithmetic here
1156
1138
  // and would fail on a legitimately rounded reward.
1157
1139
  base_pool_raw: String(built.declared.base_pool),
1158
- lottery_winner_count: Number(built.declared.lottery_winner_count),
1159
- lottery_prize_per_winner_raw: String(built.declared.lottery_prize_per_winner),
1160
- qualify_deadline: Number(built.declared.qualify_deadline),
1161
- settlement_deadline: Number(built.declared.settlement_deadline),
1140
+ // The rest is what create_mission recorded, not what the funding response says: whether
1141
+ // this is a lottery decides whether a co-signer is required at all.
1142
+ lottery_winner_count: Number(mission.lottery_winner_count ?? 0),
1143
+ lottery_prize_per_winner_raw: String(mission.lottery_prize_per_winner_raw ?? "0"),
1144
+ qualify_deadline: Number(mission.qualify_deadline),
1145
+ settlement_deadline: Number(mission.settlement_deadline),
1146
+ seed_commit: mission.seed_commit,
1147
+ max_fee_bps: quotedFeeBps,
1162
1148
  mint,
1163
- sponsor: wallet.publicKey
1149
+ sponsor: wallet.publicKey,
1150
+ sponsor_token_account: tokenAccount
1164
1151
  },
1165
- expected_program_id: cfg.program_id
1152
+ expected_program_id: pin.program_id,
1153
+ onchain_config: onchainConfig,
1154
+ onchain_config_error: onchainConfigError
1166
1155
  });
1167
1156
  if (!verdict.ok) {
1168
1157
  return {
1169
1158
  funded: false,
1170
1159
  refused_to_sign: true,
1171
1160
  problems: verdict.problems,
1161
+ warnings: verdict.warnings,
1172
1162
  summary: verdict.summary,
1163
+ fee_quote_source: feeQuoteSource,
1173
1164
  what_this_means: "The transaction does not match what you asked for, so it was NOT signed and nothing was escrowed. This is the verifier doing its job. Do not retry blindly \u2014 the discrepancy above is either a bug or an attempt to have you authorise something else."
1174
1165
  };
1175
1166
  }
@@ -1180,22 +1171,49 @@ async function handleSolanaTool(name, args) {
1180
1171
  verified: true,
1181
1172
  task_id: built.task_id,
1182
1173
  summary: verdict.summary,
1174
+ warnings: verdict.warnings,
1175
+ fee_quote_source: feeQuoteSource,
1183
1176
  would_escrow: `${args.expected_budget} (${expectedBudgetRaw} raw) of ${mint}`
1184
1177
  };
1185
1178
  }
1186
1179
  const signed = await signTransaction(built.transaction_base64, wallet);
1187
- const confirmed = await apiPost2(
1188
- `/agent/solana/missions/${args.mission_id}/confirm-funding`,
1189
- { signed_transaction: signed, task_id: built.task_id }
1190
- );
1191
- return { funded: true, verified: true, ...confirmed };
1180
+ let confirmed;
1181
+ try {
1182
+ confirmed = await apiPost2(`/agent/solana/missions/${args.mission_id}/confirm-funding`, {
1183
+ signed_transaction: signed,
1184
+ task_id: built.task_id
1185
+ });
1186
+ } catch (e) {
1187
+ const { escrowErrorFromMessage } = await import("./escrowErrors-4XSAYLGT.js");
1188
+ const body = apiErrorBody(e);
1189
+ const message = body.message ?? e?.message ?? String(e);
1190
+ return {
1191
+ funded: false,
1192
+ verified: true,
1193
+ signed: true,
1194
+ status: e?.status,
1195
+ message,
1196
+ program_error: escrowErrorFromMessage(message) ?? void 0,
1197
+ ...body.funding_mismatch ? { funding_mismatch: body.funding_mismatch } : {},
1198
+ ...body.task_id ? { task_id: body.task_id } : {}
1199
+ };
1200
+ }
1201
+ return {
1202
+ funded: true,
1203
+ verified: true,
1204
+ lottery: verdict.summary.lottery,
1205
+ operator_cosigned: verdict.summary.operator_signature === "valid",
1206
+ max_fee_bps: verdict.summary.max_fee_bps,
1207
+ ...confirmed
1208
+ };
1192
1209
  }
1193
1210
  case "refund_solana_mission": {
1194
- const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
1211
+ const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-LX7SI5RE.js");
1212
+ const { escrowErrorFromMessage } = await import("./escrowErrors-4XSAYLGT.js");
1195
1213
  const wallet = await loadSolanaWallet();
1196
1214
  const cfg = await apiGet2("/agent/solana/config");
1197
- const mission = await apiGet2(`/agent/missions/${args.mission_id}`);
1198
- const mint = mission.mission?.token_address;
1215
+ const { mission } = await apiGet2(`/agent/missions/${args.mission_id}`);
1216
+ const mint = mission?.token_address;
1199
1217
  if (!mint) {
1200
1218
  return {
1201
1219
  refunded: false,
@@ -1203,6 +1221,11 @@ async function handleSolanaTool(name, args) {
1203
1221
  };
1204
1222
  }
1205
1223
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
1224
+ const deadlines = {
1225
+ qualify_deadline: mission?.qualify_deadline,
1226
+ settlement_deadline: mission?.settlement_deadline,
1227
+ ...mission?.qualify_deadline !== void 0 ? { cancel_cutoff: new Date(mission.qualify_deadline * 1e3).toISOString() } : {}
1228
+ };
1206
1229
  if (!args.action) {
1207
1230
  try {
1208
1231
  await apiPost2(`/agent/solana/missions/${args.mission_id}/refund-transaction`, {
@@ -1212,31 +1235,69 @@ async function handleSolanaTool(name, args) {
1212
1235
  });
1213
1236
  return { available_actions: ["claim_refund"], note: "claim_refund is available now" };
1214
1237
  } catch (e) {
1215
- const body = e?.data ?? e?.response?.data ?? e?.body ?? {};
1238
+ const body = apiErrorBody(e);
1216
1239
  return {
1217
1240
  available_actions: body.available_actions ?? [],
1218
1241
  why_not_claim_refund: body.message,
1219
- note: (body.available_actions?.length ?? 0) === 0 ? "Nothing is refundable right now. cancel needs the hiring window still open; emergency_refund needs the settlement deadline to have passed." : "Re-run with one of available_actions."
1242
+ ...deadlines,
1243
+ note: (body.available_actions?.length ?? 0) === 0 ? `Nothing is refundable right now. ${CANCEL_CUTOFF_RULE}` : `Re-run with one of available_actions. ${CANCEL_CUTOFF_RULE}`
1220
1244
  };
1221
1245
  }
1222
1246
  }
1223
- const built = await apiPost2(
1224
- `/agent/solana/missions/${args.mission_id}/refund-transaction`,
1225
- {
1247
+ const nowSec = Math.floor(Date.now() / 1e3);
1248
+ if (args.action === "cancel" && typeof mission?.qualify_deadline === "number" && nowSec >= mission.qualify_deadline) {
1249
+ return {
1250
+ refunded: false,
1251
+ rejected_by: "client",
1252
+ action: "cancel",
1253
+ message: `qualify_deadline (${deadlines.cancel_cutoff}) has passed by this machine's clock, so the escrow program would refuse this cancel (TooLateToCancel). Nothing was built or signed.`,
1254
+ ...deadlines,
1255
+ what_to_do: CANCEL_CUTOFF_RULE
1256
+ };
1257
+ }
1258
+ const pin = resolvePinnedProgram(cfg);
1259
+ if ("problem" in pin) {
1260
+ return {
1261
+ refunded: false,
1262
+ refused_to_sign: true,
1263
+ problems: [pin.problem],
1264
+ what_this_means: "Nothing was built, signed or moved."
1265
+ };
1266
+ }
1267
+ let built;
1268
+ try {
1269
+ built = await apiPost2(
1270
+ `/agent/solana/missions/${args.mission_id}/refund-transaction`,
1271
+ {
1272
+ action: args.action,
1273
+ sponsor_wallet: wallet.publicKey,
1274
+ sponsor_token_account: tokenAccount
1275
+ }
1276
+ );
1277
+ } catch (e) {
1278
+ const body = apiErrorBody(e);
1279
+ const message = body.message ?? e?.message ?? String(e);
1280
+ return {
1281
+ refunded: false,
1282
+ rejected_by: "backend",
1226
1283
  action: args.action,
1227
- sponsor_wallet: wallet.publicKey,
1228
- sponsor_token_account: tokenAccount
1229
- }
1230
- );
1231
- const { Transaction, PublicKey } = await import("@solana/web3.js");
1284
+ status: e?.status,
1285
+ message,
1286
+ available_actions: body.available_actions ?? [],
1287
+ program_error: escrowErrorFromMessage(message) ?? void 0,
1288
+ ...deadlines,
1289
+ what_to_do: CANCEL_CUTOFF_RULE
1290
+ };
1291
+ }
1292
+ const { Transaction } = await import("@solana/web3.js");
1232
1293
  const tx = Transaction.from(Buffer.from(built.transaction_base64, "base64"));
1233
1294
  const problems = [];
1234
1295
  if (tx.instructions.length !== 1) {
1235
1296
  problems.push(`expected 1 instruction, found ${tx.instructions.length}`);
1236
1297
  }
1237
1298
  const ix = tx.instructions[0];
1238
- if (ix?.programId?.toBase58() !== cfg.program_id) {
1239
- problems.push(`program is ${ix?.programId?.toBase58()}, expected ${cfg.program_id}`);
1299
+ if (ix?.programId?.toBase58() !== pin.program_id) {
1300
+ problems.push(`program is ${ix?.programId?.toBase58()}, expected ${pin.program_id}`);
1240
1301
  }
1241
1302
  const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
1242
1303
  if (signers.length !== 1 || signers[0] !== wallet.publicKey) {
@@ -1251,7 +1312,6 @@ async function handleSolanaTool(name, args) {
1251
1312
  if ((ix?.data?.length ?? 0) !== 8) {
1252
1313
  problems.push(`instruction data is ${ix?.data?.length} bytes, expected 8`);
1253
1314
  }
1254
- void PublicKey;
1255
1315
  if (problems.length > 0) {
1256
1316
  return {
1257
1317
  refunded: false,
@@ -1267,14 +1327,33 @@ async function handleSolanaTool(name, args) {
1267
1327
  verified: true,
1268
1328
  action: args.action,
1269
1329
  task_id: built.task_id,
1270
- destination: tokenAccount
1330
+ destination: tokenAccount,
1331
+ ...built.cancel_deadline ? { cancel_deadline: built.cancel_deadline } : {}
1271
1332
  };
1272
1333
  }
1273
1334
  const signed = await signTransaction(built.transaction_base64, wallet);
1274
- const confirmed = await apiPost2(
1275
- `/agent/solana/missions/${args.mission_id}/confirm-refund`,
1276
- { signed_transaction: signed, action: args.action }
1277
- );
1335
+ let confirmed;
1336
+ try {
1337
+ confirmed = await apiPost2(`/agent/solana/missions/${args.mission_id}/confirm-refund`, {
1338
+ signed_transaction: signed,
1339
+ action: args.action
1340
+ });
1341
+ } catch (e) {
1342
+ const body = apiErrorBody(e);
1343
+ const message = body.message ?? e?.message ?? String(e);
1344
+ const programError = escrowErrorFromMessage(message);
1345
+ return {
1346
+ refunded: false,
1347
+ rejected_by: programError || /transaction failed/i.test(message) ? "chain" : "backend",
1348
+ action: args.action,
1349
+ status: e?.status,
1350
+ message,
1351
+ program_error: programError ?? void 0,
1352
+ available_actions: body.available_actions,
1353
+ ...deadlines,
1354
+ what_to_do: programError?.what_to_do ?? CANCEL_CUTOFF_RULE
1355
+ };
1356
+ }
1278
1357
  return { refunded: true, verified: true, ...confirmed };
1279
1358
  }
1280
1359
  default: