@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
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";
@@ -19,7 +19,7 @@ import {
19
19
  var RESPONSE_SCHEMA_PROPERTY = {
20
20
  type: "array",
21
21
  maxItems: 20,
22
- description: 'Structured completion questions a human answers to complete the mission. Question ids must be unique within one schema \u2014 a duplicate id is rejected at creation/update, not merged or silently overwritten. When set, finalize_qualification auto-derives qualified UIDs from completion instead of requiring an explicit qualified_human_uids list \u2014 same mechanism media_review has always used for track ratings, generalized to any mission type. media_review defaults to a canonical two-question schema when this is omitted at creation \u2014 literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", required: false } \u2014 so read/append against those exact ids if you rely on the default rather than supplying your own schema. Every other type has no default (stays fully manual/chat-based unless you set this).',
22
+ description: 'Structured completion questions a human answers to complete the mission. Question ids must be unique within one schema \u2014 a duplicate id is rejected at creation/update, not merged or silently overwritten. When set, finalize_qualification auto-derives qualified UIDs from completion instead of requiring an explicit qualified_human_uids list \u2014 same mechanism media_review has always used for track ratings, generalized to any mission type. media_review defaults to a canonical two-question schema when this is omitted at creation \u2014 literally { id: "rating", kind: "stars", required: true } and { id: "comment", kind: "long", required: true } \u2014 so read/append against those exact ids if you rely on the default rather than supplying your own schema. Every other type has no default (stays fully manual/chat-based unless you set this).',
23
23
  items: {
24
24
  type: "object",
25
25
  properties: {
@@ -43,7 +43,10 @@ var RESPONSE_SCHEMA_PROPERTY = {
43
43
  },
44
44
  label: { type: "string", description: "Question text shown to the human (max 200 chars). For the checkbox kind this is the checkbox's own clickable text, not a separate heading." },
45
45
  help: { type: "string", description: "Optional helper text shown under the label (max 500 chars)." },
46
- required: { type: "boolean", description: "Whether this question must be answered to complete the mission." },
46
+ required: {
47
+ type: "boolean",
48
+ description: "Whether this question must be answered to complete the mission. A required: false question does NOT block mission completion or finalize_qualification's auto-qualification \u2014 both derive purely from the required questions, so an optional question left blank (or still being typed into) will not stop either from firing the instant the required ones are answered. If every question in the schema should actually be answered, mark them all required: true; reserve required: false for a genuinely optional/skippable field."
49
+ },
47
50
  options: {
48
51
  type: "array",
49
52
  items: { type: "string" },
@@ -58,7 +61,7 @@ var RESPONSE_SCHEMA_PROPERTY = {
58
61
  var missionTools = [
59
62
  {
60
63
  name: "create_mission",
61
- 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.",
62
65
  inputSchema: {
63
66
  type: "object",
64
67
  properties: {
@@ -69,8 +72,8 @@ var missionTools = [
69
72
  },
70
73
  chain: {
71
74
  type: "string",
72
- enum: ["base", "solana"],
73
- 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)."
74
77
  },
75
78
  title: { type: "string", description: "Mission title (max 100 chars)" },
76
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." },
@@ -87,14 +90,14 @@ var missionTools = [
87
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." },
88
91
  max_participants: { type: "number", minimum: 1, description: "Maximum number of participants" },
89
92
  expires_in_hours: { type: "number", minimum: 1, description: "Hours until mission expires" },
90
- 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." },
91
94
  max_humans: { type: "number", minimum: 1, description: "On-chain: maximum number of participants (both base-reward and lottery entrants)." },
92
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." },
93
96
  reward_per_human: { type: "number", minimum: 0, description: "Deprecated \u2014 use base_reward instead." },
94
- 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)." },
95
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." },
96
- 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." },
97
- 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." },
98
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)." },
99
102
  response_schema: RESPONSE_SCHEMA_PROPERTY
100
103
  },
@@ -140,21 +143,9 @@ var missionTools = [
140
143
  required: ["mission_id"]
141
144
  }
142
145
  },
143
- {
144
- name: "confirm_funding",
145
- 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.`,
146
- inputSchema: {
147
- type: "object",
148
- properties: {
149
- mission_id: { type: "string" },
150
- tx_hash: { type: "string", description: "Transaction hash of createTask() call (optional)" }
151
- },
152
- required: ["mission_id"]
153
- }
154
- },
155
146
  {
156
147
  name: "hire_participant",
157
- 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.",
158
149
  inputSchema: {
159
150
  type: "object",
160
151
  properties: {
@@ -166,7 +157,7 @@ var missionTools = [
166
157
  },
167
158
  {
168
159
  name: "reject_participant",
169
- 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.",
170
161
  inputSchema: {
171
162
  type: "object",
172
163
  properties: {
@@ -178,7 +169,7 @@ var missionTools = [
178
169
  },
179
170
  {
180
171
  name: "finalize_qualification",
181
- 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.",
182
173
  inputSchema: {
183
174
  type: "object",
184
175
  properties: {
@@ -194,7 +185,7 @@ var missionTools = [
194
185
  },
195
186
  {
196
187
  name: "settle_mission",
197
- 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.",
198
189
  inputSchema: {
199
190
  type: "object",
200
191
  properties: {
@@ -205,7 +196,7 @@ var missionTools = [
205
196
  },
206
197
  {
207
198
  name: "cancel_mission",
208
- 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).",
209
200
  inputSchema: {
210
201
  type: "object",
211
202
  properties: {
@@ -214,78 +205,9 @@ var missionTools = [
214
205
  required: ["mission_id"]
215
206
  }
216
207
  },
217
- {
218
- name: "get_cancel_params",
219
- 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.",
220
- inputSchema: {
221
- type: "object",
222
- properties: {
223
- mission_id: { type: "string" }
224
- },
225
- required: ["mission_id"]
226
- }
227
- },
228
- {
229
- name: "confirm_cancel",
230
- description: "After executing cancelTask() on EscrowVault, confirm the cancellation on the SOLO platform. Mission transitions to cancelled.",
231
- inputSchema: {
232
- type: "object",
233
- properties: {
234
- mission_id: { type: "string" },
235
- tx_hash: { type: "string", description: "Transaction hash of cancelTask() (optional)" }
236
- },
237
- required: ["mission_id"]
238
- }
239
- },
240
- {
241
- name: "get_emergency_refund_params",
242
- 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.",
243
- inputSchema: {
244
- type: "object",
245
- properties: {
246
- mission_id: { type: "string" }
247
- },
248
- required: ["mission_id"]
249
- }
250
- },
251
- {
252
- name: "confirm_emergency_refund",
253
- description: "After executing emergencyRefund() on EscrowVault, confirm on the SOLO platform. Mission transitions to cancelled.",
254
- inputSchema: {
255
- type: "object",
256
- properties: {
257
- mission_id: { type: "string" },
258
- tx_hash: { type: "string", description: "Transaction hash of emergencyRefund() (optional)" }
259
- },
260
- required: ["mission_id"]
261
- }
262
- },
263
- {
264
- name: "get_refund_params",
265
- 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).",
266
- inputSchema: {
267
- type: "object",
268
- properties: {
269
- mission_id: { type: "string" }
270
- },
271
- required: ["mission_id"]
272
- }
273
- },
274
- {
275
- name: "confirm_refund",
276
- description: "After executing claimRefund() on EscrowVault, confirm the refund on the SOLO platform. Mission transitions to refunded.",
277
- inputSchema: {
278
- type: "object",
279
- properties: {
280
- mission_id: { type: "string" },
281
- tx_hash: { type: "string", description: "Transaction hash of claimRefund() (optional)" }
282
- },
283
- required: ["mission_id"]
284
- }
285
- },
286
208
  {
287
209
  name: "rate_participant",
288
- 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.",
289
211
  inputSchema: {
290
212
  type: "object",
291
213
  properties: {
@@ -300,8 +222,10 @@ var missionTools = [
300
222
  ];
301
223
  async function handleMissionTool(name, args) {
302
224
  switch (name) {
303
- case "create_mission":
304
- 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
+ }
305
229
  case "list_missions": {
306
230
  const params = {};
307
231
  if (args.status) params.status = args.status;
@@ -313,8 +237,6 @@ async function handleMissionTool(name, args) {
313
237
  return apiGet(`/agent/missions/${args.mission_id}`);
314
238
  case "update_mission_questions":
315
239
  return apiPatch(`/agent/missions/${args.mission_id}/questions`, { response_schema: args.response_schema });
316
- case "confirm_funding":
317
- return apiPost(`/agent/missions/${args.mission_id}/confirm-funding`, { tx_hash: args.tx_hash });
318
240
  case "hire_participant":
319
241
  return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/hire`);
320
242
  case "reject_participant":
@@ -327,18 +249,6 @@ async function handleMissionTool(name, args) {
327
249
  return apiPost(`/agent/missions/${args.mission_id}/settle`);
328
250
  case "cancel_mission":
329
251
  return apiPost(`/agent/missions/${args.mission_id}/cancel`);
330
- case "get_cancel_params":
331
- return apiGet(`/agent/missions/${args.mission_id}/cancel-params`);
332
- case "confirm_cancel":
333
- return apiPost(`/agent/missions/${args.mission_id}/confirm-cancel`, { tx_hash: args.tx_hash });
334
- case "get_emergency_refund_params":
335
- return apiGet(`/agent/missions/${args.mission_id}/emergency-refund-params`);
336
- case "confirm_emergency_refund":
337
- return apiPost(`/agent/missions/${args.mission_id}/confirm-emergency-refund`, { tx_hash: args.tx_hash });
338
- case "get_refund_params":
339
- return apiGet(`/agent/missions/${args.mission_id}/refund-params`);
340
- case "confirm_refund":
341
- return apiPost(`/agent/missions/${args.mission_id}/confirm-refund`, { tx_hash: args.tx_hash });
342
252
  case "rate_participant":
343
253
  return apiPost(`/agent/missions/${args.mission_id}/participants/${args.uid}/comment`, {
344
254
  rating: args.rating,
@@ -879,7 +789,7 @@ import { readFile } from "fs/promises";
879
789
  var trackTools = [
880
790
  {
881
791
  name: "add_mission_track",
882
- 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.",
883
793
  inputSchema: {
884
794
  type: "object",
885
795
  properties: {
@@ -1002,12 +912,12 @@ async function handleTrackTool(name, args) {
1002
912
  var solanaTools = [
1003
913
  {
1004
914
  name: "get_solana_config",
1005
- 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.",
1006
916
  inputSchema: { type: "object", properties: {} }
1007
917
  },
1008
918
  {
1009
919
  name: "get_solana_wallet",
1010
- 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.",
1011
921
  inputSchema: {
1012
922
  type: "object",
1013
923
  properties: {
@@ -1020,18 +930,23 @@ var solanaTools = [
1020
930
  },
1021
931
  {
1022
932
  name: "fund_solana_mission",
1023
- 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.",
1024
934
  inputSchema: {
1025
935
  type: "object",
1026
936
  properties: {
1027
- 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." },
1028
938
  expected_budget: {
1029
939
  type: "number",
1030
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."
1031
941
  },
1032
942
  expected_mint: {
1033
943
  type: "string",
1034
- 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."
1035
950
  },
1036
951
  dry_run: {
1037
952
  type: "boolean",
@@ -1043,7 +958,7 @@ var solanaTools = [
1043
958
  },
1044
959
  {
1045
960
  name: "refund_solana_mission",
1046
- 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.",
1047
962
  inputSchema: {
1048
963
  type: "object",
1049
964
  properties: {
@@ -1076,19 +991,25 @@ function toRawAmount(amount, decimals) {
1076
991
  return (whole + frac.padEnd(decimals, "0")).replace(/^0+(?=\d)/, "");
1077
992
  }
1078
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.";
1079
998
  async function handleSolanaTool(name, args) {
1080
- 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");
1081
1001
  switch (name) {
1082
1002
  case "get_solana_config":
1083
1003
  return apiGet2("/agent/solana/config");
1084
1004
  case "get_solana_wallet": {
1085
- const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-V4T4NTCM.js");
1005
+ const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress, KEY_STORAGE_GUIDANCE } = await import("./wallet-LX7SI5RE.js");
1086
1006
  if (!hasSolanaWallet()) {
1087
1007
  return {
1088
1008
  configured: false,
1089
1009
  how_to_configure: {
1090
- option_1: "SOLO_SOLANA_KEYPAIR \u2014 JSON byte array, as `solana-keygen new` writes it",
1091
- 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
1092
1013
  },
1093
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."
1094
1015
  };
@@ -1099,7 +1020,7 @@ async function handleSolanaTool(name, args) {
1099
1020
  const mint = args.mint ?? cfg.mints[symbol];
1100
1021
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
1101
1022
  const rpc = async (method, params) => {
1102
- const res = await fetch(cfg.rpc_url, {
1023
+ const res = await fetch(solanaRpcUrl(cfg), {
1103
1024
  method: "POST",
1104
1025
  headers: { "Content-Type": "application/json" },
1105
1026
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params })
@@ -1128,23 +1049,87 @@ async function handleSolanaTool(name, args) {
1128
1049
  };
1129
1050
  }
1130
1051
  case "fund_solana_mission": {
1131
- const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-V4T4NTCM.js");
1132
- 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");
1133
1054
  const wallet = await loadSolanaWallet();
1134
1055
  const cfg = await apiGet2("/agent/solana/config");
1135
- const symbol = Object.keys(cfg.mints)[0];
1136
- const mint = args.expected_mint ?? cfg.mints[symbol];
1137
- 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
+ }
1138
1101
  const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
1139
1102
  const built = await apiPost2(
1140
1103
  `/agent/solana/missions/${args.mission_id}/funding-transaction`,
1141
1104
  { sponsor_wallet: wallet.publicKey, sponsor_token_account: tokenAccount }
1142
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
+ }
1143
1125
  const expectedBudgetRaw = toRawAmount(args.expected_budget, decimals);
1144
1126
  const verdict = await verifyFundingTransaction({
1145
1127
  transaction_base64: built.transaction_base64,
1128
+ escrow_interface: built.escrow_interface,
1129
+ task_id: String(built.task_id),
1146
1130
  declared: built.declared,
1147
1131
  accounts: built.accounts,
1132
+ message_sha256: built.message_sha256,
1148
1133
  expected: {
1149
1134
  budget_raw: expectedBudgetRaw,
1150
1135
  // base_pool is derived by the backend from reward × max_humans. The agent's check on it
@@ -1152,21 +1137,30 @@ async function handleSolanaTool(name, args) {
1152
1137
  // asserting a locally recomputed figure would require duplicating that arithmetic here
1153
1138
  // and would fail on a legitimately rounded reward.
1154
1139
  base_pool_raw: String(built.declared.base_pool),
1155
- lottery_winner_count: Number(built.declared.lottery_winner_count),
1156
- lottery_prize_per_winner_raw: String(built.declared.lottery_prize_per_winner),
1157
- qualify_deadline: Number(built.declared.qualify_deadline),
1158
- 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,
1159
1148
  mint,
1160
- sponsor: wallet.publicKey
1149
+ sponsor: wallet.publicKey,
1150
+ sponsor_token_account: tokenAccount
1161
1151
  },
1162
- expected_program_id: cfg.program_id
1152
+ expected_program_id: pin.program_id,
1153
+ onchain_config: onchainConfig,
1154
+ onchain_config_error: onchainConfigError
1163
1155
  });
1164
1156
  if (!verdict.ok) {
1165
1157
  return {
1166
1158
  funded: false,
1167
1159
  refused_to_sign: true,
1168
1160
  problems: verdict.problems,
1161
+ warnings: verdict.warnings,
1169
1162
  summary: verdict.summary,
1163
+ fee_quote_source: feeQuoteSource,
1170
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."
1171
1165
  };
1172
1166
  }
@@ -1177,22 +1171,49 @@ async function handleSolanaTool(name, args) {
1177
1171
  verified: true,
1178
1172
  task_id: built.task_id,
1179
1173
  summary: verdict.summary,
1174
+ warnings: verdict.warnings,
1175
+ fee_quote_source: feeQuoteSource,
1180
1176
  would_escrow: `${args.expected_budget} (${expectedBudgetRaw} raw) of ${mint}`
1181
1177
  };
1182
1178
  }
1183
1179
  const signed = await signTransaction(built.transaction_base64, wallet);
1184
- const confirmed = await apiPost2(
1185
- `/agent/solana/missions/${args.mission_id}/confirm-funding`,
1186
- { signed_transaction: signed, task_id: built.task_id }
1187
- );
1188
- 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
+ };
1189
1209
  }
1190
1210
  case "refund_solana_mission": {
1191
- 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");
1192
1213
  const wallet = await loadSolanaWallet();
1193
1214
  const cfg = await apiGet2("/agent/solana/config");
1194
- const mission = await apiGet2(`/agent/missions/${args.mission_id}`);
1195
- const mint = mission.mission?.token_address;
1215
+ const { mission } = await apiGet2(`/agent/missions/${args.mission_id}`);
1216
+ const mint = mission?.token_address;
1196
1217
  if (!mint) {
1197
1218
  return {
1198
1219
  refunded: false,
@@ -1200,6 +1221,11 @@ async function handleSolanaTool(name, args) {
1200
1221
  };
1201
1222
  }
1202
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
+ };
1203
1229
  if (!args.action) {
1204
1230
  try {
1205
1231
  await apiPost2(`/agent/solana/missions/${args.mission_id}/refund-transaction`, {
@@ -1209,31 +1235,69 @@ async function handleSolanaTool(name, args) {
1209
1235
  });
1210
1236
  return { available_actions: ["claim_refund"], note: "claim_refund is available now" };
1211
1237
  } catch (e) {
1212
- const body = e?.data ?? e?.response?.data ?? e?.body ?? {};
1238
+ const body = apiErrorBody(e);
1213
1239
  return {
1214
1240
  available_actions: body.available_actions ?? [],
1215
1241
  why_not_claim_refund: body.message,
1216
- 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}`
1217
1244
  };
1218
1245
  }
1219
1246
  }
1220
- const built = await apiPost2(
1221
- `/agent/solana/missions/${args.mission_id}/refund-transaction`,
1222
- {
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",
1223
1283
  action: args.action,
1224
- sponsor_wallet: wallet.publicKey,
1225
- sponsor_token_account: tokenAccount
1226
- }
1227
- );
1228
- 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");
1229
1293
  const tx = Transaction.from(Buffer.from(built.transaction_base64, "base64"));
1230
1294
  const problems = [];
1231
1295
  if (tx.instructions.length !== 1) {
1232
1296
  problems.push(`expected 1 instruction, found ${tx.instructions.length}`);
1233
1297
  }
1234
1298
  const ix = tx.instructions[0];
1235
- if (ix?.programId?.toBase58() !== cfg.program_id) {
1236
- 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}`);
1237
1301
  }
1238
1302
  const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
1239
1303
  if (signers.length !== 1 || signers[0] !== wallet.publicKey) {
@@ -1248,7 +1312,6 @@ async function handleSolanaTool(name, args) {
1248
1312
  if ((ix?.data?.length ?? 0) !== 8) {
1249
1313
  problems.push(`instruction data is ${ix?.data?.length} bytes, expected 8`);
1250
1314
  }
1251
- void PublicKey;
1252
1315
  if (problems.length > 0) {
1253
1316
  return {
1254
1317
  refunded: false,
@@ -1264,14 +1327,33 @@ async function handleSolanaTool(name, args) {
1264
1327
  verified: true,
1265
1328
  action: args.action,
1266
1329
  task_id: built.task_id,
1267
- destination: tokenAccount
1330
+ destination: tokenAccount,
1331
+ ...built.cancel_deadline ? { cancel_deadline: built.cancel_deadline } : {}
1268
1332
  };
1269
1333
  }
1270
1334
  const signed = await signTransaction(built.transaction_base64, wallet);
1271
- const confirmed = await apiPost2(
1272
- `/agent/solana/missions/${args.mission_id}/confirm-refund`,
1273
- { signed_transaction: signed, action: args.action }
1274
- );
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
+ }
1275
1357
  return { refunded: true, verified: true, ...confirmed };
1276
1358
  }
1277
1359
  default: