@projectsolo/solo-mission-mcp 0.21.13 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEVELOPER_README.md +1 -1
- package/README.md +96 -12
- package/dist/chunk-NUGGTARW.js +196 -0
- package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
- package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
- package/dist/deployment-UCUWVIPJ.js +43 -0
- package/dist/escrowErrors-4XSAYLGT.js +56 -0
- package/dist/index.js +244 -162
- package/dist/verify-7EUVRUAS.js +445 -0
- package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
- package/dist/wire-RLDY4AJR.js +24 -0
- package/package.json +1 -1
- package/src/config.ts +11 -0
- package/src/index.ts +1 -1
- package/src/scripts/check-tools-against-spec.ts +3 -3
- package/src/solana/deployment.test.ts +61 -0
- package/src/solana/deployment.ts +89 -0
- package/src/solana/escrowErrors.ts +78 -0
- package/src/solana/fixtures/config-account.json +6 -0
- package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
- package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
- package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
- package/src/solana/verify.test.ts +605 -78
- package/src/solana/verify.ts +545 -84
- package/src/solana/wallet.test.ts +85 -2
- package/src/solana/wallet.ts +74 -31
- package/src/solana/wire.test.ts +129 -0
- package/src/solana/wire.ts +264 -0
- package/src/tools/missions.ts +56 -119
- package/src/tools/solana.test.ts +356 -0
- package/src/tools/solana.ts +309 -70
- package/src/tools/tracks.ts +1 -1
- package/dist/verify-KAETIGV5.js +0 -136
- /package/src/solana/fixtures/{funding-transaction.json → funding-transaction-v1.json} +0 -0
package/src/tools/solana.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Solana funding tools.
|
|
2
|
+
* Solana funding tools — the only on-chain path this package exposes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* for any on-chain mission that resolves to `chain: 'base'`, including the pre-Solana default of
|
|
8
|
-
* omitting `chain` entirely. An agent that wants a paid/on-chain mission must pass `chain: 'solana'`
|
|
9
|
-
* explicitly and configure a wallet; only free off-chain missions (no `budget`) work with no chain
|
|
10
|
-
* configuration at all. See create_mission's own description for the current, authoritative state.
|
|
4
|
+
* A paid mission (one with a `budget`) is escrowed on Solana: `create_mission` always sends
|
|
5
|
+
* `chain: 'solana'` for it, and funding or refunding it needs a wallet configured here. Free
|
|
6
|
+
* off-chain missions (no `budget`) need no wallet and no chain configuration at all.
|
|
11
7
|
*
|
|
12
8
|
* The funding flow is deliberately ONE tool, not three. Build → verify → sign → submit as separate
|
|
13
9
|
* calls would let an agent skip the verify step, and the verify step is what makes signing an opaque
|
|
@@ -21,13 +17,13 @@ export const solanaTools: Tool[] = [
|
|
|
21
17
|
{
|
|
22
18
|
name: 'get_solana_config',
|
|
23
19
|
description:
|
|
24
|
-
'Read the Solana escrow deployment: program id, cluster, RPC endpoint, accepted mints and their decimals,
|
|
20
|
+
'Read the Solana escrow deployment: program id, cluster, RPC endpoint, accepted mints and their decimals, the minimum first payout, and escrow_interface — the program interface funding transactions use. This server verifies only escrow_interface \'v2\' and refuses to fund against anything else. 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.',
|
|
25
21
|
inputSchema: { type: 'object', properties: {} },
|
|
26
22
|
},
|
|
27
23
|
{
|
|
28
24
|
name: 'get_solana_wallet',
|
|
29
25
|
description:
|
|
30
|
-
"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
|
|
26
|
+
"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 is easy to miss. 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.",
|
|
31
27
|
inputSchema: {
|
|
32
28
|
type: 'object',
|
|
33
29
|
properties: {
|
|
@@ -42,11 +38,11 @@ export const solanaTools: Tool[] = [
|
|
|
42
38
|
{
|
|
43
39
|
name: 'fund_solana_mission',
|
|
44
40
|
description:
|
|
45
|
-
"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,
|
|
41
|
+
"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 or deadline, an extra instruction, an account that is not the program-derived address, a raised fee ceiling, a lottery co-signer that is not the program's Operator — and returns the discrepancies instead.\n\nThe escrow program id is pinned in this package per cluster (SOLO_SOLANA_PROGRAM_ID can pin another) and the API's program_id must match it; the mint's decimals, which scale expected_budget, are read from chain.\n\nEscrow interface v2 (the only one this server verifies; get_solana_config reports it):\n- max_fee_bps: create_task carries the highest platform fee the escrow may take. It must equal the fee rate create_mission quoted you (mission.solana_quoted_fee_bps). If the program's fee has risen above it by the time the transaction lands, create_task fails with FeeAboveSponsorMax instead of charging you more; cancel the mission and create a new one for a fresh quote.\n- Lottery co-signature: a mission with lottery_winner_count > 0 must be co-signed by the platform Operator, so the transaction arrives already signed by it. This tool checks that the co-signer is the Operator in the program's on-chain Config (read directly from the cluster, not from the API), that its ed25519 signature is valid over the exact message, and that you, not the Operator, pay the fee. It then adds ONLY your signature and leaves the Operator's untouched; any change to the bytes would invalidate it. A non-lottery transaction must carry no co-signer.\n\nCall create_mission with a budget first and pass that mission's id here. Fund it within 24 hours: an unfunded mission is cancelled automatically at funding_params.expires_at. A media_review mission needs at least one ready track (add_mission_track) before funding, or this returns 409 before anything is signed.",
|
|
46
42
|
inputSchema: {
|
|
47
43
|
type: 'object',
|
|
48
44
|
properties: {
|
|
49
|
-
mission_id: { type: 'string', description: '
|
|
45
|
+
mission_id: { type: 'string', description: 'A paid (budget-bearing) mission from create_mission, still in pending_funding.' },
|
|
50
46
|
expected_budget: {
|
|
51
47
|
type: 'number',
|
|
52
48
|
description:
|
|
@@ -55,7 +51,13 @@ export const solanaTools: Tool[] = [
|
|
|
55
51
|
expected_mint: {
|
|
56
52
|
type: 'string',
|
|
57
53
|
description:
|
|
58
|
-
|
|
54
|
+
"The mint you expect the budget to be taken in. Verified before signing. Defaults to the mint create_mission recorded for this mission (its funding_params.mint).",
|
|
55
|
+
},
|
|
56
|
+
expected_max_fee_bps: {
|
|
57
|
+
type: 'integer',
|
|
58
|
+
minimum: 0,
|
|
59
|
+
description:
|
|
60
|
+
"The platform fee rate, in basis points, that create_mission quoted you: its response's mission.solana_quoted_fee_bps. Verified against the transaction's max_fee_bps before signing. Defaults to the quote recorded on the mission.",
|
|
59
61
|
},
|
|
60
62
|
dry_run: {
|
|
61
63
|
type: 'boolean',
|
|
@@ -69,7 +71,7 @@ export const solanaTools: Tool[] = [
|
|
|
69
71
|
{
|
|
70
72
|
name: 'refund_solana_mission',
|
|
71
73
|
description:
|
|
72
|
-
"Get a Solana mission's escrowed funds back to the sponsor. Covers all three routes
|
|
74
|
+
"Get a Solana mission's escrowed funds back to the sponsor. Covers all three routes:\n- cancel: while the task is funded and strictly before qualify_deadline (the end of the hiring window). The escrow program refuses a cancel from qualify_deadline on (TooLateToCancel), even one built earlier, because by then the participants' work is done.\n- emergency_refund: once settlement_deadline has passed and the platform has not settled. It stays available after the cancel cut-off, and a pause does not block it.\n- claim_refund: leftover budget after a settle (mission status refundable).\n\nSame flow as funding: the backend builds the transaction, this tool VERIFIES it, signs locally, and submits. Your key never leaves this process.\n\nCall with no action to ask what is currently available — the response lists the legal actions for the mission's state rather than guessing. If the backend or the chain rejects an action, the response says which one did, names the program error, and lists the actions that remain.",
|
|
73
75
|
inputSchema: {
|
|
74
76
|
type: 'object',
|
|
75
77
|
properties: {
|
|
@@ -113,26 +115,64 @@ export function toRawAmount(amount: number, decimals: number): string {
|
|
|
113
115
|
|
|
114
116
|
export const SOLANA_TOOL_NAMES = new Set(solanaTools.map((t) => t.name));
|
|
115
117
|
|
|
118
|
+
interface SolanaConfigResponse {
|
|
119
|
+
cluster?: string;
|
|
120
|
+
program_id: string;
|
|
121
|
+
rpc_url: string;
|
|
122
|
+
mints: Record<string, string>;
|
|
123
|
+
decimals: Record<string, number>;
|
|
124
|
+
escrow_interface?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The mission fields the Solana tools read, as `GET /agent/missions/:id` returns them. */
|
|
128
|
+
interface SolanaMissionRecord {
|
|
129
|
+
token_address?: string;
|
|
130
|
+
onchain_status?: string;
|
|
131
|
+
qualify_deadline?: number;
|
|
132
|
+
settlement_deadline?: number;
|
|
133
|
+
lottery_winner_count?: number;
|
|
134
|
+
lottery_prize_per_winner_raw?: string;
|
|
135
|
+
seed_commit?: string;
|
|
136
|
+
solana_quoted_fee_bps?: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The body of an API error (`ApiResponseError.data`), with the shapes other clients use kept. */
|
|
140
|
+
function apiErrorBody(e: any): { message?: string; available_actions?: string[]; [k: string]: unknown } {
|
|
141
|
+
return e?.data ?? e?.response?.data ?? e?.body ?? {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const CANCEL_CUTOFF_RULE =
|
|
145
|
+
'cancel is refused on chain from qualify_deadline on (TooLateToCancel, error 6125); ' +
|
|
146
|
+
'emergency_refund opens once settlement_deadline has passed without a settlement.';
|
|
147
|
+
|
|
116
148
|
export async function handleSolanaTool(
|
|
117
149
|
name: string,
|
|
118
150
|
args: Record<string, any>,
|
|
119
151
|
): Promise<unknown> {
|
|
120
152
|
const { apiGet, apiPost } = await import('../api/client.js');
|
|
153
|
+
const { resolvePinnedProgram, resolveTrustedRpc, solanaRpcUrl } = await import(
|
|
154
|
+
'../solana/deployment.js'
|
|
155
|
+
);
|
|
121
156
|
|
|
122
157
|
switch (name) {
|
|
123
158
|
case 'get_solana_config':
|
|
124
159
|
return apiGet('/agent/solana/config');
|
|
125
160
|
|
|
126
161
|
case 'get_solana_wallet': {
|
|
127
|
-
const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } =
|
|
128
|
-
'../solana/wallet.js'
|
|
129
|
-
);
|
|
162
|
+
const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress, KEY_STORAGE_GUIDANCE } =
|
|
163
|
+
await import('../solana/wallet.js');
|
|
130
164
|
if (!hasSolanaWallet()) {
|
|
131
165
|
return {
|
|
132
166
|
configured: false,
|
|
133
167
|
how_to_configure: {
|
|
134
|
-
|
|
135
|
-
|
|
168
|
+
recommended:
|
|
169
|
+
'SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE — a keyfile ' +
|
|
170
|
+
'encrypted with `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256`, plus a ' +
|
|
171
|
+
'separate passphrase file (chmod 600 both)',
|
|
172
|
+
alternative:
|
|
173
|
+
'SOLO_SOLANA_KEYPAIR_PATH — path to a plaintext `solana-keygen` keyfile, e.g. ' +
|
|
174
|
+
'~/.config/solana/id.json (chmod 600)',
|
|
175
|
+
key_storage: KEY_STORAGE_GUIDANCE,
|
|
136
176
|
},
|
|
137
177
|
what_you_need:
|
|
138
178
|
'SOL for transaction fees and account rent, plus the payout token for the budget. ' +
|
|
@@ -143,11 +183,7 @@ export async function handleSolanaTool(
|
|
|
143
183
|
}
|
|
144
184
|
|
|
145
185
|
const wallet = await loadSolanaWallet();
|
|
146
|
-
const cfg = (await apiGet('/agent/solana/config')) as
|
|
147
|
-
rpc_url: string;
|
|
148
|
-
mints: Record<string, string>;
|
|
149
|
-
decimals: Record<string, number>;
|
|
150
|
-
};
|
|
186
|
+
const cfg = (await apiGet('/agent/solana/config')) as SolanaConfigResponse;
|
|
151
187
|
const symbol = Object.keys(cfg.mints)[0];
|
|
152
188
|
const mint = args.mint ?? cfg.mints[symbol];
|
|
153
189
|
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
@@ -155,7 +191,7 @@ export async function handleSolanaTool(
|
|
|
155
191
|
// Read balances directly from the cluster rather than through our API. An agent checking
|
|
156
192
|
// whether it can afford a mission should not have to trust us for the answer.
|
|
157
193
|
const rpc = async (method: string, params: unknown[]) => {
|
|
158
|
-
const res = await fetch(cfg
|
|
194
|
+
const res = await fetch(solanaRpcUrl(cfg), {
|
|
159
195
|
method: 'POST',
|
|
160
196
|
headers: { 'Content-Type': 'application/json' },
|
|
161
197
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
@@ -192,17 +228,79 @@ export async function handleSolanaTool(
|
|
|
192
228
|
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import(
|
|
193
229
|
'../solana/wallet.js'
|
|
194
230
|
);
|
|
195
|
-
const { verifyFundingTransaction } =
|
|
231
|
+
const { verifyFundingTransaction, readEscrowConfig, readMintDecimals, SUPPORTED_ESCROW_INTERFACE } =
|
|
232
|
+
await import('../solana/verify.js');
|
|
196
233
|
|
|
197
234
|
const wallet = await loadSolanaWallet();
|
|
198
|
-
const cfg = (await apiGet('/agent/solana/config')) as
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
235
|
+
const cfg = (await apiGet('/agent/solana/config')) as SolanaConfigResponse;
|
|
236
|
+
|
|
237
|
+
// Decoding against the wrong layout would compare the wrong bytes. Refuse before anything is
|
|
238
|
+
// built rather than verify a transaction this version cannot read.
|
|
239
|
+
if (cfg.escrow_interface !== SUPPORTED_ESCROW_INTERFACE) {
|
|
240
|
+
return {
|
|
241
|
+
funded: false,
|
|
242
|
+
refused_to_sign: true,
|
|
243
|
+
problems: [
|
|
244
|
+
`the deployment reports escrow_interface ${JSON.stringify(cfg.escrow_interface)}; this ` +
|
|
245
|
+
`version of @projectsolo/solo-mission-mcp verifies only '${SUPPORTED_ESCROW_INTERFACE}' ` +
|
|
246
|
+
'funding transactions',
|
|
247
|
+
],
|
|
248
|
+
what_this_means:
|
|
249
|
+
'Nothing was built, signed or escrowed. This server cannot decode the funding ' +
|
|
250
|
+
'transaction the deployment would build, so it will not sign one. Use a version of ' +
|
|
251
|
+
'this package that supports the reported escrow interface.',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The program is pinned here, not taken from the API: every derived address and the
|
|
256
|
+
// co-signer check hang off it, and the API is the party being checked.
|
|
257
|
+
const pin = resolvePinnedProgram(cfg);
|
|
258
|
+
const rpc = resolveTrustedRpc(cfg);
|
|
259
|
+
if ('problem' in pin || 'problem' in rpc) {
|
|
260
|
+
return {
|
|
261
|
+
funded: false,
|
|
262
|
+
refused_to_sign: true,
|
|
263
|
+
problems: [
|
|
264
|
+
...('problem' in pin ? [pin.problem] : []),
|
|
265
|
+
...('problem' in rpc ? [rpc.problem] : []),
|
|
266
|
+
],
|
|
267
|
+
what_this_means: 'Nothing was built, signed or escrowed.',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const rpcUrl = rpc.rpc_url;
|
|
271
|
+
|
|
272
|
+
// The mission as create_mission recorded it — the quote the agent was shown, including the
|
|
273
|
+
// fee rate (solana_quoted_fee_bps) that max_fee_bps must equal, and the mint.
|
|
274
|
+
const { mission } = (await apiGet(`/agent/missions/${args.mission_id}`)) as {
|
|
275
|
+
mission?: SolanaMissionRecord;
|
|
202
276
|
};
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
277
|
+
if (!mission) {
|
|
278
|
+
return {
|
|
279
|
+
funded: false,
|
|
280
|
+
error: `Could not read mission ${args.mission_id} — call get_mission to check it exists.`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The mission's own mint unless the agent names one: get_solana_config lists both USDC and
|
|
285
|
+
// TEST_USDC, and the backend can be configured to create missions in either, so the first
|
|
286
|
+
// listed mint is a guess that refuses a mission created in the other.
|
|
287
|
+
const mint: string =
|
|
288
|
+
args.expected_mint ?? mission.token_address ?? cfg.mints[Object.keys(cfg.mints)[0]];
|
|
289
|
+
// Scales expected_budget, so it is read from the mint account, not from the API.
|
|
290
|
+
let decimals: number;
|
|
291
|
+
try {
|
|
292
|
+
decimals = await readMintDecimals(rpcUrl, mint);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return {
|
|
295
|
+
funded: false,
|
|
296
|
+
refused_to_sign: true,
|
|
297
|
+
problems: [
|
|
298
|
+
`cannot read mint ${mint}'s decimals from chain, which expected_budget is scaled by: ` +
|
|
299
|
+
(e as Error).message,
|
|
300
|
+
],
|
|
301
|
+
what_this_means: 'Nothing was built, signed or escrowed.',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
206
304
|
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
207
305
|
|
|
208
306
|
const built = (await apiPost(
|
|
@@ -213,14 +311,43 @@ export async function handleSolanaTool(
|
|
|
213
311
|
task_id: string;
|
|
214
312
|
declared: Record<string, any>;
|
|
215
313
|
accounts: Record<string, string>;
|
|
314
|
+
escrow_interface?: string;
|
|
315
|
+
message_sha256?: string;
|
|
216
316
|
};
|
|
217
317
|
|
|
318
|
+
// Config straight from the cluster, at the PDA derived from the pinned program id: a lottery
|
|
319
|
+
// co-signer must be Config.operator, and the API is not the one to vouch for that.
|
|
320
|
+
let onchainConfig = null;
|
|
321
|
+
let onchainConfigError: string | undefined;
|
|
322
|
+
try {
|
|
323
|
+
onchainConfig = await readEscrowConfig(rpcUrl, pin.program_id);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
onchainConfigError = (e as Error).message;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let quotedFeeBps: number | undefined;
|
|
329
|
+
let feeQuoteSource: string;
|
|
330
|
+
if (args.expected_max_fee_bps !== undefined) {
|
|
331
|
+
quotedFeeBps = Number(args.expected_max_fee_bps);
|
|
332
|
+
feeQuoteSource = 'expected_max_fee_bps argument';
|
|
333
|
+
} else if (typeof mission.solana_quoted_fee_bps === 'number') {
|
|
334
|
+
quotedFeeBps = mission.solana_quoted_fee_bps;
|
|
335
|
+
feeQuoteSource = 'mission.solana_quoted_fee_bps (the create_mission quote)';
|
|
336
|
+
} else {
|
|
337
|
+
// A mission created before quotes were recorded: the backend then quotes the live rate.
|
|
338
|
+
quotedFeeBps = onchainConfig?.fee_bps;
|
|
339
|
+
feeQuoteSource = 'on-chain Config.fee_bps (the mission has no recorded quote)';
|
|
340
|
+
}
|
|
341
|
+
|
|
218
342
|
const expectedBudgetRaw = toRawAmount(args.expected_budget, decimals);
|
|
219
343
|
|
|
220
344
|
const verdict = await verifyFundingTransaction({
|
|
221
345
|
transaction_base64: built.transaction_base64,
|
|
346
|
+
escrow_interface: built.escrow_interface,
|
|
347
|
+
task_id: String(built.task_id),
|
|
222
348
|
declared: built.declared as any,
|
|
223
349
|
accounts: built.accounts as any,
|
|
350
|
+
message_sha256: built.message_sha256,
|
|
224
351
|
expected: {
|
|
225
352
|
budget_raw: expectedBudgetRaw,
|
|
226
353
|
// base_pool is derived by the backend from reward × max_humans. The agent's check on it
|
|
@@ -228,14 +355,21 @@ export async function handleSolanaTool(
|
|
|
228
355
|
// asserting a locally recomputed figure would require duplicating that arithmetic here
|
|
229
356
|
// and would fail on a legitimately rounded reward.
|
|
230
357
|
base_pool_raw: String(built.declared.base_pool),
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
358
|
+
// The rest is what create_mission recorded, not what the funding response says: whether
|
|
359
|
+
// this is a lottery decides whether a co-signer is required at all.
|
|
360
|
+
lottery_winner_count: Number(mission.lottery_winner_count ?? 0),
|
|
361
|
+
lottery_prize_per_winner_raw: String(mission.lottery_prize_per_winner_raw ?? '0'),
|
|
362
|
+
qualify_deadline: Number(mission.qualify_deadline),
|
|
363
|
+
settlement_deadline: Number(mission.settlement_deadline),
|
|
364
|
+
seed_commit: mission.seed_commit,
|
|
365
|
+
max_fee_bps: quotedFeeBps,
|
|
235
366
|
mint,
|
|
236
367
|
sponsor: wallet.publicKey,
|
|
368
|
+
sponsor_token_account: tokenAccount,
|
|
237
369
|
},
|
|
238
|
-
expected_program_id:
|
|
370
|
+
expected_program_id: pin.program_id,
|
|
371
|
+
onchain_config: onchainConfig,
|
|
372
|
+
onchain_config_error: onchainConfigError,
|
|
239
373
|
});
|
|
240
374
|
|
|
241
375
|
if (!verdict.ok) {
|
|
@@ -245,7 +379,9 @@ export async function handleSolanaTool(
|
|
|
245
379
|
funded: false,
|
|
246
380
|
refused_to_sign: true,
|
|
247
381
|
problems: verdict.problems,
|
|
382
|
+
warnings: verdict.warnings,
|
|
248
383
|
summary: verdict.summary,
|
|
384
|
+
fee_quote_source: feeQuoteSource,
|
|
249
385
|
what_this_means:
|
|
250
386
|
'The transaction does not match what you asked for, so it was NOT signed and nothing ' +
|
|
251
387
|
'was escrowed. This is the verifier doing its job. Do not retry blindly — the ' +
|
|
@@ -260,38 +396,63 @@ export async function handleSolanaTool(
|
|
|
260
396
|
verified: true,
|
|
261
397
|
task_id: built.task_id,
|
|
262
398
|
summary: verdict.summary,
|
|
399
|
+
warnings: verdict.warnings,
|
|
400
|
+
fee_quote_source: feeQuoteSource,
|
|
263
401
|
would_escrow: `${args.expected_budget} (${expectedBudgetRaw} raw) of ${mint}`,
|
|
264
402
|
};
|
|
265
403
|
}
|
|
266
404
|
|
|
405
|
+
// Adds only our signature; a lottery's Operator co-signature is carried through unchanged.
|
|
267
406
|
const signed = await signTransaction(built.transaction_base64, wallet);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
407
|
+
let confirmed: unknown;
|
|
408
|
+
try {
|
|
409
|
+
confirmed = await apiPost(`/agent/solana/missions/${args.mission_id}/confirm-funding`, {
|
|
410
|
+
signed_transaction: signed,
|
|
411
|
+
task_id: built.task_id,
|
|
412
|
+
});
|
|
413
|
+
} catch (e: any) {
|
|
414
|
+
const { escrowErrorFromMessage } = await import('../solana/escrowErrors.js');
|
|
415
|
+
const body = apiErrorBody(e);
|
|
416
|
+
const message = body.message ?? e?.message ?? String(e);
|
|
417
|
+
return {
|
|
418
|
+
funded: false,
|
|
419
|
+
verified: true,
|
|
420
|
+
signed: true,
|
|
421
|
+
status: e?.status,
|
|
422
|
+
message,
|
|
423
|
+
program_error: escrowErrorFromMessage(message) ?? undefined,
|
|
424
|
+
...(body.funding_mismatch ? { funding_mismatch: body.funding_mismatch } : {}),
|
|
425
|
+
...(body.task_id ? { task_id: body.task_id } : {}),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
272
428
|
|
|
273
|
-
return {
|
|
429
|
+
return {
|
|
430
|
+
funded: true,
|
|
431
|
+
verified: true,
|
|
432
|
+
lottery: verdict.summary.lottery,
|
|
433
|
+
operator_cosigned: verdict.summary.operator_signature === 'valid',
|
|
434
|
+
max_fee_bps: verdict.summary.max_fee_bps,
|
|
435
|
+
...(confirmed as Record<string, unknown>),
|
|
436
|
+
};
|
|
274
437
|
}
|
|
275
438
|
|
|
276
439
|
case 'refund_solana_mission': {
|
|
277
440
|
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import(
|
|
278
441
|
'../solana/wallet.js'
|
|
279
442
|
);
|
|
443
|
+
const { escrowErrorFromMessage } = await import('../solana/escrowErrors.js');
|
|
280
444
|
const wallet = await loadSolanaWallet();
|
|
281
|
-
const cfg = (await apiGet('/agent/solana/config')) as
|
|
282
|
-
program_id: string;
|
|
283
|
-
mints: Record<string, string>;
|
|
284
|
-
};
|
|
445
|
+
const cfg = (await apiGet('/agent/solana/config')) as SolanaConfigResponse;
|
|
285
446
|
// The mission's ACTUAL mint, not a guessed default — get_solana_config's mints always
|
|
286
447
|
// includes both the production payout mint (USDC) and TEST_USDC, and a mission is funded
|
|
287
448
|
// in exactly one of them. Guessing (the previous code preferred TEST_USDC whenever present)
|
|
288
449
|
// computes the wrong associated-token-account for any mission funded in the other one, and
|
|
289
450
|
// the refund/cancel/emergency-refund transaction then fails on-chain with
|
|
290
451
|
// AccountNotInitialized — confirmed live against a real USDC-funded mission.
|
|
291
|
-
const mission = (await apiGet(`/agent/missions/${args.mission_id}`)) as {
|
|
292
|
-
mission?:
|
|
452
|
+
const { mission } = (await apiGet(`/agent/missions/${args.mission_id}`)) as {
|
|
453
|
+
mission?: SolanaMissionRecord;
|
|
293
454
|
};
|
|
294
|
-
const mint = mission
|
|
455
|
+
const mint = mission?.token_address;
|
|
295
456
|
if (!mint) {
|
|
296
457
|
return {
|
|
297
458
|
refunded: false,
|
|
@@ -299,6 +460,13 @@ export async function handleSolanaTool(
|
|
|
299
460
|
};
|
|
300
461
|
}
|
|
301
462
|
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
463
|
+
const deadlines = {
|
|
464
|
+
qualify_deadline: mission?.qualify_deadline,
|
|
465
|
+
settlement_deadline: mission?.settlement_deadline,
|
|
466
|
+
...(mission?.qualify_deadline !== undefined
|
|
467
|
+
? { cancel_cutoff: new Date(mission.qualify_deadline * 1000).toISOString() }
|
|
468
|
+
: {}),
|
|
469
|
+
};
|
|
302
470
|
|
|
303
471
|
// No action: ask what is legal rather than guessing and getting a 409. Uses a deliberately
|
|
304
472
|
// invalid action so the backend answers with available_actions for this mission's state.
|
|
@@ -314,41 +482,91 @@ export async function handleSolanaTool(
|
|
|
314
482
|
// ApiResponseError carries the parsed body on `.data`; the other shapes are kept for
|
|
315
483
|
// any client that wraps differently. Reading only the latter silently yielded an empty
|
|
316
484
|
// available_actions, i.e. a false "nothing is refundable" on a mission that could cancel.
|
|
317
|
-
const body
|
|
485
|
+
const body = apiErrorBody(e);
|
|
318
486
|
return {
|
|
319
487
|
available_actions: body.available_actions ?? [],
|
|
320
488
|
why_not_claim_refund: body.message,
|
|
489
|
+
...deadlines,
|
|
321
490
|
note:
|
|
322
491
|
(body.available_actions?.length ?? 0) === 0
|
|
323
|
-
?
|
|
324
|
-
|
|
325
|
-
: 'Re-run with one of available_actions.',
|
|
492
|
+
? `Nothing is refundable right now. ${CANCEL_CUTOFF_RULE}`
|
|
493
|
+
: `Re-run with one of available_actions. ${CANCEL_CUTOFF_RULE}`,
|
|
326
494
|
};
|
|
327
495
|
}
|
|
328
496
|
}
|
|
329
497
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
498
|
+
// The program refuses a cancel from qualify_deadline on. Checked here too, so a cancel is
|
|
499
|
+
// never signed only to be rejected on chain.
|
|
500
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
501
|
+
if (
|
|
502
|
+
args.action === 'cancel' &&
|
|
503
|
+
typeof mission?.qualify_deadline === 'number' &&
|
|
504
|
+
nowSec >= mission.qualify_deadline
|
|
505
|
+
) {
|
|
506
|
+
return {
|
|
507
|
+
refunded: false,
|
|
508
|
+
rejected_by: 'client',
|
|
509
|
+
action: 'cancel',
|
|
510
|
+
message:
|
|
511
|
+
`qualify_deadline (${deadlines.cancel_cutoff}) has passed by this machine's clock, so the ` +
|
|
512
|
+
'escrow program would refuse this cancel (TooLateToCancel). Nothing was built or signed.',
|
|
513
|
+
...deadlines,
|
|
514
|
+
what_to_do: CANCEL_CUTOFF_RULE,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// The refund must be for the pinned program, not whichever one the API names.
|
|
519
|
+
const pin = resolvePinnedProgram(cfg);
|
|
520
|
+
if ('problem' in pin) {
|
|
521
|
+
return {
|
|
522
|
+
refunded: false,
|
|
523
|
+
refused_to_sign: true,
|
|
524
|
+
problems: [pin.problem],
|
|
525
|
+
what_this_means: 'Nothing was built, signed or moved.',
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let built: { transaction_base64: string; task_id: string; cancel_deadline?: string };
|
|
530
|
+
try {
|
|
531
|
+
built = (await apiPost(
|
|
532
|
+
`/agent/solana/missions/${args.mission_id}/refund-transaction`,
|
|
533
|
+
{
|
|
534
|
+
action: args.action,
|
|
535
|
+
sponsor_wallet: wallet.publicKey,
|
|
536
|
+
sponsor_token_account: tokenAccount,
|
|
537
|
+
},
|
|
538
|
+
)) as { transaction_base64: string; task_id: string; cancel_deadline?: string };
|
|
539
|
+
} catch (e: any) {
|
|
540
|
+
const body = apiErrorBody(e);
|
|
541
|
+
const message = body.message ?? e?.message ?? String(e);
|
|
542
|
+
return {
|
|
543
|
+
refunded: false,
|
|
544
|
+
rejected_by: 'backend',
|
|
333
545
|
action: args.action,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
546
|
+
status: e?.status,
|
|
547
|
+
message,
|
|
548
|
+
available_actions: body.available_actions ?? [],
|
|
549
|
+
program_error: escrowErrorFromMessage(message) ?? undefined,
|
|
550
|
+
...deadlines,
|
|
551
|
+
what_to_do: CANCEL_CUTOFF_RULE,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
338
554
|
|
|
339
555
|
// Verify before signing, same rule as funding: a headless agent has no wallet UI, so an
|
|
340
556
|
// unverified blob is bytes it cannot read. A refund moves the WHOLE escrow, so the checks
|
|
341
557
|
// that matter are that it is our program, we are the only signer, and the tokens land in our
|
|
342
|
-
// own token account.
|
|
343
|
-
|
|
558
|
+
// own token account. The three refund instructions kept their v1 layout in the v2 interface
|
|
559
|
+
// (no arguments; sponsor, config, task, vault, sponsor_token_account, token_program and the
|
|
560
|
+
// two event accounts), so these checks are unchanged.
|
|
561
|
+
const { Transaction } = await import('@solana/web3.js');
|
|
344
562
|
const tx = Transaction.from(Buffer.from(built.transaction_base64, 'base64'));
|
|
345
563
|
const problems: string[] = [];
|
|
346
564
|
if (tx.instructions.length !== 1) {
|
|
347
565
|
problems.push(`expected 1 instruction, found ${tx.instructions.length}`);
|
|
348
566
|
}
|
|
349
567
|
const ix = tx.instructions[0];
|
|
350
|
-
if (ix?.programId?.toBase58() !==
|
|
351
|
-
problems.push(`program is ${ix?.programId?.toBase58()}, expected ${
|
|
568
|
+
if (ix?.programId?.toBase58() !== pin.program_id) {
|
|
569
|
+
problems.push(`program is ${ix?.programId?.toBase58()}, expected ${pin.program_id}`);
|
|
352
570
|
}
|
|
353
571
|
const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
|
|
354
572
|
if (signers.length !== 1 || signers[0] !== wallet.publicKey) {
|
|
@@ -365,7 +583,6 @@ export async function handleSolanaTool(
|
|
|
365
583
|
if ((ix?.data?.length ?? 0) !== 8) {
|
|
366
584
|
problems.push(`instruction data is ${ix?.data?.length} bytes, expected 8`);
|
|
367
585
|
}
|
|
368
|
-
void PublicKey;
|
|
369
586
|
|
|
370
587
|
if (problems.length > 0) {
|
|
371
588
|
return {
|
|
@@ -386,14 +603,36 @@ export async function handleSolanaTool(
|
|
|
386
603
|
action: args.action,
|
|
387
604
|
task_id: built.task_id,
|
|
388
605
|
destination: tokenAccount,
|
|
606
|
+
...(built.cancel_deadline ? { cancel_deadline: built.cancel_deadline } : {}),
|
|
389
607
|
};
|
|
390
608
|
}
|
|
391
609
|
|
|
392
610
|
const signed = await signTransaction(built.transaction_base64, wallet);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
611
|
+
let confirmed: unknown;
|
|
612
|
+
try {
|
|
613
|
+
confirmed = await apiPost(`/agent/solana/missions/${args.mission_id}/confirm-refund`, {
|
|
614
|
+
signed_transaction: signed,
|
|
615
|
+
action: args.action,
|
|
616
|
+
});
|
|
617
|
+
} catch (e: any) {
|
|
618
|
+
// The backend maps a cancel landing at or after qualify_deadline to 409 TooLateToCancel
|
|
619
|
+
// with available_actions; any other on-chain failure arrives as 400 "refund transaction
|
|
620
|
+
// failed: <RPC error>", which is where the program error code is.
|
|
621
|
+
const body = apiErrorBody(e);
|
|
622
|
+
const message = body.message ?? e?.message ?? String(e);
|
|
623
|
+
const programError = escrowErrorFromMessage(message);
|
|
624
|
+
return {
|
|
625
|
+
refunded: false,
|
|
626
|
+
rejected_by: programError || /transaction failed/i.test(message) ? 'chain' : 'backend',
|
|
627
|
+
action: args.action,
|
|
628
|
+
status: e?.status,
|
|
629
|
+
message,
|
|
630
|
+
program_error: programError ?? undefined,
|
|
631
|
+
available_actions: body.available_actions,
|
|
632
|
+
...deadlines,
|
|
633
|
+
what_to_do: programError?.what_to_do ?? CANCEL_CUTOFF_RULE,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
397
636
|
return { refunded: true, verified: true, ...(confirmed as Record<string, unknown>) };
|
|
398
637
|
}
|
|
399
638
|
|
package/src/tools/tracks.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const trackTools: Tool[] = [
|
|
|
11
11
|
'multi-MB file as base64 in the tool call can exceed the calling agent\'s own tool-call or context limits, ' +
|
|
12
12
|
'causing a silent client-side failure before any request reaches the API) OR inline as file_base64 (fine for ' +
|
|
13
13
|
'small images). Exactly one of the two is required. ' +
|
|
14
|
-
'For on-chain missions, call this BEFORE
|
|
14
|
+
'For on-chain missions, call this BEFORE fund_solana_mission — uploads are blocked once the mission is active. ' +
|
|
15
15
|
'For off-chain missions, call while the mission is active and before any participant is hired. ' +
|
|
16
16
|
'The item becomes visible to hired participants once confirmed.',
|
|
17
17
|
inputSchema: {
|