@projectsolo/solo-mission-mcp 0.19.2 → 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.
- package/.env.example +1 -1
- package/.github/workflows/release.yml +13 -3
- package/dist/chunk-NXOOPOSF.js +79 -0
- package/dist/client-2NLDPRAH.js +12 -0
- package/dist/index.js +183 -74
- package/dist/verify-KAETIGV5.js +136 -0
- package/dist/wallet-IUQWBW6F.js +90 -0
- package/package.json +7 -4
- package/src/index.ts +4 -1
- package/src/scripts/check-tools-against-spec.ts +137 -0
- package/src/solana/fixtures/funding-transaction.json +26 -0
- package/src/solana/verify.test.ts +179 -0
- package/src/solana/verify.ts +257 -0
- package/src/solana/wallet.ts +142 -0
- package/src/tools/solana.ts +254 -0
- package/vitest.config.ts +4 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solana funding tools.
|
|
3
|
+
*
|
|
4
|
+
* Additive. Every existing tool behaves identically, `create_mission` without `chain` still creates
|
|
5
|
+
* a Base mission, and an agent that never touches Solana needs no wallet configured. That matters
|
|
6
|
+
* because this is a published package with third-party consumers: a breaking change to the funding
|
|
7
|
+
* shape would break integrations we cannot see, cannot test, and cannot fix.
|
|
8
|
+
*
|
|
9
|
+
* The funding flow is deliberately ONE tool, not three. Build → verify → sign → submit as separate
|
|
10
|
+
* calls would let an agent skip the verify step, and the verify step is what makes signing an opaque
|
|
11
|
+
* transaction safe. `fund_solana_mission` refuses to sign on any mismatch and returns the problems
|
|
12
|
+
* instead.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
16
|
+
|
|
17
|
+
export const solanaTools: Tool[] = [
|
|
18
|
+
{
|
|
19
|
+
name: 'get_solana_config',
|
|
20
|
+
description:
|
|
21
|
+
'Read the Solana escrow deployment: program id, cluster, RPC endpoint, accepted mints and their decimals, and the minimum first payout. Call this before funding so you use a whitelisted mint — a mint that is not whitelisted is rejected on chain, not by the API. Requires no wallet.',
|
|
22
|
+
inputSchema: { type: 'object', properties: {} },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'get_solana_wallet',
|
|
26
|
+
description:
|
|
27
|
+
"Show your Solana wallet address and its balances. Reports SOL (needed for transaction fees and for account rent) and the token balance for a given mint (the mission budget). Use this before funding: an agent needs BOTH, and the rent line has no equivalent on Base. Rent is a refundable deposit, not a fee — most of it returns when the task is closed. If no wallet is configured this explains how to set one up.",
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
mint: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description:
|
|
34
|
+
'Mint to report a balance for. Defaults to the deployment payout mint from get_solana_config.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'fund_solana_mission',
|
|
41
|
+
description:
|
|
42
|
+
"Fund a Solana mission end to end: the backend builds the escrow transaction, this tool DECODES AND VERIFIES it against the parameters you expect, signs it locally with your wallet, and submits it. Your key never leaves this process.\n\nVerification is not optional and cannot be skipped. On Solana the backend builds the transaction rather than publishing a parameter set for you to rebuild, so without a decode you would be signing bytes you cannot read. This tool refuses to sign if anything differs from what you expect — a substituted mint, an altered budget, an extra instruction, a vault that is not a program-derived address — and returns the discrepancies instead.\n\nCall create_mission with chain='solana' first; pass that mission's id here.",
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
mission_id: { type: 'string', description: 'Mission created with chain="solana".' },
|
|
47
|
+
expected_budget: {
|
|
48
|
+
type: 'number',
|
|
49
|
+
description:
|
|
50
|
+
'The total budget in whole tokens (e.g. 10 for 10 USDC) you expect to escrow. Verified against the transaction before signing. Pass what you intended, NOT what the API told you — comparing the API to itself proves nothing.',
|
|
51
|
+
},
|
|
52
|
+
expected_mint: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description:
|
|
55
|
+
'The mint you expect the budget to be taken in. Verified before signing. Defaults to the deployment payout mint.',
|
|
56
|
+
},
|
|
57
|
+
dry_run: {
|
|
58
|
+
type: 'boolean',
|
|
59
|
+
description:
|
|
60
|
+
'Build and verify, then stop without signing or submitting. Use this to inspect what would be signed. Nothing is escrowed and no fee is paid.',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ['mission_id', 'expected_budget'],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whole tokens to smallest units, without floating point.
|
|
70
|
+
*
|
|
71
|
+
* `10.5 * 1e6` is exact, but `0.07 * 1e6` is 70000.00000000001 and truncates to 69999 — a silent
|
|
72
|
+
* one-unit shortfall that makes the on-chain budget check fail with an error naming neither the
|
|
73
|
+
* amount nor the cause. Done as a decimal-string shift instead.
|
|
74
|
+
*/
|
|
75
|
+
export function toRawAmount(amount: number, decimals: number): string {
|
|
76
|
+
const s = amount.toString();
|
|
77
|
+
if (!/^\d+(\.\d+)?$/.test(s)) {
|
|
78
|
+
throw new Error(`amount must be a non-negative decimal number, got ${s}`);
|
|
79
|
+
}
|
|
80
|
+
const [whole, frac = ''] = s.split('.');
|
|
81
|
+
if (frac.length > decimals) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`amount ${s} has ${frac.length} decimal places but the mint has only ${decimals}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return (whole + frac.padEnd(decimals, '0')).replace(/^0+(?=\d)/, '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const SOLANA_TOOL_NAMES = new Set(solanaTools.map((t) => t.name));
|
|
90
|
+
|
|
91
|
+
export async function handleSolanaTool(
|
|
92
|
+
name: string,
|
|
93
|
+
args: Record<string, any>,
|
|
94
|
+
): Promise<unknown> {
|
|
95
|
+
const { apiGet, apiPost } = await import('../api/client.js');
|
|
96
|
+
|
|
97
|
+
switch (name) {
|
|
98
|
+
case 'get_solana_config':
|
|
99
|
+
return apiGet('/agent/solana/config');
|
|
100
|
+
|
|
101
|
+
case 'get_solana_wallet': {
|
|
102
|
+
const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import(
|
|
103
|
+
'../solana/wallet.js'
|
|
104
|
+
);
|
|
105
|
+
if (!hasSolanaWallet()) {
|
|
106
|
+
return {
|
|
107
|
+
configured: false,
|
|
108
|
+
how_to_configure: {
|
|
109
|
+
option_1: 'SOLO_SOLANA_KEYPAIR — JSON byte array, as `solana-keygen new` writes it',
|
|
110
|
+
option_2: 'SOLO_SOLANA_KEYPAIR_PATH — path to that file, e.g. ~/.config/solana/id.json',
|
|
111
|
+
},
|
|
112
|
+
what_you_need:
|
|
113
|
+
'SOL for transaction fees and account rent, plus the payout token for the budget. ' +
|
|
114
|
+
'Rent is a refundable deposit, not a fee — most of it returns on close_task. ' +
|
|
115
|
+
'Roughly $0.58 is locked per mission and about $0.24 is permanent, which buys the ' +
|
|
116
|
+
'on-chain record that makes the escrow verifiable by anyone.',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const wallet = await loadSolanaWallet();
|
|
121
|
+
const cfg = (await apiGet('/agent/solana/config')) as {
|
|
122
|
+
rpc_url: string;
|
|
123
|
+
mints: Record<string, string>;
|
|
124
|
+
decimals: Record<string, number>;
|
|
125
|
+
};
|
|
126
|
+
const symbol = Object.keys(cfg.mints)[0];
|
|
127
|
+
const mint = args.mint ?? cfg.mints[symbol];
|
|
128
|
+
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
129
|
+
|
|
130
|
+
// Read balances directly from the cluster rather than through our API. An agent checking
|
|
131
|
+
// whether it can afford a mission should not have to trust us for the answer.
|
|
132
|
+
const rpc = async (method: string, params: unknown[]) => {
|
|
133
|
+
const res = await fetch(cfg.rpc_url, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
137
|
+
});
|
|
138
|
+
return (await res.json()) as { result?: any; error?: { message: string } };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const solRes = await rpc('getBalance', [wallet.publicKey]);
|
|
142
|
+
const tokRes = await rpc('getTokenAccountBalance', [tokenAccount]);
|
|
143
|
+
const lamports = solRes.result?.value ?? 0;
|
|
144
|
+
const decimals = cfg.decimals[symbol] ?? 6;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
configured: true,
|
|
148
|
+
address: wallet.publicKey,
|
|
149
|
+
sol: lamports / 1e9,
|
|
150
|
+
// A token account only exists once tokens first arrive. create_task reads it, so funding
|
|
151
|
+
// fails without one — and the on-chain error names the account rather than the missing
|
|
152
|
+
// balance, which is confusing enough to call out explicitly.
|
|
153
|
+
token_account: tokenAccount,
|
|
154
|
+
token_account_exists: !tokRes.error,
|
|
155
|
+
token_balance: tokRes.result?.value?.uiAmountString ?? '0',
|
|
156
|
+
mint,
|
|
157
|
+
can_pay_fees: lamports > 10_000_000, // ~0.01 SOL — comfortably covers rent plus fees
|
|
158
|
+
note:
|
|
159
|
+
lamports === 0
|
|
160
|
+
? 'No SOL. Funding will fail with "Attempt to debit an account but found no record of a prior credit", which does not mention SOL.'
|
|
161
|
+
: undefined,
|
|
162
|
+
_decimals: decimals,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case 'fund_solana_mission': {
|
|
167
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import(
|
|
168
|
+
'../solana/wallet.js'
|
|
169
|
+
);
|
|
170
|
+
const { verifyFundingTransaction } = await import('../solana/verify.js');
|
|
171
|
+
|
|
172
|
+
const wallet = await loadSolanaWallet();
|
|
173
|
+
const cfg = (await apiGet('/agent/solana/config')) as {
|
|
174
|
+
program_id: string;
|
|
175
|
+
mints: Record<string, string>;
|
|
176
|
+
decimals: Record<string, number>;
|
|
177
|
+
};
|
|
178
|
+
const symbol = Object.keys(cfg.mints)[0];
|
|
179
|
+
const mint = args.expected_mint ?? cfg.mints[symbol];
|
|
180
|
+
const decimals = cfg.decimals[symbol] ?? 6;
|
|
181
|
+
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
182
|
+
|
|
183
|
+
const built = (await apiPost(
|
|
184
|
+
`/agent/solana/missions/${args.mission_id}/funding-transaction`,
|
|
185
|
+
{ sponsor_wallet: wallet.publicKey, sponsor_token_account: tokenAccount },
|
|
186
|
+
)) as {
|
|
187
|
+
transaction_base64: string;
|
|
188
|
+
task_id: string;
|
|
189
|
+
declared: Record<string, any>;
|
|
190
|
+
accounts: Record<string, string>;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const expectedBudgetRaw = toRawAmount(args.expected_budget, decimals);
|
|
194
|
+
|
|
195
|
+
const verdict = await verifyFundingTransaction({
|
|
196
|
+
transaction_base64: built.transaction_base64,
|
|
197
|
+
declared: built.declared as any,
|
|
198
|
+
accounts: built.accounts as any,
|
|
199
|
+
expected: {
|
|
200
|
+
budget_raw: expectedBudgetRaw,
|
|
201
|
+
// base_pool is derived by the backend from reward × max_humans. The agent's check on it
|
|
202
|
+
// is the quoted value against the encoded bytes, which verifyFundingTransaction does —
|
|
203
|
+
// asserting a locally recomputed figure would require duplicating that arithmetic here
|
|
204
|
+
// and would fail on a legitimately rounded reward.
|
|
205
|
+
base_pool_raw: String(built.declared.base_pool),
|
|
206
|
+
lottery_winner_count: Number(built.declared.lottery_winner_count),
|
|
207
|
+
lottery_prize_per_winner_raw: String(built.declared.lottery_prize_per_winner),
|
|
208
|
+
qualify_deadline: Number(built.declared.qualify_deadline),
|
|
209
|
+
settlement_deadline: Number(built.declared.settlement_deadline),
|
|
210
|
+
mint,
|
|
211
|
+
sponsor: wallet.publicKey,
|
|
212
|
+
},
|
|
213
|
+
expected_program_id: cfg.program_id,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (!verdict.ok) {
|
|
217
|
+
// Hard stop. A mismatch means the backend built something other than what was quoted, and
|
|
218
|
+
// signing would authorise that difference.
|
|
219
|
+
return {
|
|
220
|
+
funded: false,
|
|
221
|
+
refused_to_sign: true,
|
|
222
|
+
problems: verdict.problems,
|
|
223
|
+
summary: verdict.summary,
|
|
224
|
+
what_this_means:
|
|
225
|
+
'The transaction does not match what you asked for, so it was NOT signed and nothing ' +
|
|
226
|
+
'was escrowed. This is the verifier doing its job. Do not retry blindly — the ' +
|
|
227
|
+
'discrepancy above is either a bug or an attempt to have you authorise something else.',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (args.dry_run) {
|
|
232
|
+
return {
|
|
233
|
+
funded: false,
|
|
234
|
+
dry_run: true,
|
|
235
|
+
verified: true,
|
|
236
|
+
task_id: built.task_id,
|
|
237
|
+
summary: verdict.summary,
|
|
238
|
+
would_escrow: `${args.expected_budget} (${expectedBudgetRaw} raw) of ${mint}`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const signed = await signTransaction(built.transaction_base64, wallet);
|
|
243
|
+
const confirmed = await apiPost(
|
|
244
|
+
`/agent/solana/missions/${args.mission_id}/confirm-funding`,
|
|
245
|
+
{ signed_transaction: signed, task_id: built.task_id },
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return { funded: true, verified: true, ...(confirmed as Record<string, unknown>) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
default:
|
|
252
|
+
throw new Error(`Unknown Solana tool: ${name}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
// Node environment, unit-scoped. What needs covering is transaction decoding and the refusal
|
|
3
|
+
// logic — the part that can lose an agent's funds.
|
|
4
|
+
export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'] } })
|