@projectsolo/solo-mission-mcp 0.19.3 → 0.20.1

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.
@@ -0,0 +1,179 @@
1
+ /**
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.
6
+ *
7
+ * `fixtures/funding-transaction.json` is captured from a devnet funding run.
8
+ */
9
+ import { describe, it, expect } from 'vitest'
10
+ import { readFileSync } from 'fs'
11
+ import { join } from 'path'
12
+ import {
13
+ verifyFundingTransaction,
14
+ type VerifyInput,
15
+ type ExpectedFunding,
16
+ type FundingAccounts,
17
+ } from './verify'
18
+ import { toRawAmount } from '../tools/solana'
19
+
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
+ }
30
+
31
+ const baseInput = (): VerifyInput => ({
32
+ transaction_base64: fx.transaction_base64,
33
+ declared: fx.declared,
34
+ accounts: fx.accounts,
35
+ 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),
42
+ mint: fx.mint,
43
+ sponsor: fx.sponsor,
44
+ },
45
+ expected_program_id: fx.program_id,
46
+ })
47
+
48
+ describe('accepts a genuine transaction', () => {
49
+ it('passes with no problems', async () => {
50
+ const r = await verifyFundingTransaction(baseInput())
51
+ expect(r.problems).toEqual([])
52
+ expect(r.ok).toBe(true)
53
+ })
54
+
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])
60
+ })
61
+ })
62
+
63
+ describe('refuses when the agent expected something else', () => {
64
+ it('catches an altered budget', async () => {
65
+ const input = baseInput()
66
+ input.expected.budget_raw = '999999999'
67
+ const r = await verifyFundingTransaction(input)
68
+ expect(r.ok).toBe(false)
69
+ expect(r.problems.join(' ')).toMatch(/budget/)
70
+ })
71
+
72
+ it('catches a substituted mint', async () => {
73
+ const input = baseInput()
74
+ input.expected.mint = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'
75
+ const r = await verifyFundingTransaction(input)
76
+ expect(r.ok).toBe(false)
77
+ expect(r.problems.join(' ')).toMatch(/mint/)
78
+ })
79
+
80
+ it('catches a different program, even when every parameter matches', async () => {
81
+ const input = baseInput()
82
+ input.expected_program_id = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
83
+ const r = await verifyFundingTransaction(input)
84
+ expect(r.ok).toBe(false)
85
+ expect(r.problems.join(' ')).toMatch(/program/)
86
+ })
87
+
88
+ it('catches a sponsor who is not the signer', async () => {
89
+ const input = baseInput()
90
+ input.expected.sponsor = '4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi'
91
+ const r = await verifyFundingTransaction(input)
92
+ expect(r.ok).toBe(false)
93
+ expect(r.problems.join(' ')).toMatch(/signer|fee payer/i)
94
+ })
95
+
96
+ 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()
99
+ input.expected.budget_raw = '1'
100
+ input.expected.mint = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'
101
+ const r = await verifyFundingTransaction(input)
102
+ expect(r.problems.length).toBeGreaterThan(1)
103
+ })
104
+ })
105
+
106
+ describe('refuses when the backend quoted one thing and built another', () => {
107
+ 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' }
113
+ input.expected.budget_raw = '1'
114
+ const r = await verifyFundingTransaction(input)
115
+ expect(r.ok).toBe(false)
116
+ expect(r.problems.join(' ')).toMatch(/described one thing and built another/)
117
+ })
118
+
119
+ it('catches a declared deadline that disagrees with the bytes', async () => {
120
+ const input = baseInput()
121
+ input.declared = { ...fx.declared, settlement_deadline: '1' }
122
+ input.expected.settlement_deadline = 1
123
+ const r = await verifyFundingTransaction(input)
124
+ expect(r.ok).toBe(false)
125
+ expect(r.problems.join(' ')).toMatch(/described one thing and built another/)
126
+ })
127
+ })
128
+
129
+ 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)
135
+ expect(r.ok).toBe(false)
136
+ expect(r.problems.join(' ')).toMatch(/not be escrowed/)
137
+ })
138
+
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)
144
+ expect(r.ok).toBe(false)
145
+ expect(r.problems.join(' ')).toMatch(/program-derived|holds its key/)
146
+ })
147
+
148
+ 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)
152
+ expect(r.ok).toBe(false)
153
+ expect(r.problems.join(' ')).toMatch(/seed_commit/)
154
+ })
155
+ })
156
+
157
+ describe('toRawAmount avoids the float trap', () => {
158
+ it('converts whole and fractional amounts exactly', () => {
159
+ expect(toRawAmount(10, 6)).toBe('10000000')
160
+ expect(toRawAmount(10.5, 6)).toBe('10500000')
161
+ expect(toRawAmount(0.5, 6)).toBe('500000')
162
+ })
163
+
164
+ it('handles the amount that float multiplication gets wrong', () => {
165
+ // 0.07 * 1e6 is 70000.00000000001 in IEEE 754 and truncates to 69999 — a silent one-unit
166
+ // shortfall whose on-chain rejection names neither the amount nor the cause.
167
+ expect(toRawAmount(0.07, 6)).toBe('70000')
168
+ expect(toRawAmount(0.29, 6)).toBe('290000')
169
+ expect(toRawAmount(1.005, 6)).toBe('1005000')
170
+ })
171
+
172
+ it('rejects more precision than the mint has', () => {
173
+ expect(() => toRawAmount(1.0000001, 6)).toThrow(/decimal places/)
174
+ })
175
+
176
+ it('rejects a negative amount', () => {
177
+ expect(() => toRawAmount(-1, 6)).toThrow()
178
+ })
179
+ })
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Decodes a funding transaction and checks it against what the agent asked for.
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.
9
+ *
10
+ * A human signing in Phantom gets that blob decoded for free by their wallet UI. **An agent has no
11
+ * wallet UI.** So without this, "the agent authorises exactly what it signs" is technically true and
12
+ * practically meaningless: it would be authorising bytes it cannot read. The design freeze calls
13
+ * this the least settled decision in the whole plan and makes the verifier a requirement of the
14
+ * flow, not an optional extra.
15
+ *
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.
22
+ *
23
+ * This reintroduces a dependency on the instruction layout, which partial signing was meant to
24
+ * remove. The difference is where that dependency lives: inside a package we version and ship,
25
+ * rather than as a contract every third party reimplements. That is the whole point, and it is
26
+ * honest to say the shape dependency is reduced rather than eliminated.
27
+ */
28
+
29
+ /** What the agent believes it is funding. Every field is compared. */
30
+ export interface ExpectedFunding {
31
+ /** Smallest-unit strings, as the API returns them. Compared as bigints so "10" and "10.0" or a
32
+ * leading zero cannot slip past a string equality check. */
33
+ budget: string;
34
+ base_pool: string;
35
+ lottery_winner_count: number;
36
+ lottery_prize_per_winner: string;
37
+ qualify_deadline: string;
38
+ settlement_deadline: string;
39
+ /** 64 hex chars, no 0x. */
40
+ seed_commit: string;
41
+ }
42
+
43
+ export interface FundingAccounts {
44
+ program_id: string;
45
+ config: string;
46
+ task: string;
47
+ vault: string;
48
+ whitelist: string;
49
+ mint: string;
50
+ sponsor: string;
51
+ sponsor_token_account: string;
52
+ }
53
+
54
+ export interface VerifyInput {
55
+ transaction_base64: string;
56
+ declared: ExpectedFunding;
57
+ accounts: FundingAccounts;
58
+ /** What the agent asked for, independent of what the backend replied. */
59
+ expected: {
60
+ budget_raw: string;
61
+ base_pool_raw: string;
62
+ lottery_winner_count: number;
63
+ lottery_prize_per_winner_raw: string;
64
+ qualify_deadline: number;
65
+ settlement_deadline: number;
66
+ mint: string;
67
+ sponsor: string;
68
+ };
69
+ /** The program the agent expects. Pinned so a transaction pointed at a different program is
70
+ * rejected even if every parameter matches. */
71
+ expected_program_id: string;
72
+ }
73
+
74
+ export interface VerifyResult {
75
+ ok: boolean;
76
+ /** Every discrepancy found, not just the first — an agent debugging this wants the whole list. */
77
+ problems: string[];
78
+ /** What the transaction actually contains, for logging even on success. */
79
+ summary: {
80
+ program_id: string;
81
+ instruction_count: number;
82
+ signers: string[];
83
+ budget: string;
84
+ mint: string;
85
+ task: string;
86
+ };
87
+ }
88
+
89
+ const eqAmount = (a: string, b: string): boolean => {
90
+ try {
91
+ return BigInt(a) === BigInt(b);
92
+ } catch {
93
+ return false;
94
+ }
95
+ };
96
+
97
+ /**
98
+ * Verifies a funding transaction. Returns every problem rather than throwing on the first.
99
+ *
100
+ * REFUSE TO SIGN when `ok` is false. The caller must treat this as a hard stop — a mismatch means
101
+ * the backend built something other than what was quoted, and signing it authorises that difference.
102
+ */
103
+ export async function verifyFundingTransaction(input: VerifyInput): Promise<VerifyResult> {
104
+ const { Transaction, PublicKey } = await import('@solana/web3.js');
105
+ const problems: string[] = [];
106
+
107
+ const tx = Transaction.from(Buffer.from(input.transaction_base64, 'base64'));
108
+
109
+ // ---- structure ------------------------------------------------------------------------------
110
+ // Exactly one instruction. An extra one is the cheapest way to hide something — a token transfer
111
+ // appended after create_task would be signed by the same signature.
112
+ if (tx.instructions.length !== 1) {
113
+ problems.push(
114
+ `expected exactly 1 instruction, found ${tx.instructions.length} — ` +
115
+ 'additional instructions would be authorised by the same signature',
116
+ );
117
+ }
118
+
119
+ const ix = tx.instructions[0];
120
+ const programId = ix?.programId?.toBase58() ?? '(none)';
121
+ if (programId !== input.expected_program_id) {
122
+ problems.push(`program is ${programId}, expected ${input.expected_program_id}`);
123
+ }
124
+ if (programId !== input.accounts.program_id) {
125
+ problems.push(
126
+ `transaction program ${programId} disagrees with the quoted accounts.program_id ` +
127
+ `${input.accounts.program_id}`,
128
+ );
129
+ }
130
+
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) {
134
+ problems.push(
135
+ `expected the sponsor ${input.expected.sponsor} to be the only signer, found [${signers.join(', ')}]`,
136
+ );
137
+ }
138
+ if (tx.feePayer?.toBase58() !== input.expected.sponsor) {
139
+ problems.push(
140
+ `fee payer is ${tx.feePayer?.toBase58() ?? '(unset)'}, expected ${input.expected.sponsor}`,
141
+ );
142
+ }
143
+
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],
149
+ ] as const) {
150
+ if (!keyList.includes(expected)) {
151
+ problems.push(`${label} ${expected} does not appear in the transaction's accounts`);
152
+ }
153
+ }
154
+ if (input.accounts.mint !== input.expected.mint) {
155
+ problems.push(`quoted mint ${input.accounts.mint}, expected ${input.expected.mint}`);
156
+ }
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.
160
+ if (input.accounts.vault === input.accounts.sponsor_token_account) {
161
+ problems.push('escrow vault equals the sponsor token account — the budget would not be escrowed');
162
+ }
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`);
172
+ }
173
+ }
174
+ } catch {
175
+ problems.push('task or vault is not a valid address');
176
+ }
177
+
178
+ // ---- the parameters --------------------------------------------------------------------------
179
+ const d = input.declared;
180
+ const e = input.expected;
181
+ if (!eqAmount(d.budget, e.budget_raw)) {
182
+ problems.push(`budget is ${d.budget}, expected ${e.budget_raw}`);
183
+ }
184
+ if (!eqAmount(d.base_pool, e.base_pool_raw)) {
185
+ problems.push(`base_pool is ${d.base_pool}, expected ${e.base_pool_raw}`);
186
+ }
187
+ if (d.lottery_winner_count !== e.lottery_winner_count) {
188
+ problems.push(
189
+ `lottery_winner_count is ${d.lottery_winner_count}, expected ${e.lottery_winner_count}`,
190
+ );
191
+ }
192
+ if (!eqAmount(d.lottery_prize_per_winner, e.lottery_prize_per_winner_raw)) {
193
+ problems.push(
194
+ `lottery_prize_per_winner is ${d.lottery_prize_per_winner}, expected ${e.lottery_prize_per_winner_raw}`,
195
+ );
196
+ }
197
+ if (!eqAmount(d.qualify_deadline, String(e.qualify_deadline))) {
198
+ problems.push(`qualify_deadline is ${d.qualify_deadline}, expected ${e.qualify_deadline}`);
199
+ }
200
+ if (!eqAmount(d.settlement_deadline, String(e.settlement_deadline))) {
201
+ problems.push(
202
+ `settlement_deadline is ${d.settlement_deadline}, expected ${e.settlement_deadline}`,
203
+ );
204
+ }
205
+ if (!/^[0-9a-f]{64}$/i.test(d.seed_commit)) {
206
+ problems.push(`seed_commit is not 32 bytes of hex: ${d.seed_commit}`);
207
+ }
208
+
209
+ // ---- the declared parameters must match the bytes actually being signed ----------------------
210
+ // The strongest check here. Everything above compares the backend's own JSON description against
211
+ // the agent's expectations; this compares that description against the instruction data itself, so
212
+ // 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>) {
230
+ const inBytes = String(encoded[key]);
231
+ const quoted = String((d as unknown as Record<string, unknown>)[key]);
232
+ const same =
233
+ key === 'seed_commit' || key === 'lottery_winner_count'
234
+ ? inBytes.toLowerCase() === quoted.toLowerCase()
235
+ : eqAmount(inBytes, quoted);
236
+ if (!same) {
237
+ problems.push(
238
+ `the transaction encodes ${key}=${inBytes} but the response quoted ${quoted} — ` +
239
+ 'the backend described one thing and built another',
240
+ );
241
+ }
242
+ }
243
+ }
244
+
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
+ };
257
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The agent's Solana wallet.
3
+ *
4
+ * An agent needs SOL as well as USDC to sponsor a Solana mission, and that is new — on Base it
5
+ * needed only ETH for gas and USDC for the budget. The rent line has no EVM analogue:
6
+ *
7
+ * Task account rent ~0.00374 SOL ($0.374) — partly recovered on close_task
8
+ * TaskVault token account ~0.00204 SOL ($0.204) — recovered in full on close_task
9
+ * transaction fees ~0.00004 SOL ($0.004)
10
+ * ---------------------------------------------------------------------
11
+ * locked during a mission ~0.00582 SOL (~$0.58)
12
+ * permanent cost ~0.00239 SOL (~$0.24) — the on-chain archive, kept on purpose
13
+ *
14
+ * That ~$0.24 never comes back, and it buys the on-chain evidence that makes the escrow and the
15
+ * frozen participant list checkable by anyone. On a $10 mission that is 2.4%; on a $1,000 mission,
16
+ * 0.024%.
17
+ *
18
+ * KEY HANDLING. The keypair stays in this process and is used to sign locally. It is never sent to
19
+ * the Solo API — the backend builds transactions and submits them, but only the agent can authorise
20
+ * one. That is the whole point of the partial-signing flow: a compromised backend cannot move a
21
+ * sponsor's funds, because it never holds the signing key.
22
+ */
23
+
24
+ import { readFileSync } from 'fs';
25
+
26
+ export interface SolanaWallet {
27
+ publicKey: string;
28
+ /** 64-byte ed25519 secret. Kept in-process; never transmitted. */
29
+ secretKey: Uint8Array;
30
+ }
31
+
32
+ export class SolanaWalletUnavailable extends Error {
33
+ constructor(reason: string) {
34
+ super(
35
+ `Solana wallet unavailable: ${reason}\n\n` +
36
+ 'Set one of:\n' +
37
+ ' SOLO_SOLANA_KEYPAIR - JSON byte array, as `solana-keygen` writes it\n' +
38
+ ' SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json)\n\n' +
39
+ 'The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a\n' +
40
+ 'refundable deposit, not a fee: most of it returns when the task is closed.',
41
+ );
42
+ this.name = 'SolanaWalletUnavailable';
43
+ }
44
+ }
45
+
46
+ function parseKeypairBytes(raw: string): Uint8Array {
47
+ const trimmed = raw.trim();
48
+ if (!trimmed.startsWith('[')) {
49
+ throw new SolanaWalletUnavailable(
50
+ 'value is not a JSON byte array — this is the format `solana-keygen new` writes',
51
+ );
52
+ }
53
+ let parsed: unknown;
54
+ try {
55
+ parsed = JSON.parse(trimmed);
56
+ } catch {
57
+ throw new SolanaWalletUnavailable('value looks like a JSON array but does not parse');
58
+ }
59
+ if (!Array.isArray(parsed) || !parsed.every((n) => typeof n === 'number')) {
60
+ throw new SolanaWalletUnavailable('JSON array must contain only numbers');
61
+ }
62
+ const bytes = Uint8Array.from(parsed as number[]);
63
+ // 64 bytes = 32 seed + 32 public. A 32-byte value is the seed alone, which signs but yields a
64
+ // different address — surfacing later as "wrong sponsor" rather than as a key problem.
65
+ if (bytes.length !== 64) {
66
+ throw new SolanaWalletUnavailable(
67
+ `expected 64 bytes, got ${bytes.length}` +
68
+ (bytes.length === 32 ? ' — this is the seed alone, not the full keypair' : ''),
69
+ );
70
+ }
71
+ return bytes;
72
+ }
73
+
74
+ /**
75
+ * Loads the agent's wallet.
76
+ *
77
+ * Read at call time, not at module load, so a tool that never touches Solana works in a process
78
+ * with no wallet configured at all — an agent running Base missions should not need one.
79
+ */
80
+ export async function loadSolanaWallet(): Promise<SolanaWallet> {
81
+ const inline = process.env.SOLO_SOLANA_KEYPAIR;
82
+ const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
83
+
84
+ let raw: string;
85
+ if (inline && inline.trim() !== '') {
86
+ raw = inline;
87
+ } else if (path && path.trim() !== '') {
88
+ try {
89
+ raw = readFileSync(path.replace(/^~/, process.env.HOME ?? '~'), 'utf8');
90
+ } catch (e) {
91
+ throw new SolanaWalletUnavailable(`cannot read ${path}: ${(e as Error).message}`);
92
+ }
93
+ } else {
94
+ throw new SolanaWalletUnavailable('neither SOLO_SOLANA_KEYPAIR nor SOLO_SOLANA_KEYPAIR_PATH is set');
95
+ }
96
+
97
+ const secretKey = parseKeypairBytes(raw);
98
+ const { Keypair } = await import('@solana/web3.js');
99
+ const kp = Keypair.fromSecretKey(secretKey);
100
+ return { publicKey: kp.publicKey.toBase58(), secretKey };
101
+ }
102
+
103
+ /** Whether a Solana wallet is configured, without throwing. */
104
+ export function hasSolanaWallet(): boolean {
105
+ return Boolean(
106
+ (process.env.SOLO_SOLANA_KEYPAIR ?? '').trim() ||
107
+ (process.env.SOLO_SOLANA_KEYPAIR_PATH ?? '').trim(),
108
+ );
109
+ }
110
+
111
+ /**
112
+ * The associated token account for a mint — where the wallet's USDC actually lives.
113
+ *
114
+ * An agent commonly has SOL but no token account, because one is only created when tokens first
115
+ * arrive. `create_task` reads the sponsor's token account, so funding fails if it does not exist —
116
+ * and the error names the account, not the missing balance, which is confusing enough to be worth
117
+ * checking for explicitly.
118
+ */
119
+ export async function associatedTokenAddress(mint: string, owner: string): Promise<string> {
120
+ const { PublicKey } = await import('@solana/web3.js');
121
+ const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
122
+ const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
123
+ const [address] = PublicKey.findProgramAddressSync(
124
+ [new PublicKey(owner).toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), new PublicKey(mint).toBuffer()],
125
+ ASSOCIATED_TOKEN_PROGRAM_ID,
126
+ );
127
+ return address.toBase58();
128
+ }
129
+
130
+ /** Signs a base64 transaction built by the backend, returning it base64-encoded. */
131
+ export async function signTransaction(
132
+ transactionBase64: string,
133
+ wallet: SolanaWallet,
134
+ ): Promise<string> {
135
+ const { Keypair, Transaction } = await import('@solana/web3.js');
136
+ const kp = Keypair.fromSecretKey(wallet.secretKey);
137
+ const tx = Transaction.from(Buffer.from(transactionBase64, 'base64'));
138
+ // partialSign, not sign: the transaction may carry other signature slots, and `sign` would
139
+ // discard them.
140
+ tx.partialSign(kp);
141
+ return tx.serialize().toString('base64');
142
+ }