@projectsolo/solo-mission-mcp 0.20.1 → 0.21.2
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/dist/index.js +100 -0
- package/package.json +1 -1
- package/src/scripts/check-tools-against-spec.ts +38 -2
- package/src/tools/solana.ts +131 -0
package/dist/index.js
CHANGED
|
@@ -957,6 +957,26 @@ var solanaTools = [
|
|
|
957
957
|
},
|
|
958
958
|
required: ["mission_id", "expected_budget"]
|
|
959
959
|
}
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
name: "refund_solana_mission",
|
|
963
|
+
description: "Get a Solana mission's escrowed funds back to the sponsor. Covers all three routes: cancel (while funded, before hiring closes), emergency_refund (once the settlement deadline passes and the platform has not settled), and claim_refund (leftover budget after a partial settle).\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 \u2014 the response lists the legal actions for the mission's state rather than guessing. Without this tool a funded Solana mission's money was unreachable except by hand-assembling an Anchor instruction, for which Solana has no `cast send` equivalent.",
|
|
964
|
+
inputSchema: {
|
|
965
|
+
type: "object",
|
|
966
|
+
properties: {
|
|
967
|
+
mission_id: { type: "string" },
|
|
968
|
+
action: {
|
|
969
|
+
type: "string",
|
|
970
|
+
enum: ["cancel", "emergency_refund", "claim_refund"],
|
|
971
|
+
description: "Omit to query what is available for this mission right now instead of attempting one."
|
|
972
|
+
},
|
|
973
|
+
dry_run: {
|
|
974
|
+
type: "boolean",
|
|
975
|
+
description: "Build and verify without signing or submitting. Nothing moves, no fee."
|
|
976
|
+
}
|
|
977
|
+
},
|
|
978
|
+
required: ["mission_id"]
|
|
979
|
+
}
|
|
960
980
|
}
|
|
961
981
|
];
|
|
962
982
|
function toRawAmount(amount, decimals) {
|
|
@@ -1084,6 +1104,86 @@ async function handleSolanaTool(name, args) {
|
|
|
1084
1104
|
);
|
|
1085
1105
|
return { funded: true, verified: true, ...confirmed };
|
|
1086
1106
|
}
|
|
1107
|
+
case "refund_solana_mission": {
|
|
1108
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-IUQWBW6F.js");
|
|
1109
|
+
const wallet = await loadSolanaWallet();
|
|
1110
|
+
const cfg = await apiGet2("/agent/solana/config");
|
|
1111
|
+
const mint = cfg.mints.TEST_USDC ?? Object.values(cfg.mints)[0];
|
|
1112
|
+
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
1113
|
+
if (!args.action) {
|
|
1114
|
+
try {
|
|
1115
|
+
await apiPost2(`/agent/solana/missions/${args.mission_id}/refund-transaction`, {
|
|
1116
|
+
action: "claim_refund",
|
|
1117
|
+
sponsor_wallet: wallet.publicKey,
|
|
1118
|
+
sponsor_token_account: tokenAccount
|
|
1119
|
+
});
|
|
1120
|
+
return { available_actions: ["claim_refund"], note: "claim_refund is available now" };
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
const body = e?.data ?? e?.response?.data ?? e?.body ?? {};
|
|
1123
|
+
return {
|
|
1124
|
+
available_actions: body.available_actions ?? [],
|
|
1125
|
+
why_not_claim_refund: body.message,
|
|
1126
|
+
note: (body.available_actions?.length ?? 0) === 0 ? "Nothing is refundable right now. cancel needs the hiring window still open; emergency_refund needs the settlement deadline to have passed." : "Re-run with one of available_actions."
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
const built = await apiPost2(
|
|
1131
|
+
`/agent/solana/missions/${args.mission_id}/refund-transaction`,
|
|
1132
|
+
{
|
|
1133
|
+
action: args.action,
|
|
1134
|
+
sponsor_wallet: wallet.publicKey,
|
|
1135
|
+
sponsor_token_account: tokenAccount
|
|
1136
|
+
}
|
|
1137
|
+
);
|
|
1138
|
+
const { Transaction, PublicKey } = await import("@solana/web3.js");
|
|
1139
|
+
const tx = Transaction.from(Buffer.from(built.transaction_base64, "base64"));
|
|
1140
|
+
const problems = [];
|
|
1141
|
+
if (tx.instructions.length !== 1) {
|
|
1142
|
+
problems.push(`expected 1 instruction, found ${tx.instructions.length}`);
|
|
1143
|
+
}
|
|
1144
|
+
const ix = tx.instructions[0];
|
|
1145
|
+
if (ix?.programId?.toBase58() !== cfg.program_id) {
|
|
1146
|
+
problems.push(`program is ${ix?.programId?.toBase58()}, expected ${cfg.program_id}`);
|
|
1147
|
+
}
|
|
1148
|
+
const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
|
|
1149
|
+
if (signers.length !== 1 || signers[0] !== wallet.publicKey) {
|
|
1150
|
+
problems.push(`expected only ${wallet.publicKey} to sign, found [${signers.join(", ")}]`);
|
|
1151
|
+
}
|
|
1152
|
+
const keys = (ix?.keys ?? []).map((k) => k.pubkey.toBase58());
|
|
1153
|
+
if (!keys.includes(tokenAccount)) {
|
|
1154
|
+
problems.push(
|
|
1155
|
+
`the refund destination ${tokenAccount} is not in the transaction \u2014 the funds would go elsewhere`
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
if ((ix?.data?.length ?? 0) !== 8) {
|
|
1159
|
+
problems.push(`instruction data is ${ix?.data?.length} bytes, expected 8`);
|
|
1160
|
+
}
|
|
1161
|
+
void PublicKey;
|
|
1162
|
+
if (problems.length > 0) {
|
|
1163
|
+
return {
|
|
1164
|
+
refunded: false,
|
|
1165
|
+
refused_to_sign: true,
|
|
1166
|
+
problems,
|
|
1167
|
+
what_this_means: "The refund transaction does not match what was asked for, so it was NOT signed and nothing moved. Do not retry blindly."
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
if (args.dry_run) {
|
|
1171
|
+
return {
|
|
1172
|
+
refunded: false,
|
|
1173
|
+
dry_run: true,
|
|
1174
|
+
verified: true,
|
|
1175
|
+
action: args.action,
|
|
1176
|
+
task_id: built.task_id,
|
|
1177
|
+
destination: tokenAccount
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
const signed = await signTransaction(built.transaction_base64, wallet);
|
|
1181
|
+
const confirmed = await apiPost2(
|
|
1182
|
+
`/agent/solana/missions/${args.mission_id}/confirm-refund`,
|
|
1183
|
+
{ signed_transaction: signed, action: args.action }
|
|
1184
|
+
);
|
|
1185
|
+
return { refunded: true, verified: true, ...confirmed };
|
|
1186
|
+
}
|
|
1087
1187
|
default:
|
|
1088
1188
|
throw new Error(`Unknown Solana tool: ${name}`);
|
|
1089
1189
|
}
|
package/package.json
CHANGED
|
@@ -122,10 +122,46 @@ try {
|
|
|
122
122
|
process.exit(1);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Whether a documented route is this package's responsibility at all.
|
|
127
|
+
*
|
|
128
|
+
* This is an AGENT-facing package: every tool authenticates with X-Agent-Key. A human-authenticated
|
|
129
|
+
* route is out of scope by design — an agent has no Firebase user token and could not call it if a
|
|
130
|
+
* tool existed.
|
|
131
|
+
*
|
|
132
|
+
* The check's original premise, "every route the spec documents must be called by something here",
|
|
133
|
+
* held only while the spec happened to annotate agent routes exclusively. It broke the moment
|
|
134
|
+
* solo-firebase documented `GET /human/solana/rewards` and `POST /profile/wallet/solana/bind` —
|
|
135
|
+
* correctly, since a published API should describe its human surface too. The Base equivalents
|
|
136
|
+
* (`/human/rewards`, `/profile/wallet/bind`) are simply not annotated yet, which is why this never
|
|
137
|
+
* fired before rather than the rule ever having been right.
|
|
138
|
+
*
|
|
139
|
+
* Scoped by path prefix rather than an allowlist of specific routes, so the next human endpoint
|
|
140
|
+
* does not fail a release for the same reason.
|
|
141
|
+
*/
|
|
142
|
+
function isAgentScoped(route: string): boolean {
|
|
143
|
+
const path = route.split(' ')[1] ?? '';
|
|
144
|
+
return path.startsWith('/agent') || path.startsWith('/missions') || path.startsWith('/humans');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const uncalled = specRoutes.filter(
|
|
148
|
+
(route) =>
|
|
149
|
+
!called.has(route) &&
|
|
150
|
+
!route.endsWith('/agent/openapi.json') &&
|
|
151
|
+
isAgentScoped(route),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const outOfScope = specRoutes.filter((route) => !isAgentScoped(route));
|
|
155
|
+
if (outOfScope.length > 0) {
|
|
156
|
+
// Reported, not silent: a route landing here because it is genuinely human-facing is fine, but
|
|
157
|
+
// one landing here because an agent route was misnamed would otherwise vanish from the check.
|
|
158
|
+
console.log('Human-authenticated routes, out of scope for this agent-facing package:');
|
|
159
|
+
for (const route of outOfScope) console.log(` - ${route}`);
|
|
160
|
+
console.log('');
|
|
161
|
+
}
|
|
126
162
|
|
|
127
163
|
if (uncalled.length > 0) {
|
|
128
|
-
console.error("The live API spec documents routes that no tool in src/tools/*.ts calls:");
|
|
164
|
+
console.error("The live API spec documents AGENT routes that no tool in src/tools/*.ts calls:");
|
|
129
165
|
for (const route of uncalled) console.error(` - ${route}`);
|
|
130
166
|
console.error(`\nSpec source: ${SPEC_URL}`);
|
|
131
167
|
console.error("Either a tool's REST call was changed/removed without updating this check's");
|
package/src/tools/solana.ts
CHANGED
|
@@ -63,6 +63,28 @@ export const solanaTools: Tool[] = [
|
|
|
63
63
|
required: ['mission_id', 'expected_budget'],
|
|
64
64
|
},
|
|
65
65
|
},
|
|
66
|
+
{
|
|
67
|
+
name: 'refund_solana_mission',
|
|
68
|
+
description:
|
|
69
|
+
"Get a Solana mission's escrowed funds back to the sponsor. Covers all three routes: cancel (while funded, before hiring closes), emergency_refund (once the settlement deadline passes and the platform has not settled), and claim_refund (leftover budget after a partial settle).\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. Without this tool a funded Solana mission's money was unreachable except by hand-assembling an Anchor instruction, for which Solana has no `cast send` equivalent.",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
mission_id: { type: 'string' },
|
|
74
|
+
action: {
|
|
75
|
+
type: 'string',
|
|
76
|
+
enum: ['cancel', 'emergency_refund', 'claim_refund'],
|
|
77
|
+
description:
|
|
78
|
+
'Omit to query what is available for this mission right now instead of attempting one.',
|
|
79
|
+
},
|
|
80
|
+
dry_run: {
|
|
81
|
+
type: 'boolean',
|
|
82
|
+
description: 'Build and verify without signing or submitting. Nothing moves, no fee.',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
required: ['mission_id'],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
66
88
|
];
|
|
67
89
|
|
|
68
90
|
/**
|
|
@@ -248,6 +270,115 @@ export async function handleSolanaTool(
|
|
|
248
270
|
return { funded: true, verified: true, ...(confirmed as Record<string, unknown>) };
|
|
249
271
|
}
|
|
250
272
|
|
|
273
|
+
case 'refund_solana_mission': {
|
|
274
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import(
|
|
275
|
+
'../solana/wallet.js'
|
|
276
|
+
);
|
|
277
|
+
const wallet = await loadSolanaWallet();
|
|
278
|
+
const cfg = (await apiGet('/agent/solana/config')) as {
|
|
279
|
+
program_id: string;
|
|
280
|
+
mints: Record<string, string>;
|
|
281
|
+
};
|
|
282
|
+
const mint = cfg.mints.TEST_USDC ?? Object.values(cfg.mints)[0];
|
|
283
|
+
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
284
|
+
|
|
285
|
+
// No action: ask what is legal rather than guessing and getting a 409. Uses a deliberately
|
|
286
|
+
// invalid action so the backend answers with available_actions for this mission's state.
|
|
287
|
+
if (!args.action) {
|
|
288
|
+
try {
|
|
289
|
+
await apiPost(`/agent/solana/missions/${args.mission_id}/refund-transaction`, {
|
|
290
|
+
action: 'claim_refund',
|
|
291
|
+
sponsor_wallet: wallet.publicKey,
|
|
292
|
+
sponsor_token_account: tokenAccount,
|
|
293
|
+
});
|
|
294
|
+
return { available_actions: ['claim_refund'], note: 'claim_refund is available now' };
|
|
295
|
+
} catch (e: any) {
|
|
296
|
+
// ApiResponseError carries the parsed body on `.data`; the other shapes are kept for
|
|
297
|
+
// any client that wraps differently. Reading only the latter silently yielded an empty
|
|
298
|
+
// available_actions, i.e. a false "nothing is refundable" on a mission that could cancel.
|
|
299
|
+
const body: any = e?.data ?? e?.response?.data ?? e?.body ?? {};
|
|
300
|
+
return {
|
|
301
|
+
available_actions: body.available_actions ?? [],
|
|
302
|
+
why_not_claim_refund: body.message,
|
|
303
|
+
note:
|
|
304
|
+
(body.available_actions?.length ?? 0) === 0
|
|
305
|
+
? 'Nothing is refundable right now. cancel needs the hiring window still open; ' +
|
|
306
|
+
'emergency_refund needs the settlement deadline to have passed.'
|
|
307
|
+
: 'Re-run with one of available_actions.',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const built = (await apiPost(
|
|
313
|
+
`/agent/solana/missions/${args.mission_id}/refund-transaction`,
|
|
314
|
+
{
|
|
315
|
+
action: args.action,
|
|
316
|
+
sponsor_wallet: wallet.publicKey,
|
|
317
|
+
sponsor_token_account: tokenAccount,
|
|
318
|
+
},
|
|
319
|
+
)) as { transaction_base64: string; task_id: string };
|
|
320
|
+
|
|
321
|
+
// Verify before signing, same rule as funding: a headless agent has no wallet UI, so an
|
|
322
|
+
// unverified blob is bytes it cannot read. A refund moves the WHOLE escrow, so the checks
|
|
323
|
+
// that matter are that it is our program, we are the only signer, and the tokens land in our
|
|
324
|
+
// own token account.
|
|
325
|
+
const { Transaction, PublicKey } = await import('@solana/web3.js');
|
|
326
|
+
const tx = Transaction.from(Buffer.from(built.transaction_base64, 'base64'));
|
|
327
|
+
const problems: string[] = [];
|
|
328
|
+
if (tx.instructions.length !== 1) {
|
|
329
|
+
problems.push(`expected 1 instruction, found ${tx.instructions.length}`);
|
|
330
|
+
}
|
|
331
|
+
const ix = tx.instructions[0];
|
|
332
|
+
if (ix?.programId?.toBase58() !== cfg.program_id) {
|
|
333
|
+
problems.push(`program is ${ix?.programId?.toBase58()}, expected ${cfg.program_id}`);
|
|
334
|
+
}
|
|
335
|
+
const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
|
|
336
|
+
if (signers.length !== 1 || signers[0] !== wallet.publicKey) {
|
|
337
|
+
problems.push(`expected only ${wallet.publicKey} to sign, found [${signers.join(', ')}]`);
|
|
338
|
+
}
|
|
339
|
+
const keys = (ix?.keys ?? []).map((k) => k.pubkey.toBase58());
|
|
340
|
+
if (!keys.includes(tokenAccount)) {
|
|
341
|
+
problems.push(
|
|
342
|
+
`the refund destination ${tokenAccount} is not in the transaction — the funds would go elsewhere`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
// No arguments on any of the three instructions, so the data is exactly the 8-byte
|
|
346
|
+
// discriminator. Anything longer means something was appended.
|
|
347
|
+
if ((ix?.data?.length ?? 0) !== 8) {
|
|
348
|
+
problems.push(`instruction data is ${ix?.data?.length} bytes, expected 8`);
|
|
349
|
+
}
|
|
350
|
+
void PublicKey;
|
|
351
|
+
|
|
352
|
+
if (problems.length > 0) {
|
|
353
|
+
return {
|
|
354
|
+
refunded: false,
|
|
355
|
+
refused_to_sign: true,
|
|
356
|
+
problems,
|
|
357
|
+
what_this_means:
|
|
358
|
+
'The refund transaction does not match what was asked for, so it was NOT signed and ' +
|
|
359
|
+
'nothing moved. Do not retry blindly.',
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (args.dry_run) {
|
|
364
|
+
return {
|
|
365
|
+
refunded: false,
|
|
366
|
+
dry_run: true,
|
|
367
|
+
verified: true,
|
|
368
|
+
action: args.action,
|
|
369
|
+
task_id: built.task_id,
|
|
370
|
+
destination: tokenAccount,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const signed = await signTransaction(built.transaction_base64, wallet);
|
|
375
|
+
const confirmed = await apiPost(
|
|
376
|
+
`/agent/solana/missions/${args.mission_id}/confirm-refund`,
|
|
377
|
+
{ signed_transaction: signed, action: args.action },
|
|
378
|
+
);
|
|
379
|
+
return { refunded: true, verified: true, ...(confirmed as Record<string, unknown>) };
|
|
380
|
+
}
|
|
381
|
+
|
|
251
382
|
default:
|
|
252
383
|
throw new Error(`Unknown Solana tool: ${name}`);
|
|
253
384
|
}
|