@projectsolo/solo-mission-mcp 0.19.3 → 0.21.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/dist/chunk-NXOOPOSF.js +79 -0
- package/dist/client-2NLDPRAH.js +12 -0
- package/dist/index.js +283 -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 +38 -2
- 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 +382 -0
- package/vitest.config.ts +4 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
var config = {
|
|
4
|
+
agentKey: process.env.SOLO_AGENT_KEY ?? "",
|
|
5
|
+
apiUrl: process.env.SOLO_MISSION_API_URL ?? "https://api.mission.projectsolo.ai"
|
|
6
|
+
};
|
|
7
|
+
if (!config.agentKey) {
|
|
8
|
+
console.warn("Warning: SOLO_AGENT_KEY is not set. Only register_agent will work until a key is configured.");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/api/client.ts
|
|
12
|
+
var DEFAULT_HEADERS = {
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
"X-Agent-Key": config.agentKey
|
|
15
|
+
};
|
|
16
|
+
var ApiResponseError = class extends Error {
|
|
17
|
+
status;
|
|
18
|
+
data;
|
|
19
|
+
constructor(status, data) {
|
|
20
|
+
const msg = data?.message || data?.error || `Request failed with status ${status}`;
|
|
21
|
+
super(status === 429 ? "Rate limit exceeded. Please slow down and retry after a moment." : msg);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.data = data;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
async function parseErrorResponse(response) {
|
|
27
|
+
const data = await response.json().catch(() => ({}));
|
|
28
|
+
throw new ApiResponseError(response.status, data);
|
|
29
|
+
}
|
|
30
|
+
async function apiGet(path, params) {
|
|
31
|
+
const url = new URL(`${config.apiUrl}${path}`);
|
|
32
|
+
if (params) {
|
|
33
|
+
for (const [key, value] of Object.entries(params)) {
|
|
34
|
+
if (value != null) url.searchParams.set(key, String(value));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const response = await fetch(url.toString(), {
|
|
38
|
+
headers: DEFAULT_HEADERS,
|
|
39
|
+
signal: AbortSignal.timeout(3e4)
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) return parseErrorResponse(response);
|
|
42
|
+
return response.json();
|
|
43
|
+
}
|
|
44
|
+
async function apiPost(path, body) {
|
|
45
|
+
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: DEFAULT_HEADERS,
|
|
48
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
49
|
+
signal: AbortSignal.timeout(3e4)
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) return parseErrorResponse(response);
|
|
52
|
+
return response.json();
|
|
53
|
+
}
|
|
54
|
+
async function apiDelete(path) {
|
|
55
|
+
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
56
|
+
method: "DELETE",
|
|
57
|
+
headers: DEFAULT_HEADERS,
|
|
58
|
+
signal: AbortSignal.timeout(3e4)
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) return parseErrorResponse(response);
|
|
61
|
+
return response.json();
|
|
62
|
+
}
|
|
63
|
+
async function publicApiPost(path, body) {
|
|
64
|
+
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
68
|
+
signal: AbortSignal.timeout(3e4)
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) return parseErrorResponse(response);
|
|
71
|
+
return response.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
apiGet,
|
|
76
|
+
apiPost,
|
|
77
|
+
apiDelete,
|
|
78
|
+
publicApiPost
|
|
79
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
apiDelete,
|
|
4
|
+
apiGet,
|
|
5
|
+
apiPost,
|
|
6
|
+
publicApiPost
|
|
7
|
+
} from "./chunk-NXOOPOSF.js";
|
|
2
8
|
|
|
3
9
|
// src/index.ts
|
|
4
10
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -8,79 +14,6 @@ import {
|
|
|
8
14
|
ListToolsRequestSchema
|
|
9
15
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
16
|
|
|
11
|
-
// src/config.ts
|
|
12
|
-
import "dotenv/config";
|
|
13
|
-
var config = {
|
|
14
|
-
agentKey: process.env.SOLO_AGENT_KEY ?? "",
|
|
15
|
-
apiUrl: process.env.SOLO_MISSION_API_URL ?? "https://api.mission.projectsolo.ai"
|
|
16
|
-
};
|
|
17
|
-
if (!config.agentKey) {
|
|
18
|
-
console.warn("Warning: SOLO_AGENT_KEY is not set. Only register_agent will work until a key is configured.");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// src/api/client.ts
|
|
22
|
-
var DEFAULT_HEADERS = {
|
|
23
|
-
"Content-Type": "application/json",
|
|
24
|
-
"X-Agent-Key": config.agentKey
|
|
25
|
-
};
|
|
26
|
-
var ApiResponseError = class extends Error {
|
|
27
|
-
status;
|
|
28
|
-
data;
|
|
29
|
-
constructor(status, data) {
|
|
30
|
-
const msg = data?.message || data?.error || `Request failed with status ${status}`;
|
|
31
|
-
super(status === 429 ? "Rate limit exceeded. Please slow down and retry after a moment." : msg);
|
|
32
|
-
this.status = status;
|
|
33
|
-
this.data = data;
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
async function parseErrorResponse(response) {
|
|
37
|
-
const data = await response.json().catch(() => ({}));
|
|
38
|
-
throw new ApiResponseError(response.status, data);
|
|
39
|
-
}
|
|
40
|
-
async function apiGet(path, params) {
|
|
41
|
-
const url = new URL(`${config.apiUrl}${path}`);
|
|
42
|
-
if (params) {
|
|
43
|
-
for (const [key, value] of Object.entries(params)) {
|
|
44
|
-
if (value != null) url.searchParams.set(key, String(value));
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
const response = await fetch(url.toString(), {
|
|
48
|
-
headers: DEFAULT_HEADERS,
|
|
49
|
-
signal: AbortSignal.timeout(3e4)
|
|
50
|
-
});
|
|
51
|
-
if (!response.ok) return parseErrorResponse(response);
|
|
52
|
-
return response.json();
|
|
53
|
-
}
|
|
54
|
-
async function apiPost(path, body) {
|
|
55
|
-
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
56
|
-
method: "POST",
|
|
57
|
-
headers: DEFAULT_HEADERS,
|
|
58
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
59
|
-
signal: AbortSignal.timeout(3e4)
|
|
60
|
-
});
|
|
61
|
-
if (!response.ok) return parseErrorResponse(response);
|
|
62
|
-
return response.json();
|
|
63
|
-
}
|
|
64
|
-
async function apiDelete(path) {
|
|
65
|
-
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
66
|
-
method: "DELETE",
|
|
67
|
-
headers: DEFAULT_HEADERS,
|
|
68
|
-
signal: AbortSignal.timeout(3e4)
|
|
69
|
-
});
|
|
70
|
-
if (!response.ok) return parseErrorResponse(response);
|
|
71
|
-
return response.json();
|
|
72
|
-
}
|
|
73
|
-
async function publicApiPost(path, body) {
|
|
74
|
-
const response = await fetch(`${config.apiUrl}${path}`, {
|
|
75
|
-
method: "POST",
|
|
76
|
-
headers: { "Content-Type": "application/json" },
|
|
77
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
78
|
-
signal: AbortSignal.timeout(3e4)
|
|
79
|
-
});
|
|
80
|
-
if (!response.ok) return parseErrorResponse(response);
|
|
81
|
-
return response.json();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
17
|
// src/tools/missions.ts
|
|
85
18
|
var missionTools = [
|
|
86
19
|
{
|
|
@@ -982,8 +915,282 @@ async function handleTrackTool(name, args) {
|
|
|
982
915
|
}
|
|
983
916
|
}
|
|
984
917
|
|
|
918
|
+
// src/tools/solana.ts
|
|
919
|
+
var solanaTools = [
|
|
920
|
+
{
|
|
921
|
+
name: "get_solana_config",
|
|
922
|
+
description: "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 \u2014 a mint that is not whitelisted is rejected on chain, not by the API. Requires no wallet.",
|
|
923
|
+
inputSchema: { type: "object", properties: {} }
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
name: "get_solana_wallet",
|
|
927
|
+
description: "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 \u2014 most of it returns when the task is closed. If no wallet is configured this explains how to set one up.",
|
|
928
|
+
inputSchema: {
|
|
929
|
+
type: "object",
|
|
930
|
+
properties: {
|
|
931
|
+
mint: {
|
|
932
|
+
type: "string",
|
|
933
|
+
description: "Mint to report a balance for. Defaults to the deployment payout mint from get_solana_config."
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
name: "fund_solana_mission",
|
|
940
|
+
description: "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 \u2014 a substituted mint, an altered budget, an extra instruction, a vault that is not a program-derived address \u2014 and returns the discrepancies instead.\n\nCall create_mission with chain='solana' first; pass that mission's id here.",
|
|
941
|
+
inputSchema: {
|
|
942
|
+
type: "object",
|
|
943
|
+
properties: {
|
|
944
|
+
mission_id: { type: "string", description: 'Mission created with chain="solana".' },
|
|
945
|
+
expected_budget: {
|
|
946
|
+
type: "number",
|
|
947
|
+
description: "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 \u2014 comparing the API to itself proves nothing."
|
|
948
|
+
},
|
|
949
|
+
expected_mint: {
|
|
950
|
+
type: "string",
|
|
951
|
+
description: "The mint you expect the budget to be taken in. Verified before signing. Defaults to the deployment payout mint."
|
|
952
|
+
},
|
|
953
|
+
dry_run: {
|
|
954
|
+
type: "boolean",
|
|
955
|
+
description: "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."
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
required: ["mission_id", "expected_budget"]
|
|
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
|
+
}
|
|
980
|
+
}
|
|
981
|
+
];
|
|
982
|
+
function toRawAmount(amount, decimals) {
|
|
983
|
+
const s = amount.toString();
|
|
984
|
+
if (!/^\d+(\.\d+)?$/.test(s)) {
|
|
985
|
+
throw new Error(`amount must be a non-negative decimal number, got ${s}`);
|
|
986
|
+
}
|
|
987
|
+
const [whole, frac = ""] = s.split(".");
|
|
988
|
+
if (frac.length > decimals) {
|
|
989
|
+
throw new Error(
|
|
990
|
+
`amount ${s} has ${frac.length} decimal places but the mint has only ${decimals}`
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
return (whole + frac.padEnd(decimals, "0")).replace(/^0+(?=\d)/, "");
|
|
994
|
+
}
|
|
995
|
+
var SOLANA_TOOL_NAMES = new Set(solanaTools.map((t) => t.name));
|
|
996
|
+
async function handleSolanaTool(name, args) {
|
|
997
|
+
const { apiGet: apiGet2, apiPost: apiPost2 } = await import("./client-2NLDPRAH.js");
|
|
998
|
+
switch (name) {
|
|
999
|
+
case "get_solana_config":
|
|
1000
|
+
return apiGet2("/agent/solana/config");
|
|
1001
|
+
case "get_solana_wallet": {
|
|
1002
|
+
const { hasSolanaWallet, loadSolanaWallet, associatedTokenAddress } = await import("./wallet-IUQWBW6F.js");
|
|
1003
|
+
if (!hasSolanaWallet()) {
|
|
1004
|
+
return {
|
|
1005
|
+
configured: false,
|
|
1006
|
+
how_to_configure: {
|
|
1007
|
+
option_1: "SOLO_SOLANA_KEYPAIR \u2014 JSON byte array, as `solana-keygen new` writes it",
|
|
1008
|
+
option_2: "SOLO_SOLANA_KEYPAIR_PATH \u2014 path to that file, e.g. ~/.config/solana/id.json"
|
|
1009
|
+
},
|
|
1010
|
+
what_you_need: "SOL for transaction fees and account rent, plus the payout token for the budget. Rent is a refundable deposit, not a fee \u2014 most of it returns on close_task. Roughly $0.58 is locked per mission and about $0.24 is permanent, which buys the on-chain record that makes the escrow verifiable by anyone."
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
const wallet = await loadSolanaWallet();
|
|
1014
|
+
const cfg = await apiGet2("/agent/solana/config");
|
|
1015
|
+
const symbol = Object.keys(cfg.mints)[0];
|
|
1016
|
+
const mint = args.mint ?? cfg.mints[symbol];
|
|
1017
|
+
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
1018
|
+
const rpc = async (method, params) => {
|
|
1019
|
+
const res = await fetch(cfg.rpc_url, {
|
|
1020
|
+
method: "POST",
|
|
1021
|
+
headers: { "Content-Type": "application/json" },
|
|
1022
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params })
|
|
1023
|
+
});
|
|
1024
|
+
return await res.json();
|
|
1025
|
+
};
|
|
1026
|
+
const solRes = await rpc("getBalance", [wallet.publicKey]);
|
|
1027
|
+
const tokRes = await rpc("getTokenAccountBalance", [tokenAccount]);
|
|
1028
|
+
const lamports = solRes.result?.value ?? 0;
|
|
1029
|
+
const decimals = cfg.decimals[symbol] ?? 6;
|
|
1030
|
+
return {
|
|
1031
|
+
configured: true,
|
|
1032
|
+
address: wallet.publicKey,
|
|
1033
|
+
sol: lamports / 1e9,
|
|
1034
|
+
// A token account only exists once tokens first arrive. create_task reads it, so funding
|
|
1035
|
+
// fails without one — and the on-chain error names the account rather than the missing
|
|
1036
|
+
// balance, which is confusing enough to call out explicitly.
|
|
1037
|
+
token_account: tokenAccount,
|
|
1038
|
+
token_account_exists: !tokRes.error,
|
|
1039
|
+
token_balance: tokRes.result?.value?.uiAmountString ?? "0",
|
|
1040
|
+
mint,
|
|
1041
|
+
can_pay_fees: lamports > 1e7,
|
|
1042
|
+
// ~0.01 SOL — comfortably covers rent plus fees
|
|
1043
|
+
note: lamports === 0 ? 'No SOL. Funding will fail with "Attempt to debit an account but found no record of a prior credit", which does not mention SOL.' : void 0,
|
|
1044
|
+
_decimals: decimals
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
case "fund_solana_mission": {
|
|
1048
|
+
const { loadSolanaWallet, associatedTokenAddress, signTransaction } = await import("./wallet-IUQWBW6F.js");
|
|
1049
|
+
const { verifyFundingTransaction } = await import("./verify-KAETIGV5.js");
|
|
1050
|
+
const wallet = await loadSolanaWallet();
|
|
1051
|
+
const cfg = await apiGet2("/agent/solana/config");
|
|
1052
|
+
const symbol = Object.keys(cfg.mints)[0];
|
|
1053
|
+
const mint = args.expected_mint ?? cfg.mints[symbol];
|
|
1054
|
+
const decimals = cfg.decimals[symbol] ?? 6;
|
|
1055
|
+
const tokenAccount = await associatedTokenAddress(mint, wallet.publicKey);
|
|
1056
|
+
const built = await apiPost2(
|
|
1057
|
+
`/agent/solana/missions/${args.mission_id}/funding-transaction`,
|
|
1058
|
+
{ sponsor_wallet: wallet.publicKey, sponsor_token_account: tokenAccount }
|
|
1059
|
+
);
|
|
1060
|
+
const expectedBudgetRaw = toRawAmount(args.expected_budget, decimals);
|
|
1061
|
+
const verdict = await verifyFundingTransaction({
|
|
1062
|
+
transaction_base64: built.transaction_base64,
|
|
1063
|
+
declared: built.declared,
|
|
1064
|
+
accounts: built.accounts,
|
|
1065
|
+
expected: {
|
|
1066
|
+
budget_raw: expectedBudgetRaw,
|
|
1067
|
+
// base_pool is derived by the backend from reward × max_humans. The agent's check on it
|
|
1068
|
+
// is the quoted value against the encoded bytes, which verifyFundingTransaction does —
|
|
1069
|
+
// asserting a locally recomputed figure would require duplicating that arithmetic here
|
|
1070
|
+
// and would fail on a legitimately rounded reward.
|
|
1071
|
+
base_pool_raw: String(built.declared.base_pool),
|
|
1072
|
+
lottery_winner_count: Number(built.declared.lottery_winner_count),
|
|
1073
|
+
lottery_prize_per_winner_raw: String(built.declared.lottery_prize_per_winner),
|
|
1074
|
+
qualify_deadline: Number(built.declared.qualify_deadline),
|
|
1075
|
+
settlement_deadline: Number(built.declared.settlement_deadline),
|
|
1076
|
+
mint,
|
|
1077
|
+
sponsor: wallet.publicKey
|
|
1078
|
+
},
|
|
1079
|
+
expected_program_id: cfg.program_id
|
|
1080
|
+
});
|
|
1081
|
+
if (!verdict.ok) {
|
|
1082
|
+
return {
|
|
1083
|
+
funded: false,
|
|
1084
|
+
refused_to_sign: true,
|
|
1085
|
+
problems: verdict.problems,
|
|
1086
|
+
summary: verdict.summary,
|
|
1087
|
+
what_this_means: "The transaction does not match what you asked for, so it was NOT signed and nothing was escrowed. This is the verifier doing its job. Do not retry blindly \u2014 the discrepancy above is either a bug or an attempt to have you authorise something else."
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
if (args.dry_run) {
|
|
1091
|
+
return {
|
|
1092
|
+
funded: false,
|
|
1093
|
+
dry_run: true,
|
|
1094
|
+
verified: true,
|
|
1095
|
+
task_id: built.task_id,
|
|
1096
|
+
summary: verdict.summary,
|
|
1097
|
+
would_escrow: `${args.expected_budget} (${expectedBudgetRaw} raw) of ${mint}`
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
const signed = await signTransaction(built.transaction_base64, wallet);
|
|
1101
|
+
const confirmed = await apiPost2(
|
|
1102
|
+
`/agent/solana/missions/${args.mission_id}/confirm-funding`,
|
|
1103
|
+
{ signed_transaction: signed, task_id: built.task_id }
|
|
1104
|
+
);
|
|
1105
|
+
return { funded: true, verified: true, ...confirmed };
|
|
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?.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
|
+
}
|
|
1187
|
+
default:
|
|
1188
|
+
throw new Error(`Unknown Solana tool: ${name}`);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
985
1192
|
// src/index.ts
|
|
986
|
-
var ALL_TOOLS = [...agentTools, ...missionTools, ...humanTools, ...conversationTools, ...realtimeTools, ...trackTools];
|
|
1193
|
+
var ALL_TOOLS = [...agentTools, ...missionTools, ...humanTools, ...conversationTools, ...realtimeTools, ...trackTools, ...solanaTools];
|
|
987
1194
|
var AGENT_TOOL_NAMES = new Set(agentTools.map((t) => t.name));
|
|
988
1195
|
var MISSION_TOOL_NAMES = new Set(missionTools.map((t) => t.name));
|
|
989
1196
|
var HUMAN_TOOL_NAMES = new Set(humanTools.map((t) => t.name));
|
|
@@ -1013,6 +1220,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1013
1220
|
result = await handleRealtimeTool(name, args);
|
|
1014
1221
|
} else if (TRACK_TOOL_NAMES.has(name)) {
|
|
1015
1222
|
result = await handleTrackTool(name, args);
|
|
1223
|
+
} else if (SOLANA_TOOL_NAMES.has(name)) {
|
|
1224
|
+
result = await handleSolanaTool(name, args);
|
|
1016
1225
|
} else {
|
|
1017
1226
|
return {
|
|
1018
1227
|
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// src/solana/verify.ts
|
|
2
|
+
var eqAmount = (a, b) => {
|
|
3
|
+
try {
|
|
4
|
+
return BigInt(a) === BigInt(b);
|
|
5
|
+
} catch {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
async function verifyFundingTransaction(input) {
|
|
10
|
+
const { Transaction, PublicKey } = await import("@solana/web3.js");
|
|
11
|
+
const problems = [];
|
|
12
|
+
const tx = Transaction.from(Buffer.from(input.transaction_base64, "base64"));
|
|
13
|
+
if (tx.instructions.length !== 1) {
|
|
14
|
+
problems.push(
|
|
15
|
+
`expected exactly 1 instruction, found ${tx.instructions.length} \u2014 additional instructions would be authorised by the same signature`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const ix = tx.instructions[0];
|
|
19
|
+
const programId = ix?.programId?.toBase58() ?? "(none)";
|
|
20
|
+
if (programId !== input.expected_program_id) {
|
|
21
|
+
problems.push(`program is ${programId}, expected ${input.expected_program_id}`);
|
|
22
|
+
}
|
|
23
|
+
if (programId !== input.accounts.program_id) {
|
|
24
|
+
problems.push(
|
|
25
|
+
`transaction program ${programId} disagrees with the quoted accounts.program_id ${input.accounts.program_id}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
|
|
29
|
+
if (signers.length !== 1 || signers[0] !== input.expected.sponsor) {
|
|
30
|
+
problems.push(
|
|
31
|
+
`expected the sponsor ${input.expected.sponsor} to be the only signer, found [${signers.join(", ")}]`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (tx.feePayer?.toBase58() !== input.expected.sponsor) {
|
|
35
|
+
problems.push(
|
|
36
|
+
`fee payer is ${tx.feePayer?.toBase58() ?? "(unset)"}, expected ${input.expected.sponsor}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const keyList = (ix?.keys ?? []).map((k) => k.pubkey.toBase58());
|
|
40
|
+
for (const [label, expected] of [
|
|
41
|
+
["mint", input.expected.mint],
|
|
42
|
+
["sponsor", input.expected.sponsor]
|
|
43
|
+
]) {
|
|
44
|
+
if (!keyList.includes(expected)) {
|
|
45
|
+
problems.push(`${label} ${expected} does not appear in the transaction's accounts`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (input.accounts.mint !== input.expected.mint) {
|
|
49
|
+
problems.push(`quoted mint ${input.accounts.mint}, expected ${input.expected.mint}`);
|
|
50
|
+
}
|
|
51
|
+
if (input.accounts.vault === input.accounts.sponsor_token_account) {
|
|
52
|
+
problems.push("escrow vault equals the sponsor token account \u2014 the budget would not be escrowed");
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
for (const [label, addr] of [
|
|
56
|
+
["task", input.accounts.task],
|
|
57
|
+
["vault", input.accounts.vault]
|
|
58
|
+
]) {
|
|
59
|
+
if (PublicKey.isOnCurve(new PublicKey(addr).toBytes())) {
|
|
60
|
+
problems.push(`${label} ${addr} is not a program-derived address \u2014 someone holds its key`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
problems.push("task or vault is not a valid address");
|
|
65
|
+
}
|
|
66
|
+
const d = input.declared;
|
|
67
|
+
const e = input.expected;
|
|
68
|
+
if (!eqAmount(d.budget, e.budget_raw)) {
|
|
69
|
+
problems.push(`budget is ${d.budget}, expected ${e.budget_raw}`);
|
|
70
|
+
}
|
|
71
|
+
if (!eqAmount(d.base_pool, e.base_pool_raw)) {
|
|
72
|
+
problems.push(`base_pool is ${d.base_pool}, expected ${e.base_pool_raw}`);
|
|
73
|
+
}
|
|
74
|
+
if (d.lottery_winner_count !== e.lottery_winner_count) {
|
|
75
|
+
problems.push(
|
|
76
|
+
`lottery_winner_count is ${d.lottery_winner_count}, expected ${e.lottery_winner_count}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!eqAmount(d.lottery_prize_per_winner, e.lottery_prize_per_winner_raw)) {
|
|
80
|
+
problems.push(
|
|
81
|
+
`lottery_prize_per_winner is ${d.lottery_prize_per_winner}, expected ${e.lottery_prize_per_winner_raw}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (!eqAmount(d.qualify_deadline, String(e.qualify_deadline))) {
|
|
85
|
+
problems.push(`qualify_deadline is ${d.qualify_deadline}, expected ${e.qualify_deadline}`);
|
|
86
|
+
}
|
|
87
|
+
if (!eqAmount(d.settlement_deadline, String(e.settlement_deadline))) {
|
|
88
|
+
problems.push(
|
|
89
|
+
`settlement_deadline is ${d.settlement_deadline}, expected ${e.settlement_deadline}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (!/^[0-9a-f]{64}$/i.test(d.seed_commit)) {
|
|
93
|
+
problems.push(`seed_commit is not 32 bytes of hex: ${d.seed_commit}`);
|
|
94
|
+
}
|
|
95
|
+
const data = ix?.data ?? Buffer.alloc(0);
|
|
96
|
+
if (data.length !== 8 + 8 + 8 + 4 + 8 + 8 + 8 + 32) {
|
|
97
|
+
problems.push(
|
|
98
|
+
`instruction data is ${data.length} bytes, expected 84 for create_task \u2014 this is not the instruction it claims to be`
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
const encoded = {
|
|
102
|
+
budget: data.readBigUInt64LE(8).toString(),
|
|
103
|
+
base_pool: data.readBigUInt64LE(16).toString(),
|
|
104
|
+
lottery_winner_count: data.readUInt32LE(24),
|
|
105
|
+
lottery_prize_per_winner: data.readBigUInt64LE(28).toString(),
|
|
106
|
+
qualify_deadline: data.readBigInt64LE(36).toString(),
|
|
107
|
+
settlement_deadline: data.readBigInt64LE(44).toString(),
|
|
108
|
+
seed_commit: data.subarray(52, 84).toString("hex")
|
|
109
|
+
};
|
|
110
|
+
for (const key of Object.keys(encoded)) {
|
|
111
|
+
const inBytes = String(encoded[key]);
|
|
112
|
+
const quoted = String(d[key]);
|
|
113
|
+
const same = key === "seed_commit" || key === "lottery_winner_count" ? inBytes.toLowerCase() === quoted.toLowerCase() : eqAmount(inBytes, quoted);
|
|
114
|
+
if (!same) {
|
|
115
|
+
problems.push(
|
|
116
|
+
`the transaction encodes ${key}=${inBytes} but the response quoted ${quoted} \u2014 the backend described one thing and built another`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
ok: problems.length === 0,
|
|
123
|
+
problems,
|
|
124
|
+
summary: {
|
|
125
|
+
program_id: programId,
|
|
126
|
+
instruction_count: tx.instructions.length,
|
|
127
|
+
signers,
|
|
128
|
+
budget: d.budget,
|
|
129
|
+
mint: input.accounts.mint,
|
|
130
|
+
task: input.accounts.task
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export {
|
|
135
|
+
verifyFundingTransaction
|
|
136
|
+
};
|