@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
@@ -0,0 +1,356 @@
1
+ /**
2
+ * The Solana tool handlers, with the API mocked and the RPC stubbed, driven by the real v2
3
+ * funding responses in `src/solana/fixtures`. What matters here is the flow around the verifier:
4
+ * which quote max_fee_bps is held to, that nothing is signed or submitted on a refusal, that a
5
+ * lottery's co-signature survives signing, and that a refused refund says who refused it and why.
6
+ */
7
+ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'
8
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { PublicKey, Transaction, TransactionInstruction } from '@solana/web3.js'
12
+
13
+ const apiPost = vi.fn()
14
+ const apiGet = vi.fn()
15
+ vi.mock('../api/client.js', () => ({
16
+ apiPost: (...a: unknown[]) => apiPost(...a),
17
+ apiGet: (...a: unknown[]) => apiGet(...a),
18
+ }))
19
+
20
+ import { handleSolanaTool } from './solana.js'
21
+ import { decryptOpenSslAes256CbcPbkdf2 } from '../solana/wallet.js'
22
+ import { ed25519Verify, parseWireTransaction } from '../solana/wire.js'
23
+ import { DISCRIMINATORS } from '../solana/verify.js'
24
+
25
+ const FIX = join(__dirname, '..', 'solana', 'fixtures')
26
+ const load = (f: string) => JSON.parse(readFileSync(join(FIX, f), 'utf8'))
27
+ const lottery = load('funding-transaction-v2-lottery.json')
28
+ const plain = load('funding-transaction-v2-plain.json')
29
+ const configFx = load('config-account.json')
30
+ const PROGRAM_ID: string = lottery.program_id
31
+ const MINT: string = lottery.mint
32
+
33
+ const apiError = (status: number, data: Record<string, unknown>) =>
34
+ Object.assign(new Error(String(data.message)), { status, data })
35
+
36
+ let dir: string
37
+ let savedPath: string | undefined
38
+ beforeAll(() => {
39
+ dir = mkdtempSync(join(tmpdir(), 'solo-solana-tools-'))
40
+ const keyfile = join(dir, 'id.json')
41
+ writeFileSync(
42
+ keyfile,
43
+ decryptOpenSslAes256CbcPbkdf2(readFileSync(join(FIX, 'test-keypair.json.enc')), 'test-only-fixture-passphrase'),
44
+ )
45
+ savedPath = process.env.SOLO_SOLANA_KEYPAIR_PATH
46
+ process.env.SOLO_SOLANA_KEYPAIR_PATH = keyfile
47
+ })
48
+ afterAll(() => {
49
+ if (savedPath === undefined) delete process.env.SOLO_SOLANA_KEYPAIR_PATH
50
+ else process.env.SOLO_SOLANA_KEYPAIR_PATH = savedPath
51
+ rmSync(dir, { recursive: true, force: true })
52
+ })
53
+
54
+ let config: Record<string, unknown>
55
+ let mission: Record<string, unknown>
56
+ let rpcFails: boolean
57
+ // An initialized 6-decimal SPL mint account (82 bytes: decimals at 44, is_initialized at 45).
58
+ const mintData = (() => { const b = Buffer.alloc(82); b[44] = 6; b[45] = 1; return b.toString('base64') })()
59
+ beforeEach(() => {
60
+ apiGet.mockReset()
61
+ apiPost.mockReset()
62
+ rpcFails = false
63
+ config = {
64
+ cluster: 'devnet',
65
+ program_id: PROGRAM_ID,
66
+ rpc_url: 'https://rpc.example',
67
+ mints: { TEST_USDC: MINT },
68
+ decimals: { TEST_USDC: 6 },
69
+ escrow_interface: 'v2',
70
+ }
71
+ apiGet.mockImplementation(async (path: string) => {
72
+ if (path === '/agent/solana/config') return config
73
+ if (path.startsWith('/agent/missions/')) return { success: true, mission }
74
+ throw new Error(`unexpected GET ${path}`)
75
+ })
76
+ vi.stubGlobal(
77
+ 'fetch',
78
+ vi.fn(async (_url: string, init: { body: string }) => {
79
+ const { method, params } = JSON.parse(init.body)
80
+ if (rpcFails) throw new Error('fetch failed')
81
+ if (method !== 'getAccountInfo') throw new Error(`unexpected RPC ${method}`)
82
+ if (params[0] !== configFx.address) {
83
+ return { json: async () => ({ result: { value: { owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', data: [mintData, 'base64'] } } }) }
84
+ }
85
+ return {
86
+ json: async () => ({
87
+ result: { value: { owner: configFx.owner, data: [configFx.data_base64, 'base64'] } },
88
+ }),
89
+ }
90
+ }),
91
+ )
92
+ })
93
+ afterEach(() => vi.unstubAllGlobals())
94
+
95
+ describe('fund_solana_mission', () => {
96
+ const serve = (fx: any, response = fx.response) => {
97
+ mission = { ...fx.mission }
98
+ apiPost.mockImplementation(async (path: string, body: any) => {
99
+ if (path.endsWith('/funding-transaction')) return structuredClone(response)
100
+ if (path.endsWith('/confirm-funding')) return { task_id: body.task_id, status: 'active', signature: 'sig' }
101
+ throw new Error(`unexpected POST ${path}`)
102
+ })
103
+ }
104
+ const submitted = () => apiPost.mock.calls.filter(([p]) => String(p).endsWith('/confirm-funding'))
105
+
106
+ it('verifies, signs and submits a co-signed lottery, keeping the Operator signature', async () => {
107
+ serve(lottery)
108
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT })
109
+ expect(r).toMatchObject({ funded: true, verified: true, lottery: true, operator_cosigned: true, max_fee_bps: 0 })
110
+ const signed = parseWireTransaction(Buffer.from(submitted()[0][1].signed_transaction, 'base64'))
111
+ const original = parseWireTransaction(Buffer.from(lottery.response.transaction_base64, 'base64'))
112
+ expect(signed.message.equals(original.message)).toBe(true)
113
+ expect(signed.signatures[1].equals(original.signatures[1])).toBe(true)
114
+ expect(ed25519Verify(signed.signatures[0], signed.message, lottery.sponsor)).toBe(true)
115
+ expect(ed25519Verify(signed.signatures[1], signed.message, signed.accountKeys[1])).toBe(true)
116
+ })
117
+
118
+ it('funds a non-lottery mission', async () => {
119
+ serve(plain)
120
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT })
121
+ expect(r).toMatchObject({ funded: true, lottery: false, operator_cosigned: false })
122
+ })
123
+
124
+ it("defaults the expected mint to the mission's own, not the first mint the config lists", async () => {
125
+ config.mints = { USDC: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', TEST_USDC: MINT }
126
+ serve(plain)
127
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, dry_run: true })
128
+ expect(r).toMatchObject({ verified: true, would_escrow: `0.01 (10000 raw) of ${MINT}` })
129
+ const fundingCall = apiPost.mock.calls.find(([p]) => String(p).endsWith('/funding-transaction'))!
130
+ expect(fundingCall[1].sponsor_token_account).toBe(plain.response.accounts.sponsor_token_account)
131
+ })
132
+
133
+ it('still refuses when expected_mint names a different mint than the transaction uses', async () => {
134
+ serve(plain)
135
+ const r: any = await handleSolanaTool('fund_solana_mission', {
136
+ mission_id: 'm1', expected_budget: 0.01, expected_mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', dry_run: true,
137
+ })
138
+ expect(r.refused_to_sign).toBe(true)
139
+ expect(r.problems.join(' ')).toMatch(/account #3 \(mint\) is ECXjKm8n/)
140
+ })
141
+
142
+ it('refuses before building anything when the deployment is not on the v2 interface', async () => {
143
+ config.escrow_interface = 'v1'
144
+ serve(plain)
145
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01 })
146
+ expect(r.refused_to_sign).toBe(true)
147
+ expect(r.problems[0]).toMatch(/verifies only 'v2'/)
148
+ expect(apiPost).not.toHaveBeenCalled()
149
+ })
150
+
151
+ it('refuses a tampered lottery transaction and never signs or submits it', async () => {
152
+ const bytes = Buffer.from(lottery.response.transaction_base64, 'base64')
153
+ bytes[bytes.length - 3] ^= 0xff // inside the seed commitment
154
+ serve(lottery, { ...lottery.response, transaction_base64: bytes.toString('base64') })
155
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT })
156
+ expect(r.refused_to_sign).toBe(true)
157
+ expect(r.problems.join(' ')).toMatch(/Operator signature does not verify/)
158
+ expect(submitted()).toHaveLength(0)
159
+ })
160
+
161
+ it('holds max_fee_bps to the fee create_mission quoted (mission.solana_quoted_fee_bps)', async () => {
162
+ serve(plain)
163
+ mission.solana_quoted_fee_bps = 25
164
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT })
165
+ expect(r.refused_to_sign).toBe(true)
166
+ expect(r.fee_quote_source).toMatch(/solana_quoted_fee_bps/)
167
+ expect(r.problems.join(' ')).toMatch(/expected the quoted fee rate 25/)
168
+ expect(submitted()).toHaveLength(0)
169
+ })
170
+
171
+ it('lets expected_max_fee_bps state the quote explicitly', async () => {
172
+ serve(plain)
173
+ mission.solana_quoted_fee_bps = 25
174
+ const r: any = await handleSolanaTool('fund_solana_mission', {
175
+ mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT, expected_max_fee_bps: 0, dry_run: true,
176
+ })
177
+ expect(r).toMatchObject({ dry_run: true, verified: true, fee_quote_source: 'expected_max_fee_bps argument' })
178
+ })
179
+
180
+ it('falls back to the on-chain fee for a mission with no recorded quote', async () => {
181
+ serve(plain)
182
+ delete mission.solana_quoted_fee_bps
183
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT, dry_run: true })
184
+ expect(r).toMatchObject({ verified: true })
185
+ expect(r.fee_quote_source).toMatch(/on-chain Config\.fee_bps/)
186
+ })
187
+
188
+ it('refuses a lottery when Config cannot be read from the cluster', async () => {
189
+ serve(lottery)
190
+ const real = globalThis.fetch as any
191
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init: { body: string }) =>
192
+ JSON.parse(init.body).params[0] === MINT ? real(url, init) : Promise.reject(new Error('fetch failed'))))
193
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT })
194
+ expect(r.refused_to_sign).toBe(true)
195
+ expect(r.problems.join(' ')).toMatch(/Config\.operator could not be read/)
196
+ expect(submitted()).toHaveLength(0)
197
+ })
198
+
199
+ it('refuses, before building anything, a program id the API reports but this server does not pin', async () => {
200
+ config.program_id = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
201
+ serve(plain)
202
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT })
203
+ expect(r.refused_to_sign).toBe(true)
204
+ expect(r.problems[0]).toMatch(/but this server pins 2CPC5V63/)
205
+ expect(apiPost).not.toHaveBeenCalled()
206
+ })
207
+
208
+ it('refuses an unpinned cluster unless both the program and the RPC are pinned locally', async () => {
209
+ config.cluster = 'localnet'
210
+ serve(plain)
211
+ const args = { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT, dry_run: true }
212
+ const r: any = await handleSolanaTool('fund_solana_mission', args)
213
+ expect(r.problems.join(' ')).toMatch(/no escrow program is pinned for cluster "localnet"/)
214
+ process.env.SOLO_SOLANA_PROGRAM_ID = PROGRAM_ID
215
+ try {
216
+ // The API's rpc_url could serve a fake mint with other decimals: not used.
217
+ const noRpc: any = await handleSolanaTool('fund_solana_mission', args)
218
+ expect(noRpc.problems).toEqual([expect.stringMatching(/set SOLO_SOLANA_RPC_URL/)])
219
+ expect(apiPost).not.toHaveBeenCalled()
220
+ process.env.SOLO_SOLANA_RPC_URL = 'https://my.rpc'
221
+ const ok: any = await handleSolanaTool('fund_solana_mission', args)
222
+ expect(ok).toMatchObject({ dry_run: true, verified: true })
223
+ expect((globalThis.fetch as any).mock.calls.every(([url]: [string]) => url === 'https://my.rpc')).toBe(true)
224
+ } finally {
225
+ delete process.env.SOLO_SOLANA_PROGRAM_ID
226
+ delete process.env.SOLO_SOLANA_RPC_URL
227
+ }
228
+ })
229
+
230
+ it('scales expected_budget by the decimals on chain, not the ones the API reports', async () => {
231
+ // The API claims 9 decimals: trusting it would turn "0.01" into 10,000,000 raw units and
232
+ // refuse the genuine 10,000-unit transaction (or accept a 1000x larger one).
233
+ config.decimals = { TEST_USDC: 9 }
234
+ serve(plain)
235
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT, dry_run: true })
236
+ expect(r).toMatchObject({ verified: true, would_escrow: `0.01 (10000 raw) of ${MINT}` })
237
+ })
238
+
239
+ it('refuses when the mint decimals cannot be read', async () => {
240
+ rpcFails = true
241
+ serve(plain)
242
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT })
243
+ expect(r.refused_to_sign).toBe(true)
244
+ expect(r.problems[0]).toMatch(/cannot read mint .* decimals from chain/)
245
+ expect(apiPost).not.toHaveBeenCalled()
246
+ })
247
+
248
+ it('names the program error when the chain rejects the funding', async () => {
249
+ serve(plain)
250
+ apiPost.mockImplementation(async (path: string) => {
251
+ if (path.endsWith('/funding-transaction')) return structuredClone(plain.response)
252
+ throw apiError(400, { message: 'funding transaction failed: ... custom program error: 0x17f4' })
253
+ })
254
+ const r: any = await handleSolanaTool('fund_solana_mission', { mission_id: 'm1', expected_budget: 0.01, expected_mint: MINT })
255
+ expect(r).toMatchObject({ funded: false, signed: true, program_error: { code: 6132, name: 'FeeAboveSponsorMax' } })
256
+ })
257
+ })
258
+
259
+ describe('refund_solana_mission', () => {
260
+ const now = Math.floor(Date.now() / 1000)
261
+ const sponsor = new PublicKey(lottery.sponsor)
262
+ const refundTx = (disc: readonly number[]) => {
263
+ const ata = PublicKey.findProgramAddressSync(
264
+ [sponsor.toBuffer(), new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA').toBuffer(), new PublicKey(MINT).toBuffer()],
265
+ new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'),
266
+ )[0]
267
+ const program = new PublicKey(PROGRAM_ID)
268
+ const k = (pubkey: PublicKey, isSigner = false, isWritable = false) => ({ pubkey, isSigner, isWritable })
269
+ const pda = (s: string) => PublicKey.findProgramAddressSync([Buffer.from(s)], program)[0]
270
+ const t = new Transaction({ feePayer: sponsor, recentBlockhash: Transaction.from(Buffer.from(plain.response.transaction_base64, 'base64')).recentBlockhash! })
271
+ t.add(new TransactionInstruction({
272
+ programId: program,
273
+ keys: [k(sponsor, true), k(pda('config')), k(pda('task-x'), false, true), k(pda('vault-x'), false, true), k(ata, false, true),
274
+ k(new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')), k(pda('__event_authority')), k(program)],
275
+ data: Buffer.from(disc),
276
+ }))
277
+ return t.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64')
278
+ }
279
+ const refundPosts = () => apiPost.mock.calls.filter(([p]) => String(p).endsWith('/refund-transaction'))
280
+
281
+ beforeEach(() => {
282
+ mission = { token_address: MINT, onchain_status: 'funded', qualify_deadline: now + 3600, settlement_deadline: now + 7200 }
283
+ })
284
+
285
+ it('refuses a cancel from qualify_deadline on without building or signing it', async () => {
286
+ mission.qualify_deadline = now - 1
287
+ const r: any = await handleSolanaTool('refund_solana_mission', { mission_id: 'm1', action: 'cancel' })
288
+ expect(r).toMatchObject({ refunded: false, rejected_by: 'client', action: 'cancel' })
289
+ expect(r.message).toMatch(/TooLateToCancel/)
290
+ expect(r.what_to_do).toMatch(/emergency_refund/)
291
+ expect(refundPosts()).toHaveLength(0)
292
+ })
293
+
294
+ it('surfaces a backend refusal with the actions that remain', async () => {
295
+ apiPost.mockRejectedValue(apiError(409, {
296
+ message: 'the qualify deadline has passed — cancel is no longer available',
297
+ available_actions: ['emergency_refund'],
298
+ }))
299
+ const r: any = await handleSolanaTool('refund_solana_mission', { mission_id: 'm1', action: 'cancel' })
300
+ expect(r).toMatchObject({ rejected_by: 'backend', status: 409, available_actions: ['emergency_refund'] })
301
+ expect(r.cancel_cutoff).toBe(new Date((now + 3600) * 1000).toISOString())
302
+ })
303
+
304
+ it('surfaces an on-chain TooLateToCancel from confirm-refund', async () => {
305
+ apiPost.mockImplementation(async (path: string) => {
306
+ if (path.endsWith('/refund-transaction')) return { action: 'cancel', task_id: '7', transaction_base64: refundTx(DISCRIMINATORS.cancel_task) }
307
+ throw apiError(409, {
308
+ message: "the escrow program rejects cancel from qualify_deadline on (TooLateToCancel). The mission settles normally, or use action: 'emergency_refund' after settlement_deadline.",
309
+ available_actions: ['emergency_refund'],
310
+ })
311
+ })
312
+ const r: any = await handleSolanaTool('refund_solana_mission', { mission_id: 'm1', action: 'cancel' })
313
+ expect(r).toMatchObject({
314
+ refunded: false,
315
+ rejected_by: 'chain',
316
+ program_error: { code: 6125, name: 'TooLateToCancel' },
317
+ available_actions: ['emergency_refund'],
318
+ })
319
+ })
320
+
321
+ it('names an early emergency_refund rejected on chain', async () => {
322
+ apiPost.mockImplementation(async (path: string) => {
323
+ if (path.endsWith('/refund-transaction')) return { action: 'emergency_refund', task_id: '7', transaction_base64: refundTx(DISCRIMINATORS.emergency_refund) }
324
+ throw apiError(400, { message: 'refund transaction failed: Error processing Instruction 0: custom program error: 0x1780' })
325
+ })
326
+ const r: any = await handleSolanaTool('refund_solana_mission', { mission_id: 'm1', action: 'emergency_refund' })
327
+ expect(r).toMatchObject({ rejected_by: 'chain', program_error: { name: 'TooEarlyForEmergencyRefund' } })
328
+ expect(r.what_to_do).toMatch(/settlement_deadline has passed/)
329
+ })
330
+
331
+ it('signs and confirms a verified refund', async () => {
332
+ apiPost.mockImplementation(async (path: string, body: any) => {
333
+ if (path.endsWith('/refund-transaction')) return { action: 'cancel', task_id: '7', transaction_base64: refundTx(DISCRIMINATORS.cancel_task) }
334
+ const t = parseWireTransaction(Buffer.from(body.signed_transaction, 'base64'))
335
+ expect(ed25519Verify(t.signatures[0], t.message, lottery.sponsor)).toBe(true)
336
+ return { action: 'cancel', signature: 'sig', onchain_status: 'cancelled' }
337
+ })
338
+ const r: any = await handleSolanaTool('refund_solana_mission', { mission_id: 'm1', action: 'cancel' })
339
+ expect(r).toMatchObject({ refunded: true, verified: true, onchain_status: 'cancelled' })
340
+ })
341
+
342
+ it('refuses a refund for a program the API reports but this server does not pin, before building it', async () => {
343
+ config.program_id = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
344
+ const r: any = await handleSolanaTool('refund_solana_mission', { mission_id: 'm1', action: 'cancel' })
345
+ expect(r.refused_to_sign).toBe(true)
346
+ expect(r.problems.join(' ')).toMatch(/this server pins/)
347
+ expect(refundPosts()).toHaveLength(0)
348
+ })
349
+
350
+ it('explains the cancel cut-off when asked what is available', async () => {
351
+ apiPost.mockRejectedValue(apiError(409, { message: "claim refund requires onchain_status 'refundable'", available_actions: ['cancel'] }))
352
+ const r: any = await handleSolanaTool('refund_solana_mission', { mission_id: 'm1' })
353
+ expect(r.available_actions).toEqual(['cancel'])
354
+ expect(r.note).toMatch(/refused on chain from qualify_deadline on/)
355
+ })
356
+ })