@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.
- package/DEVELOPER_README.md +1 -1
- package/README.md +96 -12
- package/dist/chunk-NUGGTARW.js +196 -0
- package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
- package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
- package/dist/deployment-UCUWVIPJ.js +43 -0
- package/dist/escrowErrors-4XSAYLGT.js +56 -0
- package/dist/index.js +244 -162
- package/dist/verify-7EUVRUAS.js +445 -0
- package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
- package/dist/wire-RLDY4AJR.js +24 -0
- package/package.json +1 -1
- package/src/config.ts +11 -0
- package/src/index.ts +1 -1
- package/src/scripts/check-tools-against-spec.ts +3 -3
- package/src/solana/deployment.test.ts +61 -0
- package/src/solana/deployment.ts +89 -0
- package/src/solana/escrowErrors.ts +78 -0
- package/src/solana/fixtures/config-account.json +6 -0
- package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
- package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
- package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
- package/src/solana/verify.test.ts +605 -78
- package/src/solana/verify.ts +545 -84
- package/src/solana/wallet.test.ts +85 -2
- package/src/solana/wallet.ts +74 -31
- package/src/solana/wire.test.ts +129 -0
- package/src/solana/wire.ts +264 -0
- package/src/tools/missions.ts +56 -119
- package/src/tools/solana.test.ts +356 -0
- package/src/tools/solana.ts +309 -70
- package/src/tools/tracks.ts +1 -1
- package/dist/verify-KAETIGV5.js +0 -136
- /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
|
+
})
|