@projectsolo/solo-mission-mcp 0.21.13 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/DEVELOPER_README.md +1 -1
  2. package/README.md +96 -12
  3. package/dist/chunk-NUGGTARW.js +196 -0
  4. package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
  5. package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
  6. package/dist/deployment-UCUWVIPJ.js +43 -0
  7. package/dist/escrowErrors-4XSAYLGT.js +56 -0
  8. package/dist/index.js +244 -162
  9. package/dist/verify-7EUVRUAS.js +445 -0
  10. package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
  11. package/dist/wire-RLDY4AJR.js +24 -0
  12. package/package.json +1 -1
  13. package/src/config.ts +11 -0
  14. package/src/index.ts +1 -1
  15. package/src/scripts/check-tools-against-spec.ts +3 -3
  16. package/src/solana/deployment.test.ts +61 -0
  17. package/src/solana/deployment.ts +89 -0
  18. package/src/solana/escrowErrors.ts +78 -0
  19. package/src/solana/fixtures/config-account.json +6 -0
  20. package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
  21. package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
  22. package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
  23. package/src/solana/verify.test.ts +605 -78
  24. package/src/solana/verify.ts +545 -84
  25. package/src/solana/wallet.test.ts +85 -2
  26. package/src/solana/wallet.ts +74 -31
  27. package/src/solana/wire.test.ts +129 -0
  28. package/src/solana/wire.ts +264 -0
  29. package/src/tools/missions.ts +56 -119
  30. package/src/tools/solana.test.ts +356 -0
  31. package/src/tools/solana.ts +309 -70
  32. package/src/tools/tracks.ts +1 -1
  33. package/dist/verify-KAETIGV5.js +0 -136
  34. /package/src/solana/fixtures/{funding-transaction.json → funding-transaction-v1.json} +0 -0
@@ -1,156 +1,683 @@
1
1
  /**
2
2
  * The verifier is the one thing standing between an agent and signing bytes it cannot read, so it
3
- * is tested against a REAL transaction built by the deployed program's own backend rather than a
4
- * synthetic one. A hand-built fixture would encode my assumptions about the layout twice and agree
5
- * with itself.
3
+ * is tested against REAL transactions built by the backend's own v2 builders rather than synthetic
4
+ * ones. A hand-built fixture would encode my assumptions about the layout twice and agree with
5
+ * itself.
6
6
  *
7
- * `fixtures/funding-transaction.json` is captured from a devnet funding run.
7
+ * fixtures/funding-transaction-v2-lottery.json — co-signed by the devnet Operator
8
+ * fixtures/funding-transaction-v2-plain.json — no co-signer
9
+ * fixtures/config-account.json — the devnet Config those were built against
10
+ * fixtures/solo_escrow.v2.idl-excerpt.json — solo-escrow-solana u1.1.0's IDL, trimmed
11
+ * fixtures/funding-transaction-v1.json — a pre-upgrade transaction, which must be refused
12
+ *
13
+ * Adversarial variants are derived from the real transactions: same instruction, one thing changed.
8
14
  */
9
- import { describe, it, expect } from 'vitest'
15
+ import { describe, it, expect, vi, afterEach } from 'vitest'
10
16
  import { readFileSync } from 'fs'
11
17
  import { join } from 'path'
18
+ import { Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction } from '@solana/web3.js'
12
19
  import {
20
+ CREATE_TASK_V2_ACCOUNTS,
21
+ CREATE_TASK_V2_DATA_LENGTH,
22
+ DISCRIMINATORS,
23
+ decodeCreateTaskV2,
24
+ decodeEscrowConfig,
25
+ readEscrowConfig,
26
+ readMintDecimals,
13
27
  verifyFundingTransaction,
14
28
  type VerifyInput,
15
- type ExpectedFunding,
16
- type FundingAccounts,
17
29
  } from './verify'
30
+ import { ESCROW_ERRORS, escrowErrorFromMessage } from './escrowErrors'
31
+ import { parseWireTransaction } from './wire'
18
32
  import { toRawAmount } from '../tools/solana'
19
33
 
20
- const fx = JSON.parse(
21
- readFileSync(join(__dirname, 'fixtures', 'funding-transaction.json'), 'utf8'),
22
- ) as {
23
- transaction_base64: string
24
- declared: ExpectedFunding
25
- accounts: FundingAccounts
26
- program_id: string
27
- sponsor: string
28
- mint: string
29
- }
34
+ const FIX = join(__dirname, 'fixtures')
35
+ const load = (f: string) => JSON.parse(readFileSync(join(FIX, f), 'utf8'))
36
+ const lottery = load('funding-transaction-v2-lottery.json')
37
+ const plain = load('funding-transaction-v2-plain.json')
38
+ const v1 = load('funding-transaction-v1.json')
39
+ const idl = load('solo_escrow.v2.idl-excerpt.json')
40
+ const configFx = load('config-account.json')
41
+ const onchainConfig = decodeEscrowConfig(Buffer.from(configFx.data_base64, 'base64'), configFx.address)
42
+ const PROGRAM_ID: string = lottery.program_id
43
+ const OPERATOR = onchainConfig.operator
30
44
 
31
- const baseInput = (): VerifyInput => ({
32
- transaction_base64: fx.transaction_base64,
33
- declared: fx.declared,
34
- accounts: fx.accounts,
45
+ const inputFor = (fx: any): VerifyInput => ({
46
+ transaction_base64: fx.response.transaction_base64,
47
+ escrow_interface: fx.response.escrow_interface,
48
+ task_id: fx.response.task_id,
49
+ declared: structuredClone(fx.response.declared),
50
+ accounts: { ...fx.response.accounts },
51
+ message_sha256: fx.response.message_sha256,
35
52
  expected: {
36
- budget_raw: String(fx.declared.budget),
37
- base_pool_raw: String(fx.declared.base_pool),
38
- lottery_winner_count: Number(fx.declared.lottery_winner_count),
39
- lottery_prize_per_winner_raw: String(fx.declared.lottery_prize_per_winner),
40
- qualify_deadline: Number(fx.declared.qualify_deadline),
41
- settlement_deadline: Number(fx.declared.settlement_deadline),
53
+ budget_raw: fx.mission.budget_raw,
54
+ base_pool_raw: fx.response.declared.base_pool,
55
+ lottery_winner_count: fx.mission.lottery_winner_count ?? 0,
56
+ lottery_prize_per_winner_raw: fx.mission.lottery_prize_per_winner_raw ?? '0',
57
+ qualify_deadline: fx.mission.qualify_deadline,
58
+ settlement_deadline: fx.mission.settlement_deadline,
59
+ seed_commit: fx.mission.seed_commit,
60
+ max_fee_bps: fx.mission.solana_quoted_fee_bps,
42
61
  mint: fx.mint,
43
62
  sponsor: fx.sponsor,
63
+ sponsor_token_account: fx.response.accounts.sponsor_token_account,
44
64
  },
45
65
  expected_program_id: fx.program_id,
66
+ onchain_config: { ...onchainConfig },
46
67
  })
47
68
 
48
- describe('accepts a genuine transaction', () => {
49
- it('passes with no problems', async () => {
50
- const r = await verifyFundingTransaction(baseInput())
69
+ const raw = (fx: any) => Buffer.from(fx.response.transaction_base64, 'base64')
70
+
71
+ /**
72
+ * Rebuilds a fixture's transaction with one thing changed, the way a backend would have to in
73
+ * order to get it past the verifier: same instruction unless told otherwise.
74
+ */
75
+ function rebuild(
76
+ fx: any,
77
+ o: {
78
+ operatorSlot?: PublicKey | 'program'
79
+ coSigner?: Keypair
80
+ feePayer?: PublicKey
81
+ data?: Buffer
82
+ keys?: (keys: TransactionInstruction['keys']) => TransactionInstruction['keys']
83
+ extra?: TransactionInstruction
84
+ } = {},
85
+ ): string {
86
+ const t0 = Transaction.from(raw(fx))
87
+ const ix0 = t0.instructions[0]
88
+ let keys = ix0.keys.map((k) => ({ ...k }))
89
+ if (o.operatorSlot === 'program') {
90
+ keys[9] = { pubkey: new PublicKey(PROGRAM_ID), isSigner: false, isWritable: false }
91
+ } else if (o.operatorSlot) {
92
+ keys[9] = { pubkey: o.operatorSlot, isSigner: true, isWritable: false }
93
+ }
94
+ if (o.keys) keys = o.keys(keys)
95
+ const t = new Transaction({
96
+ feePayer: o.feePayer ?? new PublicKey(fx.sponsor),
97
+ recentBlockhash: t0.recentBlockhash!,
98
+ }).add(new TransactionInstruction({ programId: ix0.programId, keys, data: o.data ?? ix0.data }))
99
+ if (o.extra) t.add(o.extra)
100
+ if (o.coSigner) t.partialSign(o.coSigner)
101
+ return t.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64')
102
+ }
103
+
104
+ const problemsOf = async (input: VerifyInput) => {
105
+ const r = await verifyFundingTransaction(input)
106
+ return { ...r, text: r.problems.join('\n') }
107
+ }
108
+
109
+ // A key standing in for the Operator where a test needs a co-signature it controls.
110
+ const testOperator = Keypair.generate()
111
+ const asTestOperator = (input: VerifyInput): VerifyInput => ({
112
+ ...input,
113
+ onchain_config: { ...onchainConfig, operator: testOperator.publicKey.toBase58() },
114
+ message_sha256: undefined,
115
+ })
116
+
117
+ describe('the v2 interface, byte-checked against the IDL', () => {
118
+ const ixByName = (n: string) => idl.instructions.find((i: any) => i.name === n)
119
+
120
+ it('uses the IDL discriminators', () => {
121
+ for (const name of ['create_task', 'cancel_task', 'emergency_refund', 'claim_refund'] as const) {
122
+ expect(DISCRIMINATORS[name]).toEqual(ixByName(name).discriminator)
123
+ }
124
+ expect(DISCRIMINATORS.Config).toEqual(idl.accounts.find((a: any) => a.name === 'Config').discriminator)
125
+ })
126
+
127
+ it('lists create_task accounts in IDL order, with the optional operator signer after system_program', () => {
128
+ const accounts = ixByName('create_task').accounts
129
+ expect(accounts.map((a: any) => a.name)).toEqual([...CREATE_TASK_V2_ACCOUNTS])
130
+ const op = accounts[CREATE_TASK_V2_ACCOUNTS.indexOf('operator')]
131
+ expect(op).toMatchObject({ name: 'operator', signer: true, optional: true })
132
+ expect(accounts[CREATE_TASK_V2_ACCOUNTS.indexOf('operator') - 1].name).toBe('system_program')
133
+ })
134
+
135
+ // Borsh, driven by the IDL's own argument list — an independent encoder for the byte comparison.
136
+ const SIZE: Record<string, number> = { u16: 2, u32: 4, u64: 8, i64: 8 }
137
+ const encodeFromIdl = (values: Record<string, string | number>) => {
138
+ const parts: Buffer[] = [Buffer.from(ixByName('create_task').discriminator)]
139
+ for (const arg of ixByName('create_task').args) {
140
+ const v = values[arg.name]
141
+ if (typeof arg.type === 'object') {
142
+ expect(arg.type).toEqual({ array: ['u8', 32] })
143
+ parts.push(Buffer.from(String(v).replace(/^0x/, ''), 'hex'))
144
+ continue
145
+ }
146
+ const b = Buffer.alloc(SIZE[arg.type])
147
+ if (arg.type === 'u16') b.writeUInt16LE(Number(v))
148
+ else if (arg.type === 'u32') b.writeUInt32LE(Number(v))
149
+ else if (arg.type === 'u64') b.writeBigUInt64LE(BigInt(v))
150
+ else if (arg.type === 'i64') b.writeBigInt64LE(BigInt(v))
151
+ else throw new Error(`unhandled IDL type ${arg.type}`)
152
+ parts.push(b)
153
+ }
154
+ return Buffer.concat(parts)
155
+ }
156
+
157
+ it('has max_fee_bps: u16 as the trailing create_task argument, 86 bytes in all', () => {
158
+ const args = ixByName('create_task').args
159
+ expect(args[args.length - 1]).toEqual({ name: 'max_fee_bps', type: 'u16' })
160
+ const len = 8 + args.reduce((n: number, a: any) => n + (typeof a.type === 'object' ? 32 : SIZE[a.type]), 0)
161
+ expect(len).toBe(CREATE_TASK_V2_DATA_LENGTH)
162
+ expect(CREATE_TASK_V2_DATA_LENGTH).toBe(86)
163
+ })
164
+
165
+ for (const [label, fx] of [['lottery', lottery], ['non-lottery', plain]] as const) {
166
+ it(`the backend's ${label} instruction data is exactly the IDL encoding of what it declared`, () => {
167
+ const data = parseWireTransaction(raw(fx)).instructions[0].data
168
+ expect(data.equals(encodeFromIdl(fx.response.declared))).toBe(true)
169
+ const decoded = decodeCreateTaskV2(data)
170
+ expect(decoded).toEqual({
171
+ budget: fx.response.declared.budget,
172
+ base_pool: fx.response.declared.base_pool,
173
+ lottery_winner_count: fx.response.declared.lottery_winner_count,
174
+ lottery_prize_per_winner: fx.response.declared.lottery_prize_per_winner,
175
+ qualify_deadline: fx.response.declared.qualify_deadline,
176
+ settlement_deadline: fx.response.declared.settlement_deadline,
177
+ seed_commit: fx.response.declared.seed_commit,
178
+ max_fee_bps: fx.response.declared.max_fee_bps,
179
+ })
180
+ })
181
+ }
182
+
183
+ it('decodes Config at the offsets the IDL field order gives, and the snapshot names the fixture co-signer', () => {
184
+ const fields = idl.types.find((t: any) => t.name === 'Config').type.fields.map((f: any) => f.name)
185
+ expect(fields.slice(0, 8)).toEqual([
186
+ 'admin', 'operator', 'guardian', 'treasury', 'fee_bps', 'min_review_window', 'paused', 'task_counter',
187
+ ])
188
+ // 8 + 4×32 = 136 (fee_bps), +2 = 138 (min_review_window), +8 = 146 (paused), +1 = 147 (task_counter).
189
+ const data = Buffer.from(configFx.data_base64, 'base64')
190
+ expect(onchainConfig.operator).toBe(new PublicKey(data.subarray(40, 72)).toBase58())
191
+ expect(onchainConfig.fee_bps).toBe(data.readUInt16LE(136))
192
+ expect(onchainConfig.task_counter).toBe(data.readBigUInt64LE(147).toString())
193
+ const tx = parseWireTransaction(raw(lottery))
194
+ expect(tx.accountKeys[tx.instructions[0].accountIndexes[9]]).toBe(OPERATOR)
195
+ })
196
+
197
+ it('refuses to decode a Config without the Config discriminator', () => {
198
+ const data = Buffer.from(configFx.data_base64, 'base64')
199
+ data[0] ^= 0xff
200
+ expect(() => decodeEscrowConfig(data, configFx.address)).toThrow(/discriminator/)
201
+ })
202
+
203
+ it('confirms the refund instructions kept the layout refund_solana_mission verifies', () => {
204
+ // No arguments (so the data is the bare 8-byte discriminator), the sponsor as the only signer,
205
+ // and the sponsor token account as the destination — identical to v1.
206
+ for (const name of ['cancel_task', 'emergency_refund', 'claim_refund']) {
207
+ const i = ixByName(name)
208
+ expect(i.args).toEqual([])
209
+ expect(i.accounts.map((a: any) => a.name)).toEqual([
210
+ 'sponsor', 'config', 'task', 'vault', 'sponsor_token_account', 'token_program', 'event_authority', 'program',
211
+ ])
212
+ expect(i.accounts.filter((a: any) => a.signer).map((a: any) => a.name)).toEqual(['sponsor'])
213
+ }
214
+ })
215
+
216
+ it('names program errors by the IDL codes', () => {
217
+ for (const [code, info] of Object.entries(ESCROW_ERRORS)) {
218
+ expect(idl.errors.find((e: any) => e.code === Number(code))?.name).toBe(info.name)
219
+ }
220
+ expect(escrowErrorFromMessage('Error processing Instruction 0: custom program error: 0x17ed')?.name).toBe('TooLateToCancel')
221
+ expect(escrowErrorFromMessage('Program log: AnchorError ... Error Number: 6132.')?.name).toBe('FeeAboveSponsorMax')
222
+ expect(escrowErrorFromMessage('custom program error: 0x17ec')?.name).toBe('LotteryRequiresOperator')
223
+ expect(escrowErrorFromMessage('nothing to see')).toBeNull()
224
+ })
225
+ })
226
+
227
+ describe('readEscrowConfig', () => {
228
+ afterEach(() => vi.unstubAllGlobals())
229
+ const ok = (owner = configFx.owner) => ({
230
+ status: 200,
231
+ json: async () => ({ result: { value: { owner, data: [configFx.data_base64, 'base64'] } } }),
232
+ })
233
+
234
+ it('reads Config at the PDA derived from the pinned program id', async () => {
235
+ const fetchMock = vi.fn(async () => ok())
236
+ vi.stubGlobal('fetch', fetchMock)
237
+ const cfg = await readEscrowConfig('https://rpc.example', PROGRAM_ID)
238
+ expect(cfg.operator).toBe(OPERATOR)
239
+ expect(cfg.address).toBe(configFx.address)
240
+ expect(JSON.parse((fetchMock.mock.calls[0] as any)[1].body).params[0]).toBe(configFx.address)
241
+ })
242
+
243
+ it('retries a rate-limited RPC, then succeeds', async () => {
244
+ const fetchMock = vi
245
+ .fn()
246
+ .mockResolvedValueOnce({ status: 429, json: async () => ({}) })
247
+ .mockResolvedValueOnce({ status: 200, json: async () => ({ error: { message: 'Connection rate limits exceeded' } }) })
248
+ .mockResolvedValueOnce(ok())
249
+ vi.stubGlobal('fetch', fetchMock)
250
+ expect((await readEscrowConfig('https://rpc.example', PROGRAM_ID, { backoffMs: 0 })).operator).toBe(OPERATOR)
251
+ expect(fetchMock).toHaveBeenCalledTimes(3)
252
+ })
253
+
254
+ it('gives up after the last attempt', async () => {
255
+ vi.stubGlobal('fetch', vi.fn(async () => ({ status: 429, json: async () => ({}) })))
256
+ await expect(readEscrowConfig('https://rpc.example', PROGRAM_ID, { backoffMs: 0 })).rejects.toThrow(/HTTP 429/)
257
+ })
258
+
259
+ it('refuses a Config account not owned by the program', async () => {
260
+ vi.stubGlobal('fetch', vi.fn(async () => ok('11111111111111111111111111111111')))
261
+ await expect(readEscrowConfig('https://rpc.example', PROGRAM_ID)).rejects.toThrow(/not the program/)
262
+ })
263
+
264
+ it('does not retry an RPC error that is not a rate limit', async () => {
265
+ const fetchMock = vi.fn(async () => ({ status: 200, json: async () => ({ error: { message: 'Invalid param' } }) }))
266
+ vi.stubGlobal('fetch', fetchMock)
267
+ await expect(readEscrowConfig('https://rpc.example', PROGRAM_ID, { backoffMs: 0 })).rejects.toThrow(/Invalid param/)
268
+ expect(fetchMock).toHaveBeenCalledTimes(1)
269
+ })
270
+ })
271
+
272
+ describe('readMintDecimals', () => {
273
+ afterEach(() => vi.unstubAllGlobals())
274
+ const mint = (owner: string, decimals: number, initialized = 1, len = 82) => {
275
+ const b = Buffer.alloc(len)
276
+ b[44] = decimals
277
+ b[45] = initialized
278
+ return { status: 200, json: async () => ({ result: { value: { owner, data: [b.toString('base64'), 'base64'] } } }) }
279
+ }
280
+ const TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
281
+
282
+ it('reads decimals from an initialized SPL Token mint', async () => {
283
+ vi.stubGlobal('fetch', vi.fn(async () => mint(TOKEN, 6)))
284
+ expect(await readMintDecimals('https://rpc.example', lottery.mint)).toBe(6)
285
+ })
286
+
287
+ it('refuses an account that is not an SPL Token mint', async () => {
288
+ vi.stubGlobal('fetch', vi.fn(async () => mint('11111111111111111111111111111111', 6)))
289
+ await expect(readMintDecimals('https://rpc.example', lottery.mint)).rejects.toThrow(/not the SPL Token program/)
290
+ vi.stubGlobal('fetch', vi.fn(async () => mint(TOKEN, 6, 0)))
291
+ await expect(readMintDecimals('https://rpc.example', lottery.mint)).rejects.toThrow(/not an initialized/)
292
+ vi.stubGlobal('fetch', vi.fn(async () => mint(TOKEN, 6, 1, 165)))
293
+ await expect(readMintDecimals('https://rpc.example', lottery.mint)).rejects.toThrow(/not an initialized/)
294
+ })
295
+ })
296
+
297
+ describe('accepts genuine v2 transactions', () => {
298
+ it('accepts the backend-built lottery transaction with a valid Operator co-signature', async () => {
299
+ const r = await problemsOf(inputFor(lottery))
300
+ expect(r.problems).toEqual([])
301
+ expect(r.ok).toBe(true)
302
+ expect(r.summary).toMatchObject({
303
+ lottery: true,
304
+ operator: OPERATOR,
305
+ operator_signature: 'valid',
306
+ max_fee_bps: 0,
307
+ signers: [lottery.sponsor, OPERATOR],
308
+ fee_payer: lottery.sponsor,
309
+ message_sha256: lottery.response.message_sha256,
310
+ })
311
+ })
312
+
313
+ it('accepts the backend-built non-lottery transaction, with the program id in the operator slot', async () => {
314
+ const r = await problemsOf(inputFor(plain))
315
+ expect(r.problems).toEqual([])
316
+ expect(r.summary).toMatchObject({ lottery: false, operator_signature: 'not_required', signers: [plain.sponsor] })
317
+ })
318
+
319
+ it('accepts a non-lottery transaction without reading Config', async () => {
320
+ const input = inputFor(plain)
321
+ input.onchain_config = null
322
+ input.onchain_config_error = 'RPC down'
323
+ expect((await problemsOf(input)).problems).toEqual([])
324
+ })
325
+
326
+ it('accepts any valid co-signature by the key Config names as Operator', async () => {
327
+ const input = asTestOperator(inputFor(lottery))
328
+ input.transaction_base64 = rebuild(lottery, { operatorSlot: testOperator.publicKey, coSigner: testOperator })
329
+ const r = await problemsOf(input)
51
330
  expect(r.problems).toEqual([])
331
+ expect(r.summary.operator_signature).toBe('valid')
332
+ })
333
+
334
+ it('warns, without refusing, when the live fee is above the ceiling or the program is paused', async () => {
335
+ const input = inputFor(plain)
336
+ input.onchain_config = { ...onchainConfig, fee_bps: 50, paused: true }
337
+ const r = await problemsOf(input)
52
338
  expect(r.ok).toBe(true)
339
+ expect(r.warnings.join(' ')).toMatch(/FeeAboveSponsorMax/)
340
+ expect(r.warnings.join(' ')).toMatch(/paused/)
341
+ })
342
+ })
343
+
344
+ describe('lottery co-signature refusals', () => {
345
+ it('refuses a lottery transaction whose Operator signature is missing', async () => {
346
+ const bytes = raw(lottery)
347
+ const tx = parseWireTransaction(bytes)
348
+ const opIndex = tx.accountKeys.indexOf(OPERATOR)
349
+ bytes.fill(0, tx.signatureOffset + 64 * opIndex, tx.signatureOffset + 64 * (opIndex + 1))
350
+ const input = inputFor(lottery)
351
+ input.transaction_base64 = bytes.toString('base64')
352
+ const r = await problemsOf(input)
353
+ expect(r.ok).toBe(false)
354
+ expect(r.summary.operator_signature).toBe('missing')
355
+ expect(r.text).toMatch(/co-signature is missing/)
356
+ })
357
+
358
+ it('refuses a lottery transaction with no co-signer at all (program id in the operator slot)', async () => {
359
+ const input = inputFor(lottery)
360
+ input.transaction_base64 = rebuild(lottery, { operatorSlot: 'program' })
361
+ input.message_sha256 = undefined
362
+ const r = await problemsOf(input)
363
+ expect(r.ok).toBe(false)
364
+ expect(r.text).toMatch(/Config\.operator/)
365
+ expect(r.text).toMatch(/not a signer/)
53
366
  })
54
367
 
55
- it('reports what it actually saw, for logging even on success', async () => {
56
- const r = await verifyFundingTransaction(baseInput())
57
- expect(r.summary.program_id).toBe(fx.program_id)
58
- expect(r.summary.instruction_count).toBe(1)
59
- expect(r.summary.signers).toEqual([fx.sponsor])
368
+ it('refuses a co-signature by a key that is not the on-chain Config.operator', async () => {
369
+ const input = inputFor(lottery)
370
+ input.transaction_base64 = rebuild(lottery, { operatorSlot: testOperator.publicKey, coSigner: testOperator })
371
+ input.message_sha256 = undefined
372
+ const r = await problemsOf(input)
373
+ expect(r.ok).toBe(false)
374
+ expect(r.text).toMatch(new RegExp(`on-chain Config\\.operator is ${OPERATOR}`))
375
+ })
376
+
377
+ it('refuses the real co-signature when Config names a different Operator', async () => {
378
+ const input = inputFor(lottery)
379
+ input.onchain_config = { ...onchainConfig, operator: testOperator.publicKey.toBase58() }
380
+ const r = await problemsOf(input)
381
+ expect(r.ok).toBe(false)
382
+ expect(r.text).toMatch(/Config\.operator/)
383
+ })
384
+
385
+ it('refuses a lottery when Config cannot be read, rather than trusting the co-signer', async () => {
386
+ const input = inputFor(lottery)
387
+ input.onchain_config = null
388
+ input.onchain_config_error = 'fetch failed'
389
+ const r = await problemsOf(input)
390
+ expect(r.ok).toBe(false)
391
+ expect(r.text).toMatch(/could not be read \(fetch failed\)/)
392
+ })
393
+
394
+ it('refuses a message altered after co-signing, even when the description was altered to match', async () => {
395
+ // A consistent lie: the budget in the bytes, the declared budget and the agent's expectation
396
+ // all say 20000. Only the Operator's signature over the original bytes catches it.
397
+ const bytes = raw(lottery)
398
+ const tx = parseWireTransaction(bytes)
399
+ const dataAt = bytes.length - tx.instructions[0].data.length
400
+ bytes.writeBigUInt64LE(20_000n, dataAt + 8)
401
+ const input = inputFor(lottery)
402
+ input.transaction_base64 = bytes.toString('base64')
403
+ input.declared.budget = '20000'
404
+ input.expected.budget_raw = '20000'
405
+ input.message_sha256 = undefined
406
+ const r = await problemsOf(input)
407
+ expect(r.ok).toBe(false)
408
+ expect(r.problems).toEqual([expect.stringMatching(/Operator signature does not verify over this message/)])
409
+ expect(r.summary.operator_signature).toBe('invalid')
410
+ })
411
+
412
+ it('refuses a message whose hash is not the one the backend recorded', async () => {
413
+ const bytes = raw(lottery)
414
+ const tx = parseWireTransaction(bytes)
415
+ // One byte of the recent blockhash: harmless-looking, and it breaks the co-signature too.
416
+ bytes[tx.signatureOffset + 64 * tx.signatures.length + 3 + 1 + 32 * tx.accountKeys.length] ^= 1
417
+ const input = inputFor(lottery)
418
+ input.transaction_base64 = bytes.toString('base64')
419
+ const r = await problemsOf(input)
420
+ expect(r.text).toMatch(/message_sha256/)
421
+ expect(r.text).toMatch(/does not verify/)
422
+ })
423
+
424
+ it('refuses a lottery where the Operator, not the sponsor, pays the fee', async () => {
425
+ const input = asTestOperator(inputFor(lottery))
426
+ input.transaction_base64 = rebuild(lottery, {
427
+ operatorSlot: testOperator.publicKey,
428
+ coSigner: testOperator,
429
+ feePayer: testOperator.publicKey,
430
+ })
431
+ const r = await problemsOf(input)
432
+ expect(r.ok).toBe(false)
433
+ expect(r.text).toMatch(/fee payer is \w+ \(the Operator\), expected the sponsor/)
434
+ expect(r.summary.operator_signature).toBe('valid') // a valid co-signature does not excuse it
435
+ })
436
+
437
+ it('refuses a lottery the mission never asked for (non-lottery expected)', async () => {
438
+ const input = inputFor(lottery)
439
+ input.expected.lottery_winner_count = 0
440
+ input.expected.lottery_prize_per_winner_raw = '0'
441
+ const r = await problemsOf(input)
442
+ expect(r.ok).toBe(false)
443
+ expect(r.text).toMatch(/lottery_winner_count/)
444
+ expect(r.text).toMatch(/operator slot holds/)
445
+ })
446
+ })
447
+
448
+ describe('non-lottery refusals', () => {
449
+ it('refuses a stray Operator in a non-lottery transaction, even unsigned', async () => {
450
+ const input = inputFor(plain)
451
+ input.transaction_base64 = rebuild(plain, { operatorSlot: new PublicKey(OPERATOR) })
452
+ const r = await problemsOf(input)
453
+ expect(r.ok).toBe(false)
454
+ expect(r.text).toMatch(/must pass the program id/)
455
+ expect(r.text).toMatch(/only signer/)
456
+ })
457
+
458
+ it('refuses a stray co-signed Operator in a non-lottery transaction', async () => {
459
+ const input = inputFor(plain)
460
+ input.transaction_base64 = rebuild(plain, { operatorSlot: testOperator.publicKey, coSigner: testOperator })
461
+ const r = await problemsOf(input)
462
+ expect(r.ok).toBe(false)
463
+ expect(r.text).toMatch(/must pass the program id/)
464
+ })
465
+
466
+ it('refuses a lottery the backend quietly turned into a non-lottery', async () => {
467
+ const input = inputFor(plain)
468
+ input.expected.lottery_winner_count = 1
469
+ const r = await problemsOf(input)
470
+ expect(r.ok).toBe(false)
471
+ expect(r.text).toMatch(/lottery_winner_count/)
472
+ })
473
+ })
474
+
475
+ describe('max_fee_bps must equal the quote', () => {
476
+ it('refuses when the quote differs from what is encoded', async () => {
477
+ const input = inputFor(plain)
478
+ input.expected.max_fee_bps = 25
479
+ const r = await problemsOf(input)
480
+ expect(r.ok).toBe(false)
481
+ expect(r.text).toMatch(/max_fee_bps is 0, expected the quoted fee rate 25/)
482
+ })
483
+
484
+ it('refuses a raised ceiling even when the response declares it', async () => {
485
+ const data = Buffer.from(parseWireTransaction(raw(plain)).instructions[0].data)
486
+ data.writeUInt16LE(1000, 84)
487
+ const input = inputFor(plain)
488
+ input.transaction_base64 = rebuild(plain, { data })
489
+ input.declared.max_fee_bps = 1000
490
+ const r = await problemsOf(input)
491
+ expect(r.ok).toBe(false)
492
+ expect(r.text).toMatch(/encodes max_fee_bps=1000, not the quoted fee rate 0/)
493
+ })
494
+
495
+ it('refuses a raised ceiling on a co-signed lottery — a valid co-signature does not make it acceptable', async () => {
496
+ const data = Buffer.from(parseWireTransaction(raw(lottery)).instructions[0].data)
497
+ data.writeUInt16LE(1000, 84)
498
+ const input = asTestOperator(inputFor(lottery))
499
+ input.transaction_base64 = rebuild(lottery, { operatorSlot: testOperator.publicKey, coSigner: testOperator, data })
500
+ input.declared.max_fee_bps = 1000
501
+ const r = await problemsOf(input)
502
+ expect(r.ok).toBe(false)
503
+ expect(r.summary.operator_signature).toBe('valid')
504
+ expect(r.text).toMatch(/max_fee_bps/)
505
+ })
506
+
507
+ it('refuses a declared ceiling that disagrees with the bytes', async () => {
508
+ const input = inputFor(plain)
509
+ input.declared.max_fee_bps = 7
510
+ input.expected.max_fee_bps = 7
511
+ const r = await problemsOf(input)
512
+ expect(r.ok).toBe(false)
513
+ expect(r.text).toMatch(/described one thing and built another/)
514
+ })
515
+
516
+ it('refuses when there is no quote to compare against', async () => {
517
+ const input = inputFor(plain)
518
+ input.expected.max_fee_bps = undefined
519
+ expect((await problemsOf(input)).text).toMatch(/no quoted fee rate/)
520
+ })
521
+
522
+ it('refuses a response that does not declare max_fee_bps', async () => {
523
+ const input = inputFor(plain)
524
+ delete input.declared.max_fee_bps
525
+ expect((await problemsOf(input)).text).toMatch(/does not declare max_fee_bps/)
60
526
  })
61
527
  })
62
528
 
63
529
  describe('refuses when the agent expected something else', () => {
64
530
  it('catches an altered budget', async () => {
65
- const input = baseInput()
531
+ const input = inputFor(plain)
66
532
  input.expected.budget_raw = '999999999'
67
- const r = await verifyFundingTransaction(input)
533
+ const r = await problemsOf(input)
68
534
  expect(r.ok).toBe(false)
69
- expect(r.problems.join(' ')).toMatch(/budget/)
535
+ expect(r.text).toMatch(/budget/)
70
536
  })
71
537
 
72
538
  it('catches a substituted mint', async () => {
73
- const input = baseInput()
539
+ const input = inputFor(plain)
74
540
  input.expected.mint = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'
75
- const r = await verifyFundingTransaction(input)
541
+ const r = await problemsOf(input)
76
542
  expect(r.ok).toBe(false)
77
- expect(r.problems.join(' ')).toMatch(/mint/)
543
+ expect(r.text).toMatch(/mint/)
78
544
  })
79
545
 
80
546
  it('catches a different program, even when every parameter matches', async () => {
81
- const input = baseInput()
547
+ const input = inputFor(plain)
82
548
  input.expected_program_id = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
83
- const r = await verifyFundingTransaction(input)
549
+ const r = await problemsOf(input)
84
550
  expect(r.ok).toBe(false)
85
- expect(r.problems.join(' ')).toMatch(/program/)
551
+ expect(r.text).toMatch(/program/)
86
552
  })
87
553
 
88
- it('catches a sponsor who is not the signer', async () => {
89
- const input = baseInput()
554
+ it('catches a sponsor who is not the signer or fee payer', async () => {
555
+ const input = inputFor(plain)
90
556
  input.expected.sponsor = '4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi'
91
- const r = await verifyFundingTransaction(input)
557
+ const r = await problemsOf(input)
92
558
  expect(r.ok).toBe(false)
93
- expect(r.problems.join(' ')).toMatch(/signer|fee payer/i)
559
+ expect(r.text).toMatch(/fee payer/)
560
+ expect(r.text).toMatch(/only signer/)
561
+ })
562
+
563
+ it('catches a different seed commitment than the mission recorded', async () => {
564
+ const input = inputFor(lottery)
565
+ input.expected.seed_commit = '0x' + 'ab'.repeat(32)
566
+ expect((await problemsOf(input)).text).toMatch(/seed_commit/)
94
567
  })
95
568
 
96
569
  it('reports every problem, not just the first', async () => {
97
- // An agent debugging this wants the whole list, not one at a time.
98
- const input = baseInput()
570
+ const input = inputFor(plain)
99
571
  input.expected.budget_raw = '1'
100
572
  input.expected.mint = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'
101
- const r = await verifyFundingTransaction(input)
573
+ const r = await problemsOf(input)
102
574
  expect(r.problems.length).toBeGreaterThan(1)
103
575
  })
104
576
  })
105
577
 
106
578
  describe('refuses when the backend quoted one thing and built another', () => {
107
579
  it('catches a declared budget that disagrees with the encoded bytes', async () => {
108
- // The strongest check: everything else compares the backend's JSON against the agent's
109
- // expectations, which a lying backend controls both halves of. This compares the JSON against
110
- // the instruction data actually being signed.
111
- const input = baseInput()
112
- input.declared = { ...fx.declared, budget: '1' }
580
+ const input = inputFor(plain)
581
+ input.declared.budget = '1'
113
582
  input.expected.budget_raw = '1'
114
- const r = await verifyFundingTransaction(input)
583
+ const r = await problemsOf(input)
115
584
  expect(r.ok).toBe(false)
116
- expect(r.problems.join(' ')).toMatch(/described one thing and built another/)
585
+ expect(r.text).toMatch(/described one thing and built another/)
117
586
  })
118
587
 
119
588
  it('catches a declared deadline that disagrees with the bytes', async () => {
120
- const input = baseInput()
121
- input.declared = { ...fx.declared, settlement_deadline: '1' }
589
+ const input = inputFor(plain)
590
+ input.declared.settlement_deadline = '1'
122
591
  input.expected.settlement_deadline = 1
123
- const r = await verifyFundingTransaction(input)
592
+ const r = await problemsOf(input)
124
593
  expect(r.ok).toBe(false)
125
- expect(r.problems.join(' ')).toMatch(/described one thing and built another/)
594
+ expect(r.text).toMatch(/described one thing and built another/)
595
+ })
596
+
597
+ it('catches operator_cosigned disagreeing with the kind of task', async () => {
598
+ const input = inputFor(plain)
599
+ input.declared.operator_cosigned = true
600
+ expect((await problemsOf(input)).text).toMatch(/operator_cosigned=true/)
126
601
  })
127
602
  })
128
603
 
129
604
  describe('structural refusals', () => {
130
- it('catches a vault equal to the sponsor token account', async () => {
131
- // Would mean the budget never leaves the sponsor's control — funded on paper, not escrowed.
132
- const input = baseInput()
133
- input.accounts = { ...fx.accounts, vault: fx.accounts.sponsor_token_account }
134
- const r = await verifyFundingTransaction(input)
605
+ it('refuses a deployment or response that is not on the v2 interface', async () => {
606
+ const input = inputFor(plain)
607
+ input.escrow_interface = 'v1'
608
+ expect((await problemsOf(input)).text).toMatch(/escrow_interface "v1"/)
609
+ input.escrow_interface = undefined
610
+ expect((await problemsOf(input)).text).toMatch(/escrow_interface undefined/)
611
+ })
612
+
613
+ it('refuses a v1 create_task (84 bytes, no max_fee_bps)', async () => {
614
+ const input = inputFor(plain)
615
+ input.transaction_base64 = v1.transaction_base64
616
+ const r = await problemsOf(input)
617
+ expect(r.ok).toBe(false)
618
+ expect(r.text).toMatch(/84 bytes is the v1 layout/)
619
+ })
620
+
621
+ it('refuses an extra instruction', async () => {
622
+ const input = inputFor(plain)
623
+ input.transaction_base64 = rebuild(plain, {
624
+ extra: SystemProgram.transfer({
625
+ fromPubkey: new PublicKey(plain.sponsor),
626
+ toPubkey: new PublicKey(OPERATOR),
627
+ lamports: 1,
628
+ }),
629
+ })
630
+ expect((await problemsOf(input)).text).toMatch(/exactly 1 instruction, found 2/)
631
+ })
632
+
633
+ it('refuses a vault in the instruction that is not the task vault PDA', async () => {
634
+ const input = inputFor(plain)
635
+ input.transaction_base64 = rebuild(plain, {
636
+ keys: (keys) => keys.map((k, i) => (i === 5 ? { ...keys[6] } : k)),
637
+ })
638
+ expect((await problemsOf(input)).text).toMatch(/account #5 \(vault\)/)
639
+ })
640
+
641
+ it('catches a quoted vault equal to the sponsor token account', async () => {
642
+ const input = inputFor(plain)
643
+ input.accounts.vault = input.accounts.sponsor_token_account
644
+ const r = await problemsOf(input)
135
645
  expect(r.ok).toBe(false)
136
- expect(r.problems.join(' ')).toMatch(/not be escrowed/)
646
+ expect(r.text).toMatch(/not be escrowed/)
137
647
  })
138
648
 
139
- it('catches a vault that is not a program-derived address', async () => {
140
- // An on-curve "vault" is a wallet somebody holds the key to.
141
- const input = baseInput()
142
- input.accounts = { ...fx.accounts, vault: fx.sponsor }
143
- const r = await verifyFundingTransaction(input)
649
+ it('catches a quoted vault that is not a program-derived address', async () => {
650
+ const input = inputFor(plain)
651
+ input.accounts.vault = plain.sponsor
652
+ const r = await problemsOf(input)
144
653
  expect(r.ok).toBe(false)
145
- expect(r.problems.join(' ')).toMatch(/program-derived|holds its key/)
654
+ expect(r.text).toMatch(/program-derived address/)
655
+ })
656
+
657
+ it('catches a task id that does not match the task account', async () => {
658
+ const input = inputFor(plain)
659
+ input.task_id = String(BigInt(input.task_id) + 1n)
660
+ expect((await problemsOf(input)).text).toMatch(/account #4 \(task\)/)
661
+ })
662
+
663
+ it('catches a sponsor token account other than the one the agent sent', async () => {
664
+ const input = inputFor(plain)
665
+ input.expected.sponsor_token_account = plain.sponsor
666
+ expect((await problemsOf(input)).text).toMatch(/sponsor_token_account/)
146
667
  })
147
668
 
148
669
  it('catches a malformed seed commit', async () => {
149
- const input = baseInput()
150
- input.declared = { ...fx.declared, seed_commit: 'nope' }
151
- const r = await verifyFundingTransaction(input)
670
+ const input = inputFor(plain)
671
+ input.declared.seed_commit = 'nope'
672
+ const r = await problemsOf(input)
152
673
  expect(r.ok).toBe(false)
153
- expect(r.problems.join(' ')).toMatch(/seed_commit/)
674
+ expect(r.text).toMatch(/seed_commit/)
675
+ })
676
+
677
+ it('refuses bytes that do not decode', async () => {
678
+ const input = inputFor(plain)
679
+ input.transaction_base64 = Buffer.from('not a transaction').toString('base64')
680
+ expect((await problemsOf(input)).text).toMatch(/does not decode/)
154
681
  })
155
682
  })
156
683