@projectsolo/solo-mission-mcp 0.21.14 → 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 (33) hide show
  1. package/README.md +96 -12
  2. package/dist/chunk-NUGGTARW.js +196 -0
  3. package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
  4. package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
  5. package/dist/deployment-UCUWVIPJ.js +43 -0
  6. package/dist/escrowErrors-4XSAYLGT.js +56 -0
  7. package/dist/index.js +239 -160
  8. package/dist/verify-7EUVRUAS.js +445 -0
  9. package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
  10. package/dist/wire-RLDY4AJR.js +24 -0
  11. package/package.json +1 -1
  12. package/src/config.ts +11 -0
  13. package/src/index.ts +1 -1
  14. package/src/scripts/check-tools-against-spec.ts +3 -3
  15. package/src/solana/deployment.test.ts +61 -0
  16. package/src/solana/deployment.ts +89 -0
  17. package/src/solana/escrowErrors.ts +78 -0
  18. package/src/solana/fixtures/config-account.json +6 -0
  19. package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
  20. package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
  21. package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
  22. package/src/solana/verify.test.ts +605 -78
  23. package/src/solana/verify.ts +545 -84
  24. package/src/solana/wallet.test.ts +85 -2
  25. package/src/solana/wallet.ts +74 -31
  26. package/src/solana/wire.test.ts +129 -0
  27. package/src/solana/wire.ts +264 -0
  28. package/src/tools/missions.ts +45 -117
  29. package/src/tools/solana.test.ts +356 -0
  30. package/src/tools/solana.ts +309 -70
  31. package/src/tools/tracks.ts +1 -1
  32. package/dist/verify-KAETIGV5.js +0 -136
  33. /package/src/solana/fixtures/{funding-transaction.json → funding-transaction-v1.json} +0 -0
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Decodes a funding transaction and checks it against what the agent asked for.
3
3
  *
4
- * WHY THIS IS MANDATORY, NOT DEFENSIVE. On Base an agent builds `createTask` itself from published
5
- * parameters, so it can verify every field against the API response and the contract ABI before
6
- * signing. Solana funding uses partial signing — the backend builds, the agent signs, the backend
7
- * submits — which removes a whole class of integration breakage but hands the agent an opaque
8
- * base64 blob.
4
+ * WHY THIS IS MANDATORY, NOT DEFENSIVE. An agent that built the funding instruction itself from
5
+ * published parameters could check every field before signing. Solana funding instead uses partial
6
+ * signing — the backend builds, the agent signs, the backend submits — which removes a whole class
7
+ * of integration breakage but hands the agent an opaque base64 blob.
9
8
  *
10
9
  * A human signing in Phantom gets that blob decoded for free by their wallet UI. **An agent has no
11
10
  * wallet UI.** So without this, "the agent authorises exactly what it signs" is technically true and
@@ -13,12 +12,24 @@
13
12
  * this the least settled decision in the whole plan and makes the verifier a requirement of the
14
13
  * flow, not an optional extra.
15
14
  *
16
- * WHAT THIS DOES AND DOES NOT PROVE. It re-encodes the instruction data from the agent's own
17
- * parameters and compares bytes, and it checks the accounts the transaction touches. So it catches a
18
- * backend that quoted one budget and built another, a substituted mint, a redirected vault, or a
19
- * different program entirely. It does NOT prove the backend will submit the transaction it showed
20
- * you — but it cannot usefully alter one afterwards either, because any change invalidates the
21
- * signature.
15
+ * WHAT THIS DOES AND DOES NOT PROVE. It decodes the instruction data and compares every field with
16
+ * the agent's own expectations, and it checks every account the instruction touches, by position,
17
+ * against addresses it derives itself. So it catches a backend that quoted one budget and built
18
+ * another, a substituted mint, a redirected vault, a raised fee ceiling, a co-signer that is not
19
+ * the program's Operator, or a different program entirely. It does NOT prove the backend will
20
+ * submit the transaction it showed you — but it cannot usefully alter one afterwards either,
21
+ * because any change invalidates the signature.
22
+ *
23
+ * ESCROW INTERFACE v2 ONLY (solo-escrow-solana u1.1.0, `main` 4376bac). Relative to v1:
24
+ * - `create_task` data gains a trailing `max_fee_bps: u16` (N-2). It must equal the fee rate
25
+ * the agent was quoted, so the platform cannot raise the fee between quote and execution.
26
+ * - The accounts gain an optional `operator` signer after `system_program` (SE-02). It is the
27
+ * program id — Anchor's `None` — for a non-lottery task, and the Operator, as a signer, for a
28
+ * lottery task. A lottery transaction arrives already co-signed by the Operator; this checks
29
+ * that the co-signer is the on-chain `Config.operator` and that its ed25519 signature is valid
30
+ * over the exact message the agent is about to sign.
31
+ * A deployment reporting any other interface is refused rather than decoded against the wrong
32
+ * layout.
22
33
  *
23
34
  * This reintroduces a dependency on the instruction layout, which partial signing was meant to
24
35
  * remove. The difference is where that dependency lives: inside a package we version and ship,
@@ -26,8 +37,60 @@
26
37
  * honest to say the shape dependency is reduced rather than eliminated.
27
38
  */
28
39
 
29
- /** What the agent believes it is funding. Every field is compared. */
30
- export interface ExpectedFunding {
40
+ import { createHash } from 'crypto';
41
+ import {
42
+ base58Decode,
43
+ base58Encode,
44
+ ed25519Verify,
45
+ isEmptySignature,
46
+ isSignerIndex,
47
+ isWritableIndex,
48
+ parseWireTransaction,
49
+ type WireTransaction,
50
+ } from './wire.js';
51
+
52
+ /** The only program interface this verifier decodes. */
53
+ export const SUPPORTED_ESCROW_INTERFACE = 'v2';
54
+
55
+ /**
56
+ * Discriminators from solo-escrow-solana u1.1.0 `idl/solo_escrow.json`. Byte-checked against the
57
+ * vendored IDL excerpt in `verify.test.ts`.
58
+ */
59
+ export const DISCRIMINATORS = {
60
+ create_task: [194, 80, 6, 180, 232, 127, 48, 171],
61
+ cancel_task: [69, 228, 134, 187, 134, 105, 238, 48],
62
+ emergency_refund: [188, 73, 52, 195, 137, 70, 180, 147],
63
+ claim_refund: [15, 16, 30, 161, 255, 228, 97, 60],
64
+ /** The `Config` ACCOUNT discriminator (first 8 bytes of its data). */
65
+ Config: [155, 12, 170, 224, 30, 250, 204, 130],
66
+ } as const;
67
+
68
+ /** `create_task`'s accounts in IDL order (v2). The instruction must list exactly these. */
69
+ export const CREATE_TASK_V2_ACCOUNTS = [
70
+ 'sponsor',
71
+ 'config',
72
+ 'whitelist',
73
+ 'mint',
74
+ 'task',
75
+ 'vault',
76
+ 'sponsor_token_account',
77
+ 'token_program',
78
+ 'system_program',
79
+ 'operator',
80
+ 'event_authority',
81
+ 'program',
82
+ ] as const;
83
+
84
+ /** 8 discriminator | budget u64 | base_pool u64 | lottery_winner_count u32
85
+ * | lottery_prize_per_winner u64 | qualify_deadline i64 | settlement_deadline i64
86
+ * | seed_commit [u8; 32] | max_fee_bps u16 */
87
+ export const CREATE_TASK_V2_DATA_LENGTH = 8 + 8 + 8 + 4 + 8 + 8 + 8 + 32 + 2;
88
+
89
+ export const TOKEN_PROGRAM_ID = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
90
+ export const SYSTEM_PROGRAM_ID = '11111111111111111111111111111111';
91
+
92
+ /** What the backend says the transaction encodes (`declared` in the funding response). */
93
+ export interface DeclaredFunding {
31
94
  /** Smallest-unit strings, as the API returns them. Compared as bigints so "10" and "10.0" or a
32
95
  * leading zero cannot slip past a string equality check. */
33
96
  budget: string;
@@ -38,6 +101,10 @@ export interface ExpectedFunding {
38
101
  settlement_deadline: string;
39
102
  /** 64 hex chars, no 0x. */
40
103
  seed_commit: string;
104
+ /** v2: the fee ceiling encoded as `create_task`'s `max_fee_bps`. */
105
+ max_fee_bps?: number;
106
+ /** v2: whether the platform Operator co-signed (lottery tasks only). */
107
+ operator_cosigned?: boolean;
41
108
  }
42
109
 
43
110
  export interface FundingAccounts {
@@ -51,10 +118,29 @@ export interface FundingAccounts {
51
118
  sponsor_token_account: string;
52
119
  }
53
120
 
121
+ /** The escrow program's `Config` account, as read from chain. */
122
+ export interface EscrowConfigState {
123
+ address: string;
124
+ admin: string;
125
+ operator: string;
126
+ guardian: string;
127
+ treasury: string;
128
+ fee_bps: number;
129
+ min_review_window: number;
130
+ paused: boolean;
131
+ task_counter: string;
132
+ }
133
+
54
134
  export interface VerifyInput {
55
135
  transaction_base64: string;
56
- declared: ExpectedFunding;
136
+ /** `escrow_interface` from the funding response. Anything but 'v2' is refused. */
137
+ escrow_interface: string | undefined;
138
+ /** `task_id` from the funding response; the task and vault PDAs are derived from it. */
139
+ task_id: string;
140
+ declared: DeclaredFunding;
57
141
  accounts: FundingAccounts;
142
+ /** v2 lottery: the hash of the co-signed message, as the backend recorded it. */
143
+ message_sha256?: string;
58
144
  /** What the agent asked for, independent of what the backend replied. */
59
145
  expected: {
60
146
  budget_raw: string;
@@ -63,29 +149,206 @@ export interface VerifyInput {
63
149
  lottery_prize_per_winner_raw: string;
64
150
  qualify_deadline: number;
65
151
  settlement_deadline: number;
152
+ /** Hex, with or without 0x. Optional: compared when known. */
153
+ seed_commit?: string;
154
+ /** The fee rate the agent was quoted (create_mission's `solana_quoted_fee_bps`). */
155
+ max_fee_bps: number | undefined;
66
156
  mint: string;
67
157
  sponsor: string;
158
+ /** The token account the agent asked the budget to be taken from. */
159
+ sponsor_token_account: string;
68
160
  };
69
161
  /** The program the agent expects. Pinned so a transaction pointed at a different program is
70
162
  * rejected even if every parameter matches. */
71
163
  expected_program_id: string;
164
+ /** `Config` read from chain (`readEscrowConfig`), or null when the read failed. Needed to check a
165
+ * lottery co-signer; a non-lottery transaction does not depend on it. */
166
+ onchain_config: EscrowConfigState | null;
167
+ onchain_config_error?: string;
72
168
  }
73
169
 
74
170
  export interface VerifyResult {
75
171
  ok: boolean;
76
172
  /** Every discrepancy found, not just the first — an agent debugging this wants the whole list. */
77
173
  problems: string[];
174
+ /** Not a reason to refuse, but likely to make the transaction fail on chain. */
175
+ warnings: string[];
78
176
  /** What the transaction actually contains, for logging even on success. */
79
177
  summary: {
178
+ escrow_interface: string | undefined;
80
179
  program_id: string;
81
180
  instruction_count: number;
82
181
  signers: string[];
182
+ fee_payer: string | null;
83
183
  budget: string;
84
184
  mint: string;
85
185
  task: string;
186
+ lottery: boolean;
187
+ max_fee_bps: number | null;
188
+ operator: string | null;
189
+ operator_signature: 'valid' | 'invalid' | 'missing' | 'not_required';
190
+ message_sha256: string | null;
191
+ };
192
+ }
193
+
194
+ /** The fields of a v2 `create_task` instruction, decoded. */
195
+ export interface CreateTaskV2Fields {
196
+ budget: string;
197
+ base_pool: string;
198
+ lottery_winner_count: number;
199
+ lottery_prize_per_winner: string;
200
+ qualify_deadline: string;
201
+ settlement_deadline: string;
202
+ seed_commit: string;
203
+ max_fee_bps: number;
204
+ }
205
+
206
+ /** Decodes v2 `create_task` instruction data. Throws if it is not exactly that instruction. */
207
+ export function decodeCreateTaskV2(data: Buffer): CreateTaskV2Fields {
208
+ if (!data.subarray(0, 8).equals(Buffer.from(DISCRIMINATORS.create_task))) {
209
+ throw new Error(
210
+ `instruction discriminator is ${data.subarray(0, 8).toString('hex')}, not create_task's ` +
211
+ Buffer.from(DISCRIMINATORS.create_task).toString('hex'),
212
+ );
213
+ }
214
+ if (data.length !== CREATE_TASK_V2_DATA_LENGTH) {
215
+ throw new Error(
216
+ `create_task data is ${data.length} bytes, expected ${CREATE_TASK_V2_DATA_LENGTH} for the v2 ` +
217
+ 'interface' +
218
+ (data.length === CREATE_TASK_V2_DATA_LENGTH - 2 ? ' (84 bytes is the v1 layout, without max_fee_bps)' : ''),
219
+ );
220
+ }
221
+ return {
222
+ budget: data.readBigUInt64LE(8).toString(),
223
+ base_pool: data.readBigUInt64LE(16).toString(),
224
+ lottery_winner_count: data.readUInt32LE(24),
225
+ lottery_prize_per_winner: data.readBigUInt64LE(28).toString(),
226
+ qualify_deadline: data.readBigInt64LE(36).toString(),
227
+ settlement_deadline: data.readBigInt64LE(44).toString(),
228
+ seed_commit: data.subarray(52, 84).toString('hex'),
229
+ max_fee_bps: data.readUInt16LE(84),
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Decodes the escrow `Config` account:
235
+ * 8 discriminator | admin 32 | operator 32 | guardian 32 | treasury 32 | fee_bps u16 (136)
236
+ * | min_review_window i64 (138) | paused bool (146) | task_counter u64 (147)
237
+ */
238
+ export function decodeEscrowConfig(data: Buffer, address: string): EscrowConfigState {
239
+ if (data.length < 155) throw new Error(`Config account is ${data.length} bytes, too short`);
240
+ if (!data.subarray(0, 8).equals(Buffer.from(DISCRIMINATORS.Config))) {
241
+ throw new Error('account at the Config address does not carry the Config discriminator');
242
+ }
243
+ const key = (o: number) => base58Encode(data.subarray(o, o + 32));
244
+ return {
245
+ address,
246
+ admin: key(8),
247
+ operator: key(40),
248
+ guardian: key(72),
249
+ treasury: key(104),
250
+ fee_bps: data.readUInt16LE(136),
251
+ min_review_window: Number(data.readBigInt64LE(138)),
252
+ paused: data[146] !== 0,
253
+ task_counter: data.readBigUInt64LE(147).toString(),
86
254
  };
87
255
  }
88
256
 
257
+ /** The Config PDA, `[b"config"]` under the program. */
258
+ export async function configAddress(programId: string): Promise<string> {
259
+ const { PublicKey } = await import('@solana/web3.js');
260
+ return PublicKey.findProgramAddressSync([Buffer.from('config')], new PublicKey(programId))[0].toBase58();
261
+ }
262
+
263
+ interface RpcAccount {
264
+ owner: string;
265
+ data: [string, string];
266
+ }
267
+
268
+ /**
269
+ * `getAccountInfo` (base64, confirmed). Public RPC endpoints rate-limit hard (devnet answers
270
+ * "Connection rate limits exceeded"), and a transient failure would refuse a legitimate funding, so
271
+ * rate limits, 5xx and network errors are retried — and nothing else.
272
+ */
273
+ async function getAccountInfo(
274
+ rpcUrl: string,
275
+ address: string,
276
+ { attempts = 3, backoffMs = 500 }: { attempts?: number; backoffMs?: number } = {},
277
+ ): Promise<RpcAccount | null> {
278
+ type RpcBody = { result?: { value: RpcAccount | null }; error?: { message: string } };
279
+ let lastError = '';
280
+ for (let attempt = 1; attempt <= attempts; attempt++) {
281
+ try {
282
+ const res = await fetch(rpcUrl, {
283
+ method: 'POST',
284
+ headers: { 'Content-Type': 'application/json' },
285
+ body: JSON.stringify({
286
+ jsonrpc: '2.0',
287
+ id: 1,
288
+ method: 'getAccountInfo',
289
+ params: [address, { encoding: 'base64', commitment: 'confirmed' }],
290
+ }),
291
+ signal: AbortSignal.timeout(15_000),
292
+ });
293
+ if (res.status === 429 || res.status >= 500) {
294
+ lastError = `HTTP ${res.status}`;
295
+ } else {
296
+ const body = (await res.json()) as RpcBody;
297
+ if (!body.error) return body.result?.value ?? null;
298
+ if (!/rate limit|too many/i.test(body.error.message)) {
299
+ throw new Error(`RPC getAccountInfo(${address}) failed: ${body.error.message}`);
300
+ }
301
+ lastError = body.error.message;
302
+ }
303
+ } catch (e) {
304
+ if ((e as Error).message.startsWith('RPC getAccountInfo')) throw e;
305
+ lastError = (e as Error).message;
306
+ }
307
+ if (attempt < attempts) await new Promise((r) => setTimeout(r, backoffMs * attempt));
308
+ }
309
+ throw new Error(`RPC getAccountInfo(${address}) failed: ${lastError}`);
310
+ }
311
+
312
+ /**
313
+ * Reads the escrow `Config` straight from the cluster. The address is derived here from the pinned
314
+ * program id — never taken from the backend — and the account must be owned by that program.
315
+ */
316
+ export async function readEscrowConfig(
317
+ rpcUrl: string,
318
+ programId: string,
319
+ opts: { attempts?: number; backoffMs?: number } = {},
320
+ ): Promise<EscrowConfigState> {
321
+ const address = await configAddress(programId);
322
+ const value = await getAccountInfo(rpcUrl, address, opts);
323
+ if (!value) throw new Error(`Config account ${address} does not exist on this cluster`);
324
+ if (value.owner !== programId) {
325
+ throw new Error(`Config account ${address} is owned by ${value.owner}, not the program ${programId}`);
326
+ }
327
+ return decodeEscrowConfig(Buffer.from(value.data[0], 'base64'), address);
328
+ }
329
+
330
+ /**
331
+ * A mint's decimals, read from the SPL Token mint account (82 bytes; `decimals` at 44,
332
+ * `is_initialized` at 45). `expected_budget` is scaled by this, so it must not come from the API:
333
+ * a reported 9 for a 6-decimal mint would make an agent that meant 10 approve 10,000.
334
+ */
335
+ export async function readMintDecimals(
336
+ rpcUrl: string,
337
+ mint: string,
338
+ opts: { attempts?: number; backoffMs?: number } = {},
339
+ ): Promise<number> {
340
+ const value = await getAccountInfo(rpcUrl, mint, opts);
341
+ if (!value) throw new Error(`mint ${mint} does not exist on this cluster`);
342
+ if (value.owner !== TOKEN_PROGRAM_ID) {
343
+ throw new Error(`mint ${mint} is owned by ${value.owner}, not the SPL Token program`);
344
+ }
345
+ const data = Buffer.from(value.data[0], 'base64');
346
+ if (data.length !== 82 || data[45] !== 1) {
347
+ throw new Error(`account ${mint} is not an initialized SPL Token mint`);
348
+ }
349
+ return data[44];
350
+ }
351
+
89
352
  const eqAmount = (a: string, b: string): boolean => {
90
353
  try {
91
354
  return BigInt(a) === BigInt(b);
@@ -94,17 +357,57 @@ const eqAmount = (a: string, b: string): boolean => {
94
357
  }
95
358
  };
96
359
 
360
+ const hex = (s: string | undefined) => String(s ?? '').replace(/^0x/i, '').toLowerCase();
361
+
97
362
  /**
98
- * Verifies a funding transaction. Returns every problem rather than throwing on the first.
363
+ * Verifies a v2 funding transaction. Returns every problem rather than throwing on the first.
99
364
  *
100
365
  * REFUSE TO SIGN when `ok` is false. The caller must treat this as a hard stop — a mismatch means
101
366
  * the backend built something other than what was quoted, and signing it authorises that difference.
102
367
  */
103
368
  export async function verifyFundingTransaction(input: VerifyInput): Promise<VerifyResult> {
104
- const { Transaction, PublicKey } = await import('@solana/web3.js');
369
+ const { PublicKey } = await import('@solana/web3.js');
105
370
  const problems: string[] = [];
371
+ const warnings: string[] = [];
372
+ const e = input.expected;
373
+ const d = input.declared;
374
+ const expectLottery = e.lottery_winner_count > 0;
106
375
 
107
- const tx = Transaction.from(Buffer.from(input.transaction_base64, 'base64'));
376
+ const summary: VerifyResult['summary'] = {
377
+ escrow_interface: input.escrow_interface,
378
+ program_id: '(none)',
379
+ instruction_count: 0,
380
+ signers: [],
381
+ fee_payer: null,
382
+ budget: d.budget,
383
+ mint: input.accounts.mint,
384
+ task: input.accounts.task,
385
+ lottery: expectLottery,
386
+ max_fee_bps: null,
387
+ operator: null,
388
+ operator_signature: expectLottery ? 'missing' : 'not_required',
389
+ message_sha256: null,
390
+ };
391
+ const done = (): VerifyResult => ({ ok: problems.length === 0, problems, warnings, summary });
392
+
393
+ if (input.escrow_interface !== SUPPORTED_ESCROW_INTERFACE) {
394
+ problems.push(
395
+ `the funding response reports escrow_interface ${JSON.stringify(input.escrow_interface)}; this ` +
396
+ `verifier decodes only the ${SUPPORTED_ESCROW_INTERFACE} create_task layout`,
397
+ );
398
+ }
399
+
400
+ let tx: WireTransaction;
401
+ try {
402
+ tx = parseWireTransaction(Buffer.from(input.transaction_base64, 'base64'));
403
+ } catch (err) {
404
+ problems.push(`the transaction does not decode: ${(err as Error).message}`);
405
+ return done();
406
+ }
407
+ summary.instruction_count = tx.instructions.length;
408
+ summary.signers = tx.accountKeys.slice(0, tx.header.numRequiredSignatures);
409
+ summary.fee_payer = tx.accountKeys[0] ?? null;
410
+ summary.message_sha256 = createHash('sha256').update(tx.message).digest('hex');
108
411
 
109
412
  // ---- structure ------------------------------------------------------------------------------
110
413
  // Exactly one instruction. An extra one is the cheapest way to hide something — a token transfer
@@ -115,9 +418,9 @@ export async function verifyFundingTransaction(input: VerifyInput): Promise<Veri
115
418
  'additional instructions would be authorised by the same signature',
116
419
  );
117
420
  }
118
-
119
421
  const ix = tx.instructions[0];
120
- const programId = ix?.programId?.toBase58() ?? '(none)';
422
+ const programId = ix ? tx.accountKeys[ix.programIdIndex] : '(none)';
423
+ summary.program_id = programId;
121
424
  if (programId !== input.expected_program_id) {
122
425
  problems.push(`program is ${programId}, expected ${input.expected_program_id}`);
123
426
  }
@@ -128,63 +431,206 @@ export async function verifyFundingTransaction(input: VerifyInput): Promise<Veri
128
431
  );
129
432
  }
130
433
 
131
- // ---- the only signer must be us --------------------------------------------------------------
132
- const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
133
- if (signers.length !== 1 || signers[0] !== input.expected.sponsor) {
434
+ if (
435
+ input.message_sha256 !== undefined &&
436
+ input.message_sha256.toLowerCase() !== summary.message_sha256
437
+ ) {
134
438
  problems.push(
135
- `expected the sponsor ${input.expected.sponsor} to be the only signer, found [${signers.join(', ')}]`,
439
+ `the response's message_sha256 ${input.message_sha256} is not the hash of this transaction's ` +
440
+ `message (${summary.message_sha256}) — confirm-funding would not recognise it`,
136
441
  );
137
442
  }
138
- if (tx.feePayer?.toBase58() !== input.expected.sponsor) {
443
+
444
+ // ---- the parameters in the bytes -------------------------------------------------------------
445
+ let encoded: CreateTaskV2Fields | null = null;
446
+ try {
447
+ encoded = decodeCreateTaskV2(ix?.data ?? Buffer.alloc(0));
448
+ summary.max_fee_bps = encoded.max_fee_bps;
449
+ summary.lottery = encoded.lottery_winner_count > 0;
450
+ } catch (err) {
451
+ problems.push(`${(err as Error).message} — this is not the instruction it claims to be`);
452
+ }
453
+
454
+ // ---- accounts, by position -------------------------------------------------------------------
455
+ // Derived here from the pinned program id, never taken from the backend's `accounts` block.
456
+ const program = new PublicKey(input.expected_program_id);
457
+ const pda = (...seeds: Buffer[]) => PublicKey.findProgramAddressSync(seeds, program)[0].toBase58();
458
+ let taskId: bigint | null = null;
459
+ try {
460
+ taskId = BigInt(input.task_id);
461
+ if (taskId < 0n || taskId > 0xffffffffffffffffn) throw new Error('out of range');
462
+ } catch {
463
+ problems.push(`task_id ${JSON.stringify(input.task_id)} is not a u64`);
464
+ taskId = null;
465
+ }
466
+ const u64le = (v: bigint) => {
467
+ const b = Buffer.alloc(8);
468
+ b.writeBigUInt64LE(v);
469
+ return b;
470
+ };
471
+ let mintKey: Buffer | null = null;
472
+ try {
473
+ mintKey = base58Decode(e.mint);
474
+ if (mintKey.length !== 32) mintKey = null;
475
+ } catch {
476
+ mintKey = null;
477
+ }
478
+ if (!mintKey) problems.push(`expected mint ${e.mint} is not a valid address`);
479
+
480
+ const operatorSlot = CREATE_TASK_V2_ACCOUNTS.indexOf('operator');
481
+ const want: Record<(typeof CREATE_TASK_V2_ACCOUNTS)[number], string | null> = {
482
+ sponsor: e.sponsor,
483
+ config: pda(Buffer.from('config')),
484
+ whitelist: mintKey ? pda(Buffer.from('whitelist'), mintKey) : null,
485
+ mint: e.mint,
486
+ task: taskId !== null ? pda(Buffer.from('task'), u64le(taskId)) : null,
487
+ vault: taskId !== null ? pda(Buffer.from('vault'), u64le(taskId)) : null,
488
+ sponsor_token_account: e.sponsor_token_account,
489
+ token_program: TOKEN_PROGRAM_ID,
490
+ system_program: SYSTEM_PROGRAM_ID,
491
+ operator: null, // checked below: depends on lottery vs not
492
+ event_authority: pda(Buffer.from('__event_authority')),
493
+ program: input.expected_program_id,
494
+ };
495
+
496
+ const ixKeys = (ix?.accountIndexes ?? []).map((i) => tx.accountKeys[i]);
497
+ if (ix && ixKeys.length !== CREATE_TASK_V2_ACCOUNTS.length) {
139
498
  problems.push(
140
- `fee payer is ${tx.feePayer?.toBase58() ?? '(unset)'}, expected ${input.expected.sponsor}`,
499
+ `create_task lists ${ixKeys.length} accounts, expected ${CREATE_TASK_V2_ACCOUNTS.length} ` +
500
+ `(${CREATE_TASK_V2_ACCOUNTS.join(', ')})`,
141
501
  );
502
+ } else if (ix) {
503
+ CREATE_TASK_V2_ACCOUNTS.forEach((name, i) => {
504
+ const expectedKey = want[name];
505
+ if (expectedKey !== null && ixKeys[i] !== expectedKey) {
506
+ problems.push(`account #${i} (${name}) is ${ixKeys[i]}, expected ${expectedKey}`);
507
+ }
508
+ });
509
+ if (!isSignerIndex(tx, ix.accountIndexes[0]) || !isWritableIndex(tx, ix.accountIndexes[0])) {
510
+ problems.push('the sponsor account is not a writable signer');
511
+ }
142
512
  }
143
513
 
144
- // ---- the accounts the money moves between ----------------------------------------------------
145
- const keyList = (ix?.keys ?? []).map((k) => k.pubkey.toBase58());
146
- for (const [label, expected] of [
147
- ['mint', input.expected.mint],
148
- ['sponsor', input.expected.sponsor],
514
+ // The backend's own description of the accounts must agree with what the bytes say.
515
+ for (const [label, quoted, derived] of [
516
+ ['task', input.accounts.task, want.task],
517
+ ['vault', input.accounts.vault, want.vault],
518
+ ['config', input.accounts.config, want.config],
519
+ ['whitelist', input.accounts.whitelist, want.whitelist],
149
520
  ] as const) {
150
- if (!keyList.includes(expected)) {
151
- problems.push(`${label} ${expected} does not appear in the transaction's accounts`);
521
+ if (derived !== null && quoted !== derived) {
522
+ problems.push(
523
+ `quoted ${label} ${quoted} is not the program-derived address ${derived} — ` +
524
+ 'an address that is not the PDA may be a wallet somebody holds the key to',
525
+ );
152
526
  }
153
527
  }
154
- if (input.accounts.mint !== input.expected.mint) {
155
- problems.push(`quoted mint ${input.accounts.mint}, expected ${input.expected.mint}`);
528
+ if (input.accounts.mint !== e.mint) {
529
+ problems.push(`quoted mint ${input.accounts.mint}, expected ${e.mint}`);
530
+ }
531
+ if (input.accounts.sponsor !== e.sponsor) {
532
+ problems.push(`quoted sponsor ${input.accounts.sponsor}, expected ${e.sponsor}`);
156
533
  }
157
- // The task and its vault are PDAs the program derives; they cannot be verified without repeating
158
- // the derivation, but they MUST be distinct and must both be present. A vault equal to the
159
- // sponsor's own token account would mean the budget never leaves their control.
534
+ if (input.accounts.sponsor_token_account !== e.sponsor_token_account) {
535
+ problems.push(
536
+ `quoted sponsor_token_account ${input.accounts.sponsor_token_account}, expected ${e.sponsor_token_account}`,
537
+ );
538
+ }
539
+ // A vault equal to the sponsor's own token account would mean the budget never leaves their
540
+ // control — funded on paper, not escrowed.
160
541
  if (input.accounts.vault === input.accounts.sponsor_token_account) {
161
542
  problems.push('escrow vault equals the sponsor token account — the budget would not be escrowed');
162
543
  }
163
- try {
164
- // Both must be off-curve, i.e. genuine PDAs with no private key. An on-curve "vault" is a
165
- // wallet somebody holds the key to.
166
- for (const [label, addr] of [
167
- ['task', input.accounts.task],
168
- ['vault', input.accounts.vault],
169
- ] as const) {
170
- if (PublicKey.isOnCurve(new PublicKey(addr).toBytes())) {
171
- problems.push(`${label} ${addr} is not a program-derived address — someone holds its key`);
544
+
545
+ // ---- fee payer and signers -------------------------------------------------------------------
546
+ // The sponsor pays the fee and the rent. The Operator co-signs a lottery but must never be the
547
+ // fee payer: that would make the platform the payer of record for the sponsor's escrow.
548
+ const feePayer = tx.accountKeys[0];
549
+ if (feePayer !== e.sponsor) {
550
+ const who =
551
+ input.onchain_config && feePayer === input.onchain_config.operator ? ' (the Operator)' : '';
552
+ problems.push(`fee payer is ${feePayer ?? '(unset)'}${who}, expected the sponsor ${e.sponsor}`);
553
+ }
554
+
555
+ const operatorKey = ix && ixKeys.length === CREATE_TASK_V2_ACCOUNTS.length ? ixKeys[operatorSlot] : undefined;
556
+ const operatorIndex = ix?.accountIndexes[operatorSlot];
557
+ const signers = summary.signers;
558
+
559
+ if (d.operator_cosigned !== undefined && d.operator_cosigned !== expectLottery) {
560
+ problems.push(
561
+ `the response says operator_cosigned=${d.operator_cosigned}, but this is ` +
562
+ (expectLottery ? 'a lottery task, which must be co-signed' : 'not a lottery task, which must not be'),
563
+ );
564
+ }
565
+
566
+ if (!expectLottery) {
567
+ // Anchor's None for the optional operator is the program id itself, which cannot sign.
568
+ if (operatorKey !== undefined && operatorKey !== input.expected_program_id) {
569
+ problems.push(
570
+ `the operator slot holds ${operatorKey}; a non-lottery create_task must pass the program id ` +
571
+ `${input.expected_program_id} (no co-signer)`,
572
+ );
573
+ }
574
+ if (signers.length !== 1 || signers[0] !== e.sponsor) {
575
+ problems.push(
576
+ `expected the sponsor ${e.sponsor} to be the only signer, found [${signers.join(', ')}]`,
577
+ );
578
+ }
579
+ } else {
580
+ summary.operator = operatorKey ?? null;
581
+ const cfg = input.onchain_config;
582
+ if (!cfg) {
583
+ problems.push(
584
+ 'cannot check the lottery co-signer: the on-chain Config.operator could not be read' +
585
+ (input.onchain_config_error ? ` (${input.onchain_config_error})` : ''),
586
+ );
587
+ } else if (operatorKey !== cfg.operator) {
588
+ problems.push(
589
+ `the co-signer in the operator slot is ${operatorKey ?? '(none)'}, but the on-chain ` +
590
+ `Config.operator is ${cfg.operator}`,
591
+ );
592
+ }
593
+ if (operatorKey !== undefined && operatorKey === e.sponsor) {
594
+ problems.push('the operator slot holds the sponsor itself — the co-signature would be the agent\'s own');
595
+ }
596
+ const expectedSigners = operatorKey ? [e.sponsor, operatorKey] : [e.sponsor];
597
+ if (signers.length !== 2 || signers[0] !== expectedSigners[0] || signers[1] !== expectedSigners[1]) {
598
+ problems.push(
599
+ `expected exactly two signers, the sponsor ${e.sponsor} (fee payer) and the Operator ` +
600
+ `${operatorKey ?? '(none)'}, found [${signers.join(', ')}]`,
601
+ );
602
+ }
603
+ if (operatorIndex === undefined || !isSignerIndex(tx, operatorIndex)) {
604
+ problems.push('the operator slot is not a signer — a lottery create_task needs the Operator to sign');
605
+ summary.operator_signature = 'missing';
606
+ } else {
607
+ const sig = tx.signatures[operatorIndex];
608
+ if (isEmptySignature(sig)) {
609
+ summary.operator_signature = 'missing';
610
+ problems.push(
611
+ 'the Operator co-signature is missing — a lottery create_task without it fails on chain ' +
612
+ '(LotteryRequiresOperator), and an agent-added signature cannot fix that',
613
+ );
614
+ } else if (!ed25519Verify(sig, tx.message, operatorKey as string)) {
615
+ summary.operator_signature = 'invalid';
616
+ problems.push(
617
+ 'the Operator signature does not verify over this message — the transaction was altered ' +
618
+ 'after it was co-signed, or the signature is not the Operator\'s',
619
+ );
620
+ } else {
621
+ summary.operator_signature = 'valid';
172
622
  }
173
623
  }
174
- } catch {
175
- problems.push('task or vault is not a valid address');
176
624
  }
177
625
 
178
626
  // ---- the parameters --------------------------------------------------------------------------
179
- const d = input.declared;
180
- const e = input.expected;
181
627
  if (!eqAmount(d.budget, e.budget_raw)) {
182
628
  problems.push(`budget is ${d.budget}, expected ${e.budget_raw}`);
183
629
  }
184
630
  if (!eqAmount(d.base_pool, e.base_pool_raw)) {
185
631
  problems.push(`base_pool is ${d.base_pool}, expected ${e.base_pool_raw}`);
186
632
  }
187
- if (d.lottery_winner_count !== e.lottery_winner_count) {
633
+ if (Number(d.lottery_winner_count) !== e.lottery_winner_count) {
188
634
  problems.push(
189
635
  `lottery_winner_count is ${d.lottery_winner_count}, expected ${e.lottery_winner_count}`,
190
636
  );
@@ -204,35 +650,35 @@ export async function verifyFundingTransaction(input: VerifyInput): Promise<Veri
204
650
  }
205
651
  if (!/^[0-9a-f]{64}$/i.test(d.seed_commit)) {
206
652
  problems.push(`seed_commit is not 32 bytes of hex: ${d.seed_commit}`);
653
+ } else if (e.seed_commit !== undefined && hex(d.seed_commit) !== hex(e.seed_commit)) {
654
+ problems.push(`seed_commit is ${d.seed_commit}, expected ${hex(e.seed_commit)}`);
655
+ }
656
+ // N-2: the ceiling must be exactly the quote. A higher one lets the fee rise before the
657
+ // transaction lands; a lower one would fail on chain for no reason the agent caused.
658
+ if (typeof e.max_fee_bps !== 'number' || !Number.isInteger(e.max_fee_bps)) {
659
+ problems.push('no quoted fee rate to check max_fee_bps against — refusing to sign an unchecked fee ceiling');
660
+ } else if (d.max_fee_bps === undefined) {
661
+ problems.push('the funding response does not declare max_fee_bps — not a v2 funding transaction');
662
+ } else if (Number(d.max_fee_bps) !== e.max_fee_bps) {
663
+ problems.push(`max_fee_bps is ${d.max_fee_bps}, expected the quoted fee rate ${e.max_fee_bps}`);
207
664
  }
208
665
 
209
666
  // ---- the declared parameters must match the bytes actually being signed ----------------------
210
667
  // The strongest check here. Everything above compares the backend's own JSON description against
211
668
  // the agent's expectations; this compares that description against the instruction data itself, so
212
669
  // a backend cannot quote correct parameters and encode different ones.
213
- const data = ix?.data ?? Buffer.alloc(0);
214
- if (data.length !== 8 + 8 + 8 + 4 + 8 + 8 + 8 + 32) {
215
- problems.push(
216
- `instruction data is ${data.length} bytes, expected 84 for create_task — ` +
217
- 'this is not the instruction it claims to be',
218
- );
219
- } else {
220
- const encoded = {
221
- budget: data.readBigUInt64LE(8).toString(),
222
- base_pool: data.readBigUInt64LE(16).toString(),
223
- lottery_winner_count: data.readUInt32LE(24),
224
- lottery_prize_per_winner: data.readBigUInt64LE(28).toString(),
225
- qualify_deadline: data.readBigInt64LE(36).toString(),
226
- settlement_deadline: data.readBigInt64LE(44).toString(),
227
- seed_commit: data.subarray(52, 84).toString('hex'),
228
- };
229
- for (const key of Object.keys(encoded) as Array<keyof typeof encoded>) {
670
+ if (encoded) {
671
+ for (const key of Object.keys(encoded) as Array<keyof CreateTaskV2Fields>) {
230
672
  const inBytes = String(encoded[key]);
231
- const quoted = String((d as unknown as Record<string, unknown>)[key]);
673
+ const quotedRaw = (d as unknown as Record<string, unknown>)[key];
674
+ if (quotedRaw === undefined) continue; // reported above
675
+ const quoted = String(quotedRaw);
232
676
  const same =
233
- key === 'seed_commit' || key === 'lottery_winner_count'
234
- ? inBytes.toLowerCase() === quoted.toLowerCase()
235
- : eqAmount(inBytes, quoted);
677
+ key === 'seed_commit'
678
+ ? inBytes === hex(quoted)
679
+ : key === 'lottery_winner_count' || key === 'max_fee_bps'
680
+ ? Number(inBytes) === Number(quoted)
681
+ : eqAmount(inBytes, quoted);
236
682
  if (!same) {
237
683
  problems.push(
238
684
  `the transaction encodes ${key}=${inBytes} but the response quoted ${quoted} — ` +
@@ -240,18 +686,33 @@ export async function verifyFundingTransaction(input: VerifyInput): Promise<Veri
240
686
  );
241
687
  }
242
688
  }
689
+ if (typeof e.max_fee_bps === 'number' && encoded.max_fee_bps !== e.max_fee_bps) {
690
+ problems.push(
691
+ `the transaction encodes max_fee_bps=${encoded.max_fee_bps}, not the quoted fee rate ${e.max_fee_bps}`,
692
+ );
693
+ }
694
+ if ((encoded.lottery_winner_count > 0) !== expectLottery) {
695
+ problems.push(
696
+ `the transaction encodes lottery_winner_count=${encoded.lottery_winner_count}, ` +
697
+ `expected ${e.lottery_winner_count}`,
698
+ );
699
+ }
243
700
  }
244
701
 
245
- return {
246
- ok: problems.length === 0,
247
- problems,
248
- summary: {
249
- program_id: programId,
250
- instruction_count: tx.instructions.length,
251
- signers,
252
- budget: d.budget,
253
- mint: input.accounts.mint,
254
- task: input.accounts.task,
255
- },
256
- };
702
+ // ---- courtesy: things the program would reject anyway ---------------------------------------
703
+ const cfg = input.onchain_config;
704
+ if (cfg && encoded) {
705
+ if (cfg.fee_bps > encoded.max_fee_bps) {
706
+ warnings.push(
707
+ `the program's fee is now ${cfg.fee_bps} bps, above this transaction's max_fee_bps ` +
708
+ `${encoded.max_fee_bps}: create_task will fail with FeeAboveSponsorMax (6132). Cancel the ` +
709
+ 'mission and create a new one to get a fresh quote.',
710
+ );
711
+ }
712
+ if (cfg.paused) {
713
+ warnings.push('the escrow program is paused: create_task will fail until it is unpaused.');
714
+ }
715
+ }
716
+
717
+ return done();
257
718
  }