@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
package/src/index.ts
CHANGED
|
@@ -18,8 +18,9 @@ import { conversationTools, handleConversationTool } from './tools/conversations
|
|
|
18
18
|
import { realtimeTools, handleRealtimeTool } from './tools/realtime.js';
|
|
19
19
|
import { agentTools, handleAgentTool } from './tools/agent.js';
|
|
20
20
|
import { trackTools, handleTrackTool } from './tools/tracks.js';
|
|
21
|
+
import { solanaTools, SOLANA_TOOL_NAMES, handleSolanaTool } from './tools/solana.js';
|
|
21
22
|
|
|
22
|
-
const ALL_TOOLS = [...agentTools, ...missionTools, ...humanTools, ...conversationTools, ...realtimeTools, ...trackTools];
|
|
23
|
+
const ALL_TOOLS = [...agentTools, ...missionTools, ...humanTools, ...conversationTools, ...realtimeTools, ...trackTools, ...solanaTools];
|
|
23
24
|
|
|
24
25
|
const AGENT_TOOL_NAMES = new Set(agentTools.map((t) => t.name));
|
|
25
26
|
const MISSION_TOOL_NAMES = new Set(missionTools.map((t) => t.name));
|
|
@@ -55,6 +56,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
55
56
|
result = await handleRealtimeTool(name, args as Record<string, any>);
|
|
56
57
|
} else if (TRACK_TOOL_NAMES.has(name)) {
|
|
57
58
|
result = await handleTrackTool(name, args as Record<string, any>);
|
|
59
|
+
} else if (SOLANA_TOOL_NAMES.has(name)) {
|
|
60
|
+
result = await handleSolanaTool(name, args as Record<string, any>);
|
|
58
61
|
} else {
|
|
59
62
|
return {
|
|
60
63
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Release gate: fails if solo-firebase's live OpenAPI spec documents a route that no
|
|
4
|
+
* MCP tool in this package actually calls.
|
|
5
|
+
*
|
|
6
|
+
* Companion to solo_firebase#196 (mission-api now serves GET /agent/openapi.json,
|
|
7
|
+
* generated live from its own route annotations) and solo_mission_web#156 (the same
|
|
8
|
+
* gate for the /developers page). Without something on this side checking against it,
|
|
9
|
+
* a live spec existing doesn't stop these tools from drifting the same way the docs
|
|
10
|
+
* page did in solo_mission_web#154.
|
|
11
|
+
*
|
|
12
|
+
* Direction of the check, and why: this package's tool set is intentionally BIGGER
|
|
13
|
+
* than what the spec currently covers (36 tools vs. 19 annotated operations — most of
|
|
14
|
+
* solo-firebase's mission-api routes aren't annotated yet, see solo_firebase#196's
|
|
15
|
+
* "next steps"). Checking "every REST call in this package must appear in the spec"
|
|
16
|
+
* would fail on every one of those un-annotated routes for no real reason. Checking
|
|
17
|
+
* the other direction — "every route the spec DOES document must be called by
|
|
18
|
+
* something here" — only walks the spec's small, well-defined set, so it stays
|
|
19
|
+
* meaningful without needing a maintained allowlist of which tools are in scope.
|
|
20
|
+
*
|
|
21
|
+
* Extraction is static regex over src/tools/*.ts (same approach as solo-firebase's own
|
|
22
|
+
* openapiSpecConformance.test.ts and solo-mission-web's check-dev-docs-against-spec.mjs
|
|
23
|
+
* — no runtime app boot, no live network call except the one spec fetch), matching
|
|
24
|
+
* apiGet/apiPost/apiPut/apiDelete/publicApiPost call sites, tolerating the generic type
|
|
25
|
+
* argument (apiPost<T>(...)) and multi-line calls actually used in this codebase.
|
|
26
|
+
* Template-literal interpolations (${args.mission_id}) and OpenAPI {id}-style path
|
|
27
|
+
* params are both normalized to a generic :param token and compared positionally —
|
|
28
|
+
* exact param names don't need to match, only path shape.
|
|
29
|
+
*
|
|
30
|
+
* Usage: npx tsx src/scripts/check-tools-against-spec.ts
|
|
31
|
+
* Override the spec URL (e.g. against a local/staging backend) with SPEC_URL=...
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
|
|
37
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const TOOLS_DIR = join(__dirname, '../tools');
|
|
39
|
+
const SPEC_URL = process.env.SPEC_URL ?? 'https://api.mission.projectsolo.ai/agent/openapi.json';
|
|
40
|
+
|
|
41
|
+
const METHOD_BY_FN: Record<string, string> = {
|
|
42
|
+
apiGet: 'GET',
|
|
43
|
+
apiPost: 'POST',
|
|
44
|
+
apiPut: 'PUT',
|
|
45
|
+
apiDelete: 'DELETE',
|
|
46
|
+
publicApiPost: 'POST',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function normalizeParams(path: string): string {
|
|
50
|
+
return path
|
|
51
|
+
.split('?')[0] // strip query strings (e.g. the upload-url ?content_type=... calls)
|
|
52
|
+
.replace(/\$\{[^}]*\}/g, ':param') // template-literal interpolations
|
|
53
|
+
.replace(/\{[^}]*\}/g, ':param'); // OpenAPI {param} style, for symmetry if ever mixed in
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every "METHOD normalizedPath" this package's tools actually call, from any tools/*.ts file. */
|
|
57
|
+
function extractCalledRoutes(): Set<string> {
|
|
58
|
+
const routes = new Set<string>();
|
|
59
|
+
const files = readdirSync(TOOLS_DIR).filter((f) => f.endsWith('.ts'));
|
|
60
|
+
const fnNames = Object.keys(METHOD_BY_FN).join('|');
|
|
61
|
+
// Matches both the common case — a string/template literal passed straight into the
|
|
62
|
+
// call — and an identifier (e.g. apiPost(path)) built from a `const path = \`...\``
|
|
63
|
+
// a line or two earlier, which conversations.ts's upload-url tools both do (the path
|
|
64
|
+
// there needs a query string appended, so it's assembled before the call).
|
|
65
|
+
const directCallPattern = new RegExp(`\\b(${fnNames})\\s*(?:<[^>]*>)?\\s*\\(\\s*(\`|'|")((?:(?!\\2).)*)\\2`, 'gs');
|
|
66
|
+
const indirectCallPattern = new RegExp(`\\b(${fnNames})\\s*(?:<[^>]*>)?\\s*\\(\\s*([A-Za-z_$][A-Za-z0-9_$]*)\\s*[,)]`, 'g');
|
|
67
|
+
const constAssignPattern = /const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(`|'|")((?:(?!\2).)*)\2/gs;
|
|
68
|
+
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
const source = readFileSync(join(TOOLS_DIR, file), 'utf8');
|
|
71
|
+
|
|
72
|
+
let match: RegExpExecArray | null;
|
|
73
|
+
while ((match = directCallPattern.exec(source)) !== null) {
|
|
74
|
+
const [, fnName, , rawPath] = match;
|
|
75
|
+
routes.add(`${METHOD_BY_FN[fnName]} ${normalizeParams(rawPath)}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const localPaths = new Map<string, string>();
|
|
79
|
+
while ((match = constAssignPattern.exec(source)) !== null) {
|
|
80
|
+
const [, varName, , rawValue] = match;
|
|
81
|
+
if (rawValue.startsWith('/')) localPaths.set(varName, rawValue);
|
|
82
|
+
}
|
|
83
|
+
while ((match = indirectCallPattern.exec(source)) !== null) {
|
|
84
|
+
const [, fnName, varName] = match;
|
|
85
|
+
const rawPath = localPaths.get(varName);
|
|
86
|
+
if (rawPath) routes.add(`${METHOD_BY_FN[fnName]} ${normalizeParams(rawPath)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return routes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every "METHOD normalizedPath" the live spec documents. */
|
|
93
|
+
async function fetchSpecRoutes(): Promise<string[]> {
|
|
94
|
+
const res = await fetch(SPEC_URL);
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
throw new Error(`Failed to fetch ${SPEC_URL}: HTTP ${res.status}`);
|
|
97
|
+
}
|
|
98
|
+
const spec = (await res.json()) as { paths?: Record<string, Record<string, unknown>> };
|
|
99
|
+
const routes: string[] = [];
|
|
100
|
+
for (const [openApiPath, operations] of Object.entries(spec.paths ?? {})) {
|
|
101
|
+
const normalizedPath = normalizeParams(openApiPath);
|
|
102
|
+
for (const method of Object.keys(operations)) {
|
|
103
|
+
routes.push(`${method.toUpperCase()} ${normalizedPath}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return routes;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const called = extractCalledRoutes();
|
|
110
|
+
if (called.size < 10) {
|
|
111
|
+
// Sanity check on the extraction itself — if the call-site shape changes and the
|
|
112
|
+
// regex stops matching, fail loudly instead of silently passing on a near-empty set.
|
|
113
|
+
console.error(`Only extracted ${called.size} called routes from src/tools/*.ts — expected 15+. Regex may be out of sync with the call-site shape.`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let specRoutes: string[];
|
|
118
|
+
try {
|
|
119
|
+
specRoutes = await fetchSpecRoutes();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(`Could not verify tool coverage against the live API spec: ${(err as Error).message}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const uncalled = specRoutes.filter((route) => !called.has(route) && !route.endsWith('/agent/openapi.json'));
|
|
126
|
+
|
|
127
|
+
if (uncalled.length > 0) {
|
|
128
|
+
console.error("The live API spec documents routes that no tool in src/tools/*.ts calls:");
|
|
129
|
+
for (const route of uncalled) console.error(` - ${route}`);
|
|
130
|
+
console.error(`\nSpec source: ${SPEC_URL}`);
|
|
131
|
+
console.error("Either a tool's REST call was changed/removed without updating this check's");
|
|
132
|
+
console.error('expectations, or the backend added something here that this package should');
|
|
133
|
+
console.error('expose a tool for.');
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`OK: all ${specRoutes.length} spec-documented routes are called by at least one tool.`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "Real unsigned funding transaction from a devnet build. Regenerate via solo-firebase.",
|
|
3
|
+
"transaction_base64": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAYLBn/X5KDAn7qqA/To43J65jX78KnAaVCjVCRbz+mYBCgnoBchO2eGZuluIN1pWEEMoHQ4k7mn6xBq3fZc15Wq5l1iIspDEk7t/bB/Zl6fY+JPqVmMKxgM2AOUhUOv0xHDeqmxgXEbeQff54UY/FVyYjrgBnMBpdqqqbxSvY/zNlm7EfkLCeFhyL4FowTI4EcRrQqdoqcQGVgWSmDjzuigLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEcYq0VKhiU4ktY09qsij+rs+4b6Yt6VTnLfH9HKPyLcmlwpi3dEh8irDXYuBeelr9ra81Js5JF779Op1Y4ebznilsPINlCy+dlo0+WnBLgmlHMRNxcGJTTgj45SCbOkTxBwQrSJibjN7eiWjtGYLfQwfBf0s2Q6KtbBFFOSJw2EG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqTAL9uQNYRTTcBtK4JgAZhwS5nf9015fN/JiocFYjUg8AQYLAAIICQQBAwoFBwZUwlAGtOh/MKuAlpgAAAAAAICWmAAAAAAAAAAAAAAAAAAAAAAAXISfagAAAADs/KBqAAAAAFewZMVt1ZTMRLwAu4YKXz3zJhH7CWS8TDfikv6h314J",
|
|
4
|
+
"declared": {
|
|
5
|
+
"budget": "10000000",
|
|
6
|
+
"base_pool": "10000000",
|
|
7
|
+
"lottery_winner_count": 0,
|
|
8
|
+
"lottery_prize_per_winner": "0",
|
|
9
|
+
"qualify_deadline": "1788839004",
|
|
10
|
+
"settlement_deadline": "1788935404",
|
|
11
|
+
"seed_commit": "57b064c56dd594cc44bc00bb860a5f3df32611fb0964bc4c37e292fea1df5e09"
|
|
12
|
+
},
|
|
13
|
+
"accounts": {
|
|
14
|
+
"program_id": "2CPC5V63FDs7SdWu89iSYYsTEpqBwuQeYuA9ASzuSo8a",
|
|
15
|
+
"config": "7HXi3y1i3p4LVV3WGV6R4CM7waxNAXrjCX6SRSUcjEca",
|
|
16
|
+
"task": "DbF8pG81oyMbSv9YrBdjL9sNcsDZJxCg4G55uMjk64gt",
|
|
17
|
+
"vault": "3fgV8JmKJ4HBue1bPNL6vwXPqVVwXArMGbbiAWVQKNR3",
|
|
18
|
+
"whitelist": "97xTnhyGDmJkZsoNtcm7gjV48yfaBstijFDqiUFru7Jr",
|
|
19
|
+
"mint": "ECXjKm8nNMYFaUuv19DRxSqMKDckmddgKhBHrjHWU73E",
|
|
20
|
+
"sponsor": "SNWfcT9xHa6EgXc6UTSfDj1gCVjeZw8NtmNLqxq7X1Z",
|
|
21
|
+
"sponsor_token_account": "9FppNm8rDAukTaBVg2fHSjYxZpXNp6YvDcHgPmn1Tm9i"
|
|
22
|
+
},
|
|
23
|
+
"program_id": "2CPC5V63FDs7SdWu89iSYYsTEpqBwuQeYuA9ASzuSo8a",
|
|
24
|
+
"sponsor": "SNWfcT9xHa6EgXc6UTSfDj1gCVjeZw8NtmNLqxq7X1Z",
|
|
25
|
+
"mint": "ECXjKm8nNMYFaUuv19DRxSqMKDckmddgKhBHrjHWU73E"
|
|
26
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verifier is the one thing standing between an agent and signing bytes it cannot read, so it
|
|
3
|
+
* is tested against a REAL transaction built by the deployed program's own backend rather than a
|
|
4
|
+
* synthetic one. A hand-built fixture would encode my assumptions about the layout twice and agree
|
|
5
|
+
* with itself.
|
|
6
|
+
*
|
|
7
|
+
* `fixtures/funding-transaction.json` is captured from a devnet funding run.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { readFileSync } from 'fs'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import {
|
|
13
|
+
verifyFundingTransaction,
|
|
14
|
+
type VerifyInput,
|
|
15
|
+
type ExpectedFunding,
|
|
16
|
+
type FundingAccounts,
|
|
17
|
+
} from './verify'
|
|
18
|
+
import { toRawAmount } from '../tools/solana'
|
|
19
|
+
|
|
20
|
+
const fx = JSON.parse(
|
|
21
|
+
readFileSync(join(__dirname, 'fixtures', 'funding-transaction.json'), 'utf8'),
|
|
22
|
+
) as {
|
|
23
|
+
transaction_base64: string
|
|
24
|
+
declared: ExpectedFunding
|
|
25
|
+
accounts: FundingAccounts
|
|
26
|
+
program_id: string
|
|
27
|
+
sponsor: string
|
|
28
|
+
mint: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const baseInput = (): VerifyInput => ({
|
|
32
|
+
transaction_base64: fx.transaction_base64,
|
|
33
|
+
declared: fx.declared,
|
|
34
|
+
accounts: fx.accounts,
|
|
35
|
+
expected: {
|
|
36
|
+
budget_raw: String(fx.declared.budget),
|
|
37
|
+
base_pool_raw: String(fx.declared.base_pool),
|
|
38
|
+
lottery_winner_count: Number(fx.declared.lottery_winner_count),
|
|
39
|
+
lottery_prize_per_winner_raw: String(fx.declared.lottery_prize_per_winner),
|
|
40
|
+
qualify_deadline: Number(fx.declared.qualify_deadline),
|
|
41
|
+
settlement_deadline: Number(fx.declared.settlement_deadline),
|
|
42
|
+
mint: fx.mint,
|
|
43
|
+
sponsor: fx.sponsor,
|
|
44
|
+
},
|
|
45
|
+
expected_program_id: fx.program_id,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('accepts a genuine transaction', () => {
|
|
49
|
+
it('passes with no problems', async () => {
|
|
50
|
+
const r = await verifyFundingTransaction(baseInput())
|
|
51
|
+
expect(r.problems).toEqual([])
|
|
52
|
+
expect(r.ok).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('reports what it actually saw, for logging even on success', async () => {
|
|
56
|
+
const r = await verifyFundingTransaction(baseInput())
|
|
57
|
+
expect(r.summary.program_id).toBe(fx.program_id)
|
|
58
|
+
expect(r.summary.instruction_count).toBe(1)
|
|
59
|
+
expect(r.summary.signers).toEqual([fx.sponsor])
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('refuses when the agent expected something else', () => {
|
|
64
|
+
it('catches an altered budget', async () => {
|
|
65
|
+
const input = baseInput()
|
|
66
|
+
input.expected.budget_raw = '999999999'
|
|
67
|
+
const r = await verifyFundingTransaction(input)
|
|
68
|
+
expect(r.ok).toBe(false)
|
|
69
|
+
expect(r.problems.join(' ')).toMatch(/budget/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('catches a substituted mint', async () => {
|
|
73
|
+
const input = baseInput()
|
|
74
|
+
input.expected.mint = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'
|
|
75
|
+
const r = await verifyFundingTransaction(input)
|
|
76
|
+
expect(r.ok).toBe(false)
|
|
77
|
+
expect(r.problems.join(' ')).toMatch(/mint/)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('catches a different program, even when every parameter matches', async () => {
|
|
81
|
+
const input = baseInput()
|
|
82
|
+
input.expected_program_id = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
|
|
83
|
+
const r = await verifyFundingTransaction(input)
|
|
84
|
+
expect(r.ok).toBe(false)
|
|
85
|
+
expect(r.problems.join(' ')).toMatch(/program/)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('catches a sponsor who is not the signer', async () => {
|
|
89
|
+
const input = baseInput()
|
|
90
|
+
input.expected.sponsor = '4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi'
|
|
91
|
+
const r = await verifyFundingTransaction(input)
|
|
92
|
+
expect(r.ok).toBe(false)
|
|
93
|
+
expect(r.problems.join(' ')).toMatch(/signer|fee payer/i)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('reports every problem, not just the first', async () => {
|
|
97
|
+
// An agent debugging this wants the whole list, not one at a time.
|
|
98
|
+
const input = baseInput()
|
|
99
|
+
input.expected.budget_raw = '1'
|
|
100
|
+
input.expected.mint = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'
|
|
101
|
+
const r = await verifyFundingTransaction(input)
|
|
102
|
+
expect(r.problems.length).toBeGreaterThan(1)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('refuses when the backend quoted one thing and built another', () => {
|
|
107
|
+
it('catches a declared budget that disagrees with the encoded bytes', async () => {
|
|
108
|
+
// The strongest check: everything else compares the backend's JSON against the agent's
|
|
109
|
+
// expectations, which a lying backend controls both halves of. This compares the JSON against
|
|
110
|
+
// the instruction data actually being signed.
|
|
111
|
+
const input = baseInput()
|
|
112
|
+
input.declared = { ...fx.declared, budget: '1' }
|
|
113
|
+
input.expected.budget_raw = '1'
|
|
114
|
+
const r = await verifyFundingTransaction(input)
|
|
115
|
+
expect(r.ok).toBe(false)
|
|
116
|
+
expect(r.problems.join(' ')).toMatch(/described one thing and built another/)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('catches a declared deadline that disagrees with the bytes', async () => {
|
|
120
|
+
const input = baseInput()
|
|
121
|
+
input.declared = { ...fx.declared, settlement_deadline: '1' }
|
|
122
|
+
input.expected.settlement_deadline = 1
|
|
123
|
+
const r = await verifyFundingTransaction(input)
|
|
124
|
+
expect(r.ok).toBe(false)
|
|
125
|
+
expect(r.problems.join(' ')).toMatch(/described one thing and built another/)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('structural refusals', () => {
|
|
130
|
+
it('catches a vault equal to the sponsor token account', async () => {
|
|
131
|
+
// Would mean the budget never leaves the sponsor's control — funded on paper, not escrowed.
|
|
132
|
+
const input = baseInput()
|
|
133
|
+
input.accounts = { ...fx.accounts, vault: fx.accounts.sponsor_token_account }
|
|
134
|
+
const r = await verifyFundingTransaction(input)
|
|
135
|
+
expect(r.ok).toBe(false)
|
|
136
|
+
expect(r.problems.join(' ')).toMatch(/not be escrowed/)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('catches a vault that is not a program-derived address', async () => {
|
|
140
|
+
// An on-curve "vault" is a wallet somebody holds the key to.
|
|
141
|
+
const input = baseInput()
|
|
142
|
+
input.accounts = { ...fx.accounts, vault: fx.sponsor }
|
|
143
|
+
const r = await verifyFundingTransaction(input)
|
|
144
|
+
expect(r.ok).toBe(false)
|
|
145
|
+
expect(r.problems.join(' ')).toMatch(/program-derived|holds its key/)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('catches a malformed seed commit', async () => {
|
|
149
|
+
const input = baseInput()
|
|
150
|
+
input.declared = { ...fx.declared, seed_commit: 'nope' }
|
|
151
|
+
const r = await verifyFundingTransaction(input)
|
|
152
|
+
expect(r.ok).toBe(false)
|
|
153
|
+
expect(r.problems.join(' ')).toMatch(/seed_commit/)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('toRawAmount avoids the float trap', () => {
|
|
158
|
+
it('converts whole and fractional amounts exactly', () => {
|
|
159
|
+
expect(toRawAmount(10, 6)).toBe('10000000')
|
|
160
|
+
expect(toRawAmount(10.5, 6)).toBe('10500000')
|
|
161
|
+
expect(toRawAmount(0.5, 6)).toBe('500000')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('handles the amount that float multiplication gets wrong', () => {
|
|
165
|
+
// 0.07 * 1e6 is 70000.00000000001 in IEEE 754 and truncates to 69999 — a silent one-unit
|
|
166
|
+
// shortfall whose on-chain rejection names neither the amount nor the cause.
|
|
167
|
+
expect(toRawAmount(0.07, 6)).toBe('70000')
|
|
168
|
+
expect(toRawAmount(0.29, 6)).toBe('290000')
|
|
169
|
+
expect(toRawAmount(1.005, 6)).toBe('1005000')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('rejects more precision than the mint has', () => {
|
|
173
|
+
expect(() => toRawAmount(1.0000001, 6)).toThrow(/decimal places/)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('rejects a negative amount', () => {
|
|
177
|
+
expect(() => toRawAmount(-1, 6)).toThrow()
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decodes a funding transaction and checks it against what the agent asked for.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS MANDATORY, NOT DEFENSIVE. On Base an agent builds `createTask` itself from published
|
|
5
|
+
* parameters, so it can verify every field against the API response and the contract ABI before
|
|
6
|
+
* signing. Solana funding uses partial signing — the backend builds, the agent signs, the backend
|
|
7
|
+
* submits — which removes a whole class of integration breakage but hands the agent an opaque
|
|
8
|
+
* base64 blob.
|
|
9
|
+
*
|
|
10
|
+
* A human signing in Phantom gets that blob decoded for free by their wallet UI. **An agent has no
|
|
11
|
+
* wallet UI.** So without this, "the agent authorises exactly what it signs" is technically true and
|
|
12
|
+
* practically meaningless: it would be authorising bytes it cannot read. The design freeze calls
|
|
13
|
+
* this the least settled decision in the whole plan and makes the verifier a requirement of the
|
|
14
|
+
* flow, not an optional extra.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THIS DOES AND DOES NOT PROVE. It re-encodes the instruction data from the agent's own
|
|
17
|
+
* parameters and compares bytes, and it checks the accounts the transaction touches. So it catches a
|
|
18
|
+
* backend that quoted one budget and built another, a substituted mint, a redirected vault, or a
|
|
19
|
+
* different program entirely. It does NOT prove the backend will submit the transaction it showed
|
|
20
|
+
* you — but it cannot usefully alter one afterwards either, because any change invalidates the
|
|
21
|
+
* signature.
|
|
22
|
+
*
|
|
23
|
+
* This reintroduces a dependency on the instruction layout, which partial signing was meant to
|
|
24
|
+
* remove. The difference is where that dependency lives: inside a package we version and ship,
|
|
25
|
+
* rather than as a contract every third party reimplements. That is the whole point, and it is
|
|
26
|
+
* honest to say the shape dependency is reduced rather than eliminated.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** What the agent believes it is funding. Every field is compared. */
|
|
30
|
+
export interface ExpectedFunding {
|
|
31
|
+
/** Smallest-unit strings, as the API returns them. Compared as bigints so "10" and "10.0" or a
|
|
32
|
+
* leading zero cannot slip past a string equality check. */
|
|
33
|
+
budget: string;
|
|
34
|
+
base_pool: string;
|
|
35
|
+
lottery_winner_count: number;
|
|
36
|
+
lottery_prize_per_winner: string;
|
|
37
|
+
qualify_deadline: string;
|
|
38
|
+
settlement_deadline: string;
|
|
39
|
+
/** 64 hex chars, no 0x. */
|
|
40
|
+
seed_commit: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FundingAccounts {
|
|
44
|
+
program_id: string;
|
|
45
|
+
config: string;
|
|
46
|
+
task: string;
|
|
47
|
+
vault: string;
|
|
48
|
+
whitelist: string;
|
|
49
|
+
mint: string;
|
|
50
|
+
sponsor: string;
|
|
51
|
+
sponsor_token_account: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface VerifyInput {
|
|
55
|
+
transaction_base64: string;
|
|
56
|
+
declared: ExpectedFunding;
|
|
57
|
+
accounts: FundingAccounts;
|
|
58
|
+
/** What the agent asked for, independent of what the backend replied. */
|
|
59
|
+
expected: {
|
|
60
|
+
budget_raw: string;
|
|
61
|
+
base_pool_raw: string;
|
|
62
|
+
lottery_winner_count: number;
|
|
63
|
+
lottery_prize_per_winner_raw: string;
|
|
64
|
+
qualify_deadline: number;
|
|
65
|
+
settlement_deadline: number;
|
|
66
|
+
mint: string;
|
|
67
|
+
sponsor: string;
|
|
68
|
+
};
|
|
69
|
+
/** The program the agent expects. Pinned so a transaction pointed at a different program is
|
|
70
|
+
* rejected even if every parameter matches. */
|
|
71
|
+
expected_program_id: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface VerifyResult {
|
|
75
|
+
ok: boolean;
|
|
76
|
+
/** Every discrepancy found, not just the first — an agent debugging this wants the whole list. */
|
|
77
|
+
problems: string[];
|
|
78
|
+
/** What the transaction actually contains, for logging even on success. */
|
|
79
|
+
summary: {
|
|
80
|
+
program_id: string;
|
|
81
|
+
instruction_count: number;
|
|
82
|
+
signers: string[];
|
|
83
|
+
budget: string;
|
|
84
|
+
mint: string;
|
|
85
|
+
task: string;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const eqAmount = (a: string, b: string): boolean => {
|
|
90
|
+
try {
|
|
91
|
+
return BigInt(a) === BigInt(b);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Verifies a funding transaction. Returns every problem rather than throwing on the first.
|
|
99
|
+
*
|
|
100
|
+
* REFUSE TO SIGN when `ok` is false. The caller must treat this as a hard stop — a mismatch means
|
|
101
|
+
* the backend built something other than what was quoted, and signing it authorises that difference.
|
|
102
|
+
*/
|
|
103
|
+
export async function verifyFundingTransaction(input: VerifyInput): Promise<VerifyResult> {
|
|
104
|
+
const { Transaction, PublicKey } = await import('@solana/web3.js');
|
|
105
|
+
const problems: string[] = [];
|
|
106
|
+
|
|
107
|
+
const tx = Transaction.from(Buffer.from(input.transaction_base64, 'base64'));
|
|
108
|
+
|
|
109
|
+
// ---- structure ------------------------------------------------------------------------------
|
|
110
|
+
// Exactly one instruction. An extra one is the cheapest way to hide something — a token transfer
|
|
111
|
+
// appended after create_task would be signed by the same signature.
|
|
112
|
+
if (tx.instructions.length !== 1) {
|
|
113
|
+
problems.push(
|
|
114
|
+
`expected exactly 1 instruction, found ${tx.instructions.length} — ` +
|
|
115
|
+
'additional instructions would be authorised by the same signature',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ix = tx.instructions[0];
|
|
120
|
+
const programId = ix?.programId?.toBase58() ?? '(none)';
|
|
121
|
+
if (programId !== input.expected_program_id) {
|
|
122
|
+
problems.push(`program is ${programId}, expected ${input.expected_program_id}`);
|
|
123
|
+
}
|
|
124
|
+
if (programId !== input.accounts.program_id) {
|
|
125
|
+
problems.push(
|
|
126
|
+
`transaction program ${programId} disagrees with the quoted accounts.program_id ` +
|
|
127
|
+
`${input.accounts.program_id}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- the only signer must be us --------------------------------------------------------------
|
|
132
|
+
const signers = (ix?.keys ?? []).filter((k) => k.isSigner).map((k) => k.pubkey.toBase58());
|
|
133
|
+
if (signers.length !== 1 || signers[0] !== input.expected.sponsor) {
|
|
134
|
+
problems.push(
|
|
135
|
+
`expected the sponsor ${input.expected.sponsor} to be the only signer, found [${signers.join(', ')}]`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (tx.feePayer?.toBase58() !== input.expected.sponsor) {
|
|
139
|
+
problems.push(
|
|
140
|
+
`fee payer is ${tx.feePayer?.toBase58() ?? '(unset)'}, expected ${input.expected.sponsor}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- the accounts the money moves between ----------------------------------------------------
|
|
145
|
+
const keyList = (ix?.keys ?? []).map((k) => k.pubkey.toBase58());
|
|
146
|
+
for (const [label, expected] of [
|
|
147
|
+
['mint', input.expected.mint],
|
|
148
|
+
['sponsor', input.expected.sponsor],
|
|
149
|
+
] as const) {
|
|
150
|
+
if (!keyList.includes(expected)) {
|
|
151
|
+
problems.push(`${label} ${expected} does not appear in the transaction's accounts`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (input.accounts.mint !== input.expected.mint) {
|
|
155
|
+
problems.push(`quoted mint ${input.accounts.mint}, expected ${input.expected.mint}`);
|
|
156
|
+
}
|
|
157
|
+
// The task and its vault are PDAs the program derives; they cannot be verified without repeating
|
|
158
|
+
// the derivation, but they MUST be distinct and must both be present. A vault equal to the
|
|
159
|
+
// sponsor's own token account would mean the budget never leaves their control.
|
|
160
|
+
if (input.accounts.vault === input.accounts.sponsor_token_account) {
|
|
161
|
+
problems.push('escrow vault equals the sponsor token account — the budget would not be escrowed');
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
// Both must be off-curve, i.e. genuine PDAs with no private key. An on-curve "vault" is a
|
|
165
|
+
// wallet somebody holds the key to.
|
|
166
|
+
for (const [label, addr] of [
|
|
167
|
+
['task', input.accounts.task],
|
|
168
|
+
['vault', input.accounts.vault],
|
|
169
|
+
] as const) {
|
|
170
|
+
if (PublicKey.isOnCurve(new PublicKey(addr).toBytes())) {
|
|
171
|
+
problems.push(`${label} ${addr} is not a program-derived address — someone holds its key`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
problems.push('task or vault is not a valid address');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- the parameters --------------------------------------------------------------------------
|
|
179
|
+
const d = input.declared;
|
|
180
|
+
const e = input.expected;
|
|
181
|
+
if (!eqAmount(d.budget, e.budget_raw)) {
|
|
182
|
+
problems.push(`budget is ${d.budget}, expected ${e.budget_raw}`);
|
|
183
|
+
}
|
|
184
|
+
if (!eqAmount(d.base_pool, e.base_pool_raw)) {
|
|
185
|
+
problems.push(`base_pool is ${d.base_pool}, expected ${e.base_pool_raw}`);
|
|
186
|
+
}
|
|
187
|
+
if (d.lottery_winner_count !== e.lottery_winner_count) {
|
|
188
|
+
problems.push(
|
|
189
|
+
`lottery_winner_count is ${d.lottery_winner_count}, expected ${e.lottery_winner_count}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (!eqAmount(d.lottery_prize_per_winner, e.lottery_prize_per_winner_raw)) {
|
|
193
|
+
problems.push(
|
|
194
|
+
`lottery_prize_per_winner is ${d.lottery_prize_per_winner}, expected ${e.lottery_prize_per_winner_raw}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (!eqAmount(d.qualify_deadline, String(e.qualify_deadline))) {
|
|
198
|
+
problems.push(`qualify_deadline is ${d.qualify_deadline}, expected ${e.qualify_deadline}`);
|
|
199
|
+
}
|
|
200
|
+
if (!eqAmount(d.settlement_deadline, String(e.settlement_deadline))) {
|
|
201
|
+
problems.push(
|
|
202
|
+
`settlement_deadline is ${d.settlement_deadline}, expected ${e.settlement_deadline}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!/^[0-9a-f]{64}$/i.test(d.seed_commit)) {
|
|
206
|
+
problems.push(`seed_commit is not 32 bytes of hex: ${d.seed_commit}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- the declared parameters must match the bytes actually being signed ----------------------
|
|
210
|
+
// The strongest check here. Everything above compares the backend's own JSON description against
|
|
211
|
+
// the agent's expectations; this compares that description against the instruction data itself, so
|
|
212
|
+
// a backend cannot quote correct parameters and encode different ones.
|
|
213
|
+
const data = ix?.data ?? Buffer.alloc(0);
|
|
214
|
+
if (data.length !== 8 + 8 + 8 + 4 + 8 + 8 + 8 + 32) {
|
|
215
|
+
problems.push(
|
|
216
|
+
`instruction data is ${data.length} bytes, expected 84 for create_task — ` +
|
|
217
|
+
'this is not the instruction it claims to be',
|
|
218
|
+
);
|
|
219
|
+
} else {
|
|
220
|
+
const encoded = {
|
|
221
|
+
budget: data.readBigUInt64LE(8).toString(),
|
|
222
|
+
base_pool: data.readBigUInt64LE(16).toString(),
|
|
223
|
+
lottery_winner_count: data.readUInt32LE(24),
|
|
224
|
+
lottery_prize_per_winner: data.readBigUInt64LE(28).toString(),
|
|
225
|
+
qualify_deadline: data.readBigInt64LE(36).toString(),
|
|
226
|
+
settlement_deadline: data.readBigInt64LE(44).toString(),
|
|
227
|
+
seed_commit: data.subarray(52, 84).toString('hex'),
|
|
228
|
+
};
|
|
229
|
+
for (const key of Object.keys(encoded) as Array<keyof typeof encoded>) {
|
|
230
|
+
const inBytes = String(encoded[key]);
|
|
231
|
+
const quoted = String((d as unknown as Record<string, unknown>)[key]);
|
|
232
|
+
const same =
|
|
233
|
+
key === 'seed_commit' || key === 'lottery_winner_count'
|
|
234
|
+
? inBytes.toLowerCase() === quoted.toLowerCase()
|
|
235
|
+
: eqAmount(inBytes, quoted);
|
|
236
|
+
if (!same) {
|
|
237
|
+
problems.push(
|
|
238
|
+
`the transaction encodes ${key}=${inBytes} but the response quoted ${quoted} — ` +
|
|
239
|
+
'the backend described one thing and built another',
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
ok: problems.length === 0,
|
|
247
|
+
problems,
|
|
248
|
+
summary: {
|
|
249
|
+
program_id: programId,
|
|
250
|
+
instruction_count: tx.instructions.length,
|
|
251
|
+
signers,
|
|
252
|
+
budget: d.budget,
|
|
253
|
+
mint: input.accounts.mint,
|
|
254
|
+
task: input.accounts.task,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|