@dfm-fi/agent 0.2.1916 → 0.2.1941
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/mcp-server.js +54 -2
- package/dist/onboarding-api.d.ts +5 -0
- package/dist/onboarding-api.js +3 -0
- package/dist/owner-actions.d.ts +94 -0
- package/dist/owner-actions.js +109 -0
- package/dist/owner-tx-verify.d.ts +70 -0
- package/dist/owner-tx-verify.js +341 -0
- package/package.json +1 -1
- package/skills.md +3 -2
package/dist/mcp-server.js
CHANGED
|
@@ -70,6 +70,8 @@ import { DEFAULT_OWNER_KEY_PATH, ownerKeyPath, policyUpdateNotice, runCreateAppW
|
|
|
70
70
|
import { getTestWalletPubkey, signBase64Transaction } from './signing.js';
|
|
71
71
|
import { ONBOARDING_ORDER, avatarPath, ensureIdentityWallet, loadAvatar, mintAndStoreKey, runOnboard } from './onboarding.js';
|
|
72
72
|
import { makeIdentityGuard, makeKeyOwnerLookup, makeSessionFetch, onboardingApi } from './onboarding-api.js';
|
|
73
|
+
import { existingOwnerKey, runOwnerAction } from './owner-actions.js';
|
|
74
|
+
import { Connection } from '@solana/web3.js';
|
|
73
75
|
import { buildWalletIdentity, resolveZapStatusWallet, withActingWalletAlias, withPortfolioWalletAlias, } from './wallet-identity.js';
|
|
74
76
|
const config = loadConfig();
|
|
75
77
|
const session = new SiwsSession(config);
|
|
@@ -164,6 +166,30 @@ const errText = (msg) => ({
|
|
|
164
166
|
* unsigned tx (`ownerSignature`) that only the owner can sign in the app; an
|
|
165
167
|
* MCP client cannot, so say so instead of reporting an empty signature.
|
|
166
168
|
*/
|
|
169
|
+
/**
|
|
170
|
+
* Agent-owned app wallet: when the API answers `ownerSignature` and THIS
|
|
171
|
+
* machine holds the wallet's P-256 owner key, complete the action here —
|
|
172
|
+
* the tx is decoded and verified locally before the key signs anything
|
|
173
|
+
* (owner-actions.ts). Returns null when there is no local owner key (a
|
|
174
|
+
* Privy-user wallet keeps "approve in the app").
|
|
175
|
+
*/
|
|
176
|
+
async function ownerSignLocally(res, input) {
|
|
177
|
+
if (!(res && typeof res === 'object' && 'ownerSignature' in res && res.ownerSignature))
|
|
178
|
+
return null;
|
|
179
|
+
const key = existingOwnerKey();
|
|
180
|
+
if (!key)
|
|
181
|
+
return null;
|
|
182
|
+
await assertSameIdentity();
|
|
183
|
+
const conn = new Connection(config.rpcUrl || 'https://api.mainnet-beta.solana.com', 'confirmed');
|
|
184
|
+
return text(await runOwnerAction({
|
|
185
|
+
key,
|
|
186
|
+
signInWallet: session.walletAddress,
|
|
187
|
+
appWallet: async () => (await api.getOperatingWallet())?.operatingWalletAddress,
|
|
188
|
+
prepare: (i) => onboardApi.prepareOwnerAction(i),
|
|
189
|
+
submit: (id, sig) => onboardApi.submitOwnerAction(id, sig),
|
|
190
|
+
resolveAlt: async (k) => (await conn.getAddressLookupTable(k)).value,
|
|
191
|
+
}, input));
|
|
192
|
+
}
|
|
167
193
|
function ownerApprovalOr(res, what, where) {
|
|
168
194
|
if (res && typeof res === 'object' && 'ownerSignature' in res && res.ownerSignature) {
|
|
169
195
|
return errText(`not executed: your app wallet is yours (user-owned), so this ${what} needs your one-click approval in the app — open https://app.dfm.finance → ${where}.`);
|
|
@@ -638,6 +664,28 @@ server.registerTool('onboard', {
|
|
|
638
664
|
return errText(`onboard failed: ${e.message}`);
|
|
639
665
|
}
|
|
640
666
|
});
|
|
667
|
+
server.registerTool('close_empty_accounts', {
|
|
668
|
+
title: 'Close empty token accounts (agent-owned app wallet)',
|
|
669
|
+
description: 'Close every EMPTY token account of this agent\'s own app wallet (owner = the local P-256 key from ' +
|
|
670
|
+
'create_app_wallet) and send the rent to the sign-in wallet (default) or back to the app wallet. A ' +
|
|
671
|
+
'non-empty account is never included. The transaction is decoded and checked locally (only CloseAccount, ' +
|
|
672
|
+
'authority = the app wallet, rent to the chosen destination) before the owner key signs. Moves only rent.',
|
|
673
|
+
inputSchema: {
|
|
674
|
+
destination: z.enum(['sign_in_wallet', 'app_wallet']).optional().describe('where the rent goes; default sign_in_wallet'),
|
|
675
|
+
},
|
|
676
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
677
|
+
}, async ({ destination }) => {
|
|
678
|
+
const write = writeGate();
|
|
679
|
+
if (!write.ok)
|
|
680
|
+
return write.result;
|
|
681
|
+
try {
|
|
682
|
+
const out = await ownerSignLocally({ ownerSignature: true }, { kind: 'close_empty', ...(destination ? { destination } : {}) });
|
|
683
|
+
return out ?? errText('close_empty_accounts needs this agent\'s owner key (create_app_wallet); a Privy-user wallet reclaims rent in the app.');
|
|
684
|
+
}
|
|
685
|
+
catch (e) {
|
|
686
|
+
return errText(`close_empty_accounts failed: ${e.message}`);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
641
689
|
server.registerTool('xp_status', {
|
|
642
690
|
title: 'XP',
|
|
643
691
|
description: "The user's XP (total and by kind: deposit, launch, profile, referral) and the top of the XP leaderboard.",
|
|
@@ -993,7 +1041,9 @@ async ({ inputMint, outputMint, amountRaw, slippageBps }) => {
|
|
|
993
1041
|
if (!write.ok)
|
|
994
1042
|
return write.result;
|
|
995
1043
|
try {
|
|
996
|
-
|
|
1044
|
+
const res = await api.swap({ inputMint, outputMint, amountRaw, slippageBps });
|
|
1045
|
+
return (await ownerSignLocally(res, { kind: 'swap', inputMint, outputMint, amountRaw, ...(slippageBps !== undefined ? { slippageBps } : {}) }))
|
|
1046
|
+
?? ownerApprovalOr(res, 'swap', 'the Swap panel');
|
|
997
1047
|
}
|
|
998
1048
|
catch (e) {
|
|
999
1049
|
return errText(e.message);
|
|
@@ -1345,7 +1395,9 @@ server.registerTool('withdraw_asset', {
|
|
|
1345
1395
|
if (!write.ok)
|
|
1346
1396
|
return write.result;
|
|
1347
1397
|
try {
|
|
1348
|
-
|
|
1398
|
+
const res = await api.withdrawAppWalletAsset(mint, amount);
|
|
1399
|
+
return (await ownerSignLocally(res, { kind: 'withdraw_asset', mint, ...(amount !== undefined ? { amount: String(amount) } : {}) }))
|
|
1400
|
+
?? ownerApprovalOr(res, 'withdrawal', 'Portfolio → App wallet');
|
|
1349
1401
|
}
|
|
1350
1402
|
catch (e) {
|
|
1351
1403
|
return errText(`withdraw_asset failed: ${e.message}`);
|
package/dist/onboarding-api.d.ts
CHANGED
|
@@ -37,6 +37,11 @@ export declare function onboardingApi(call: SessionFetch): {
|
|
|
37
37
|
}) => Promise<Record<string, unknown>>;
|
|
38
38
|
uploadAvatar: (bytes: Buffer, filename: string, mime: string) => Promise<Record<string, unknown>>;
|
|
39
39
|
createApiKey: (body: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
40
|
+
/** Owner-signed actions (agent-owned wallets): build → the MCP decodes + signs → submit. */
|
|
41
|
+
prepareOwnerAction: (input: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
42
|
+
submitOwnerAction: (requestId: string, signature: string) => Promise<{
|
|
43
|
+
signature?: string;
|
|
44
|
+
}>;
|
|
40
45
|
revokeApiKey: (id: string) => Promise<Record<string, unknown>>;
|
|
41
46
|
};
|
|
42
47
|
//# sourceMappingURL=onboarding-api.d.ts.map
|
package/dist/onboarding-api.js
CHANGED
|
@@ -96,6 +96,9 @@ export function onboardingApi(call) {
|
|
|
96
96
|
return call('/profile/avatar', { method: 'POST', body: form });
|
|
97
97
|
},
|
|
98
98
|
createApiKey: (body) => call('/agent/keys', { method: 'POST', json: body }),
|
|
99
|
+
/** Owner-signed actions (agent-owned wallets): build → the MCP decodes + signs → submit. */
|
|
100
|
+
prepareOwnerAction: (input) => call('/agent/operating/embedded/agent-wallet/owner-request', { method: 'POST', json: input }),
|
|
101
|
+
submitOwnerAction: (requestId, signature) => call('/agent/operating/embedded/agent-wallet/owner-request/submit', { method: 'POST', json: { requestId, signature } }),
|
|
99
102
|
revokeApiKey: (id) => call(`/agent/keys/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
100
103
|
};
|
|
101
104
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-signed actions for an AGENT-owned app wallet (the MCP side). When the
|
|
3
|
+
* API answers a swap / asset withdrawal with `ownerSignature` and this machine
|
|
4
|
+
* holds the wallet's P-256 owner key, the MCP completes it itself:
|
|
5
|
+
* 1. `owner-request` → the server builds + asserts the tx and returns the
|
|
6
|
+
* exact Privy `signAndSendTransaction` RPC request to sign;
|
|
7
|
+
* 2. the request envelope is checked against PINNED values (Privy app id,
|
|
8
|
+
* RPC url for that wallet, method, caip2, expiry ≤ 10 min) and the
|
|
9
|
+
* payload is rebuilt locally;
|
|
10
|
+
* 3. `body.params.transaction` is decoded HERE and checked against what the
|
|
11
|
+
* tool was asked to do (owner-tx-verify.ts) — a payload whose transaction
|
|
12
|
+
* was not decoded and approved is never signed;
|
|
13
|
+
* 4. sign with the owner key, `owner-request/submit`.
|
|
14
|
+
*/
|
|
15
|
+
import { type OwnerKey } from './app-wallet-owner.js';
|
|
16
|
+
import { type AltResolver } from './owner-tx-verify.js';
|
|
17
|
+
export declare const SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
18
|
+
export type OwnerActionInput = {
|
|
19
|
+
kind: 'swap';
|
|
20
|
+
inputMint: string;
|
|
21
|
+
outputMint: string;
|
|
22
|
+
amountRaw: string;
|
|
23
|
+
slippageBps?: number;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'withdraw_asset';
|
|
26
|
+
mint: string;
|
|
27
|
+
amount?: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'close_empty';
|
|
30
|
+
destination?: 'sign_in_wallet' | 'app_wallet';
|
|
31
|
+
};
|
|
32
|
+
export interface OwnerRpcRequest {
|
|
33
|
+
status: 'awaiting_owner_signature';
|
|
34
|
+
requestId: string;
|
|
35
|
+
walletId: string;
|
|
36
|
+
appWallet: string;
|
|
37
|
+
url: string;
|
|
38
|
+
body: {
|
|
39
|
+
method: string;
|
|
40
|
+
caip2: string;
|
|
41
|
+
params: {
|
|
42
|
+
transaction: string;
|
|
43
|
+
encoding: string;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
headers: Record<string, string>;
|
|
47
|
+
payload: string;
|
|
48
|
+
summary?: unknown;
|
|
49
|
+
}
|
|
50
|
+
/** The envelope: pinned app id, this wallet's RPC url, signAndSendTransaction on mainnet, fresh expiry. Returns the rebuilt payload. */
|
|
51
|
+
export declare function checkOwnerRpcRequest(r: OwnerRpcRequest, now?: number): string;
|
|
52
|
+
/** The owner key, ONLY if it already exists on this machine (never created here). */
|
|
53
|
+
export declare function existingOwnerKey(path?: string): OwnerKey | null;
|
|
54
|
+
export declare const DEFAULT_SWAP_SLIPPAGE_BPS = 50;
|
|
55
|
+
/**
|
|
56
|
+
* The MCP's OWN Jupiter quote (review S20), straight from Jupiter — never via
|
|
57
|
+
* DFM — so a compromised server cannot make the owner sign a route with a
|
|
58
|
+
* near-zero minimum output. No key needed; `DFM_JUPITER_QUOTE_URL` overrides.
|
|
59
|
+
* Any failure throws: no independent quote, no signature.
|
|
60
|
+
*/
|
|
61
|
+
export declare function fetchOwnQuote(q: {
|
|
62
|
+
inputMint: string;
|
|
63
|
+
outputMint: string;
|
|
64
|
+
amountRaw: string;
|
|
65
|
+
slippageBps: number;
|
|
66
|
+
platformFeeBps?: number;
|
|
67
|
+
}, doFetch?: typeof fetch): Promise<bigint>;
|
|
68
|
+
export interface OwnerActionDeps {
|
|
69
|
+
key: OwnerKey;
|
|
70
|
+
signInWallet: string;
|
|
71
|
+
/**
|
|
72
|
+
* The app wallet as GET /agent/operating reports it. NOT a security control
|
|
73
|
+
* (both values come from DFM's server — review S23): it only catches a
|
|
74
|
+
* confused request early. What binds the signature is the walletId inside
|
|
75
|
+
* the signed URL (Privy signs with exactly that wallet) plus the locally
|
|
76
|
+
* checked destinations (the app wallet's own ATAs, the local sign-in wallet).
|
|
77
|
+
*/
|
|
78
|
+
appWallet(): Promise<string | undefined>;
|
|
79
|
+
/** Independent Jupiter quote (swap only). */
|
|
80
|
+
ownQuote?(q: {
|
|
81
|
+
inputMint: string;
|
|
82
|
+
outputMint: string;
|
|
83
|
+
amountRaw: string;
|
|
84
|
+
slippageBps: number;
|
|
85
|
+
platformFeeBps?: number;
|
|
86
|
+
}): Promise<bigint>;
|
|
87
|
+
prepare(input: OwnerActionInput): Promise<Record<string, unknown>>;
|
|
88
|
+
submit(requestId: string, signature: string): Promise<{
|
|
89
|
+
signature?: string;
|
|
90
|
+
}>;
|
|
91
|
+
resolveAlt: AltResolver;
|
|
92
|
+
}
|
|
93
|
+
export declare function runOwnerAction(deps: OwnerActionDeps, input: OwnerActionInput): Promise<Record<string, unknown>>;
|
|
94
|
+
//# sourceMappingURL=owner-actions.d.ts.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-signed actions for an AGENT-owned app wallet (the MCP side). When the
|
|
3
|
+
* API answers a swap / asset withdrawal with `ownerSignature` and this machine
|
|
4
|
+
* holds the wallet's P-256 owner key, the MCP completes it itself:
|
|
5
|
+
* 1. `owner-request` → the server builds + asserts the tx and returns the
|
|
6
|
+
* exact Privy `signAndSendTransaction` RPC request to sign;
|
|
7
|
+
* 2. the request envelope is checked against PINNED values (Privy app id,
|
|
8
|
+
* RPC url for that wallet, method, caip2, expiry ≤ 10 min) and the
|
|
9
|
+
* payload is rebuilt locally;
|
|
10
|
+
* 3. `body.params.transaction` is decoded HERE and checked against what the
|
|
11
|
+
* tool was asked to do (owner-tx-verify.ts) — a payload whose transaction
|
|
12
|
+
* was not decoded and approved is never signed;
|
|
13
|
+
* 4. sign with the owner key, `owner-request/submit`.
|
|
14
|
+
*/
|
|
15
|
+
import { MAX_REQUEST_EXPIRY_AHEAD_MS, canonicalJson, lstatOrNull, loadOrCreateOwnerKey, ownerKeyPath, signerPins } from './app-wallet-owner.js';
|
|
16
|
+
import { decodeTx, JUPITER_V6, MAX_PLATFORM_FEE_BPS, verifyCloseIxs, verifySwapIxs, verifyWithdrawIxs } from './owner-tx-verify.js';
|
|
17
|
+
export const SOLANA_MAINNET_CAIP2 = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
|
|
18
|
+
const keysAre = (o, keys) => !!o && typeof o === 'object' && !Array.isArray(o) && Object.keys(o).sort().join() === [...keys].sort().join();
|
|
19
|
+
/** The envelope: pinned app id, this wallet's RPC url, signAndSendTransaction on mainnet, fresh expiry. Returns the rebuilt payload. */
|
|
20
|
+
export function checkOwnerRpcRequest(r, now = Date.now()) {
|
|
21
|
+
const pins = signerPins();
|
|
22
|
+
const url = `https://api.privy.io/v1/wallets/${encodeURIComponent(r.walletId)}/rpc`;
|
|
23
|
+
const exp = r.headers?.['privy-request-expiry'];
|
|
24
|
+
const ok = typeof r.walletId === 'string' && r.walletId.length > 0 &&
|
|
25
|
+
r.url === url &&
|
|
26
|
+
keysAre(r.body, ['method', 'caip2', 'params']) &&
|
|
27
|
+
r.body.method === 'signAndSendTransaction' &&
|
|
28
|
+
r.body.caip2 === SOLANA_MAINNET_CAIP2 &&
|
|
29
|
+
keysAre(r.body.params, ['transaction', 'encoding']) &&
|
|
30
|
+
r.body.params.encoding === 'base64' &&
|
|
31
|
+
typeof r.body.params.transaction === 'string' &&
|
|
32
|
+
keysAre(r.headers, ['privy-app-id', 'privy-request-expiry']) &&
|
|
33
|
+
r.headers['privy-app-id'] === pins.appId &&
|
|
34
|
+
typeof exp === 'string' && /^\d{13}$/.test(exp) &&
|
|
35
|
+
Number(exp) > now && Number(exp) <= now + MAX_REQUEST_EXPIRY_AHEAD_MS;
|
|
36
|
+
if (!ok)
|
|
37
|
+
throw new Error('refusing to sign: the owner request is not a signAndSendTransaction for this wallet on DFM’s Privy app, expiring within 10 min');
|
|
38
|
+
const payload = canonicalJson({ version: 1, method: 'POST', url, body: r.body, headers: r.headers });
|
|
39
|
+
if (payload !== r.payload)
|
|
40
|
+
throw new Error('refusing to sign: the server payload differs from the request it describes');
|
|
41
|
+
return payload;
|
|
42
|
+
}
|
|
43
|
+
/** The owner key, ONLY if it already exists on this machine (never created here). */
|
|
44
|
+
export function existingOwnerKey(path = ownerKeyPath()) {
|
|
45
|
+
return lstatOrNull(path) ? loadOrCreateOwnerKey(path) : null;
|
|
46
|
+
}
|
|
47
|
+
export const DEFAULT_SWAP_SLIPPAGE_BPS = 50;
|
|
48
|
+
/**
|
|
49
|
+
* The MCP's OWN Jupiter quote (review S20), straight from Jupiter — never via
|
|
50
|
+
* DFM — so a compromised server cannot make the owner sign a route with a
|
|
51
|
+
* near-zero minimum output. No key needed; `DFM_JUPITER_QUOTE_URL` overrides.
|
|
52
|
+
* Any failure throws: no independent quote, no signature.
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchOwnQuote(q, doFetch = fetch) {
|
|
55
|
+
const base = process.env.DFM_JUPITER_QUOTE_URL?.trim() || 'https://lite-api.jup.ag/swap/v1/quote';
|
|
56
|
+
const url = `${base}?inputMint=${encodeURIComponent(q.inputMint)}&outputMint=${encodeURIComponent(q.outputMint)}` +
|
|
57
|
+
`&amount=${encodeURIComponent(q.amountRaw)}&slippageBps=${q.slippageBps}` +
|
|
58
|
+
// Same platform fee as the route, so the two outputs are comparable (S24 alignment with the web).
|
|
59
|
+
(q.platformFeeBps ? `&platformFeeBps=${q.platformFeeBps}` : '');
|
|
60
|
+
let out;
|
|
61
|
+
try {
|
|
62
|
+
const res = await doFetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
63
|
+
if (!res.ok)
|
|
64
|
+
throw new Error(`status ${res.status}`);
|
|
65
|
+
out = (await res.json()).outAmount;
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
throw new Error(`refusing to sign: no independent Jupiter quote (${e.message})`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof out !== 'string' || !/^\d+$/.test(out) || BigInt(out) <= 0n)
|
|
71
|
+
throw new Error('refusing to sign: the independent Jupiter quote has no outAmount');
|
|
72
|
+
return BigInt(out);
|
|
73
|
+
}
|
|
74
|
+
export async function runOwnerAction(deps, input) {
|
|
75
|
+
const prepared = await deps.prepare(input);
|
|
76
|
+
if (prepared.status !== 'awaiting_owner_signature')
|
|
77
|
+
return prepared; // executed by DFM's signer / nothing to do
|
|
78
|
+
const r = prepared;
|
|
79
|
+
const payload = checkOwnerRpcRequest(r);
|
|
80
|
+
const app = await deps.appWallet();
|
|
81
|
+
if (!app || app !== r.appWallet)
|
|
82
|
+
throw new Error('refusing to sign: the request is for a different app wallet than this owner’s');
|
|
83
|
+
const { payer, ixs } = await decodeTx(r.body.params.transaction, deps.resolveAlt);
|
|
84
|
+
if (input.kind === 'swap') {
|
|
85
|
+
const slippageBps = input.slippageBps ?? DEFAULT_SWAP_SLIPPAGE_BPS;
|
|
86
|
+
const q = { inputMint: input.inputMint, outputMint: input.outputMint, amountRaw: input.amountRaw, slippageBps };
|
|
87
|
+
// The route's own platform fee (bounded ≤ 20 bps by the verifier) prices the independent quote.
|
|
88
|
+
const jup = ixs.find((x) => x.programId.toBase58() === JUPITER_V6);
|
|
89
|
+
const platformFeeBps = jup && jup.data.length >= 27 ? jup.data[jup.data.length - 1] : 0;
|
|
90
|
+
if (platformFeeBps > MAX_PLATFORM_FEE_BPS)
|
|
91
|
+
throw new Error(`refusing to sign: a platform fee of ${platformFeeBps} bps`);
|
|
92
|
+
const ownOutAmount = await (deps.ownQuote ?? fetchOwnQuote)({ ...q, platformFeeBps });
|
|
93
|
+
verifySwapIxs(payer, ixs, { appWallet: app, ...q, ownOutAmount });
|
|
94
|
+
}
|
|
95
|
+
else if (input.kind === 'withdraw_asset') {
|
|
96
|
+
verifyWithdrawIxs(payer, ixs, { appWallet: app, signInWallet: deps.signInWallet, mint: input.mint, ...(input.amount ? { amount: input.amount } : {}) });
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
verifyCloseIxs(payer, ixs, { appWallet: app, destination: input.destination === 'app_wallet' ? app : deps.signInWallet });
|
|
100
|
+
}
|
|
101
|
+
const res = await deps.submit(r.requestId, deps.key.sign(payload));
|
|
102
|
+
return {
|
|
103
|
+
status: 'complete',
|
|
104
|
+
kind: input.kind,
|
|
105
|
+
signature: res.signature,
|
|
106
|
+
signedBy: 'agent owner key (decoded and verified locally before signing)',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=owner-actions.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { AddressLookupTableAccount, PublicKey, TransactionInstruction } from '@solana/web3.js';
|
|
2
|
+
export declare const PROTOCOL_WALLET = "9GjEBsZ1Cos2J6inmgwhBKv9pXGNtLSJ7xQ9xZb29bET";
|
|
3
|
+
export declare const MAX_SWAP_SOL_FEE_LAMPORTS = 50000000n;
|
|
4
|
+
export declare const MAX_SWAP_SLIPPAGE_BPS = 1000;
|
|
5
|
+
/** DFM's SWAP_PLATFORM_FEE_BPS — the only token platform fee a route may carry. */
|
|
6
|
+
export declare const MAX_PLATFORM_FEE_BPS = 20;
|
|
7
|
+
/** Price drift tolerated between the independent quote and the route's own quote. */
|
|
8
|
+
export declare const QUOTE_DRIFT_BPS = 100n;
|
|
9
|
+
export declare const MAX_COMPUTE_UNITS = 1400000n;
|
|
10
|
+
export declare const MAX_PRIORITY_FEE_LAMPORTS = 1000000n;
|
|
11
|
+
export declare const JUPITER_V6 = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4";
|
|
12
|
+
export declare const WSOL = "So11111111111111111111111111111111111111112";
|
|
13
|
+
export declare const JUP_ROUTE: string;
|
|
14
|
+
export declare const JUP_SHARED_ROUTE: string;
|
|
15
|
+
/** ATA under either token program (the verifier never trusts a server-claimed program). */
|
|
16
|
+
export declare function ataOf(owner: string, mint: string, program?: string): string;
|
|
17
|
+
export type AltResolver = (key: PublicKey) => Promise<AddressLookupTableAccount | null>;
|
|
18
|
+
/**
|
|
19
|
+
* Which account positions of each decoded instruction are STATIC keys (review
|
|
20
|
+
* S24). A lookup table's contents are served by an RPC the agent does not
|
|
21
|
+
* control, so an account the verifier CHECKS must not come from one — `key()`
|
|
22
|
+
* refuses a non-static (or unknown) position. Exported so a test that copies
|
|
23
|
+
* an instruction can carry the mask along.
|
|
24
|
+
*/
|
|
25
|
+
export declare const STATIC_MASK: WeakMap<TransactionInstruction, boolean[]>;
|
|
26
|
+
export declare function decodeTx(txB64: string, resolveAlt: AltResolver): Promise<{
|
|
27
|
+
payer: string;
|
|
28
|
+
ixs: TransactionInstruction[];
|
|
29
|
+
}>;
|
|
30
|
+
export interface SwapExpect {
|
|
31
|
+
appWallet: string;
|
|
32
|
+
inputMint: string;
|
|
33
|
+
outputMint: string;
|
|
34
|
+
amountRaw: string;
|
|
35
|
+
/** Slippage the TOOL asked for (default 50). The route may not exceed it. */
|
|
36
|
+
slippageBps: number;
|
|
37
|
+
/** `outAmount` of the MCP's own Jupiter quote for the same input. */
|
|
38
|
+
ownOutAmount: bigint;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* ComputeBudget (review S21): at most one SetComputeUnitLimit (≤ 1.4M), at most
|
|
42
|
+
* one SetComputeUnitPrice, total priority fee ≤ 0.001 SOL, RequestHeapFrame
|
|
43
|
+
* allowed; any other opcode refused. Without a limit ix the worst case (1.4M)
|
|
44
|
+
* prices the fee.
|
|
45
|
+
*/
|
|
46
|
+
export declare function checkComputeBudget(ixs: Array<{
|
|
47
|
+
programId: PublicKey;
|
|
48
|
+
data: Uint8Array;
|
|
49
|
+
}>): void;
|
|
50
|
+
/**
|
|
51
|
+
* The route's economics (review S20/S22): amount, slippage ≤ requested,
|
|
52
|
+
* platform fee ≤ 20 bps, and min-out (quoted × (1 − slippage)) ≥ the
|
|
53
|
+
* independent quote × (1 − requested slippage) × (1 − 0.5%). Returns the
|
|
54
|
+
* route's platform fee bps.
|
|
55
|
+
*/
|
|
56
|
+
export declare function checkJupiterEconomics(data: Uint8Array, e: Pick<SwapExpect, 'amountRaw' | 'slippageBps' | 'ownOutAmount'>): number;
|
|
57
|
+
export declare function verifySwapIxs(payer: string, ixs: TransactionInstruction[], e: SwapExpect): void;
|
|
58
|
+
export interface WithdrawExpect {
|
|
59
|
+
appWallet: string;
|
|
60
|
+
signInWallet: string;
|
|
61
|
+
mint: string;
|
|
62
|
+
amount?: string;
|
|
63
|
+
}
|
|
64
|
+
export declare function verifyWithdrawIxs(payer: string, ixs: TransactionInstruction[], e: WithdrawExpect): void;
|
|
65
|
+
export interface CloseExpect {
|
|
66
|
+
appWallet: string;
|
|
67
|
+
destination: string;
|
|
68
|
+
}
|
|
69
|
+
export declare function verifyCloseIxs(payer: string, ixs: TransactionInstruction[], e: CloseExpect): void;
|
|
70
|
+
//# sourceMappingURL=owner-tx-verify.d.ts.map
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INDEPENDENT decoding of an owner-signed transaction (agent-owned app
|
|
3
|
+
* wallets). The DFM server builds the tx and hands the agent a Privy RPC
|
|
4
|
+
* request to sign with its P-256 owner key; the owner key is ALL the authority
|
|
5
|
+
* there is (no policy binds the owner), so this file — not the server — decides
|
|
6
|
+
* whether it is safe to sign. It decodes `body.params.transaction` itself and
|
|
7
|
+
* accepts ONLY the shapes below; anything else is refused.
|
|
8
|
+
*
|
|
9
|
+
* swap ComputeBudget · ATA create(-idempotent) owned by the app wallet ·
|
|
10
|
+
* wSOL wrap/unwrap on the app wallet's own wSOL ATA · exactly ONE
|
|
11
|
+
* Jupiter v6 `route` / `shared_accounts_route` whose authority is
|
|
12
|
+
* the app wallet, source = its input-mint ATA, destination = its
|
|
13
|
+
* output-mint ATA, input amount = the requested amount, slippage ≤
|
|
14
|
+
* the requested slippage, platform fee ≤ 20 bps to none or DFM's
|
|
15
|
+
* (9GjE) ATA of either mint, and a quoted output that — after its
|
|
16
|
+
* own slippage — is at least what an INDEPENDENT Jupiter quote
|
|
17
|
+
* (fetched by the MCP, never via DFM) gives after the requested
|
|
18
|
+
* slippage, minus 0.5% drift · at most one SOL transfer to 9GjE ≤
|
|
19
|
+
* 0.05 SOL, and only when the route carries no token fee ·
|
|
20
|
+
* ComputeBudget: ≤ 1 unit limit (≤ 1.4M), ≤ 1 unit price with a
|
|
21
|
+
* total priority fee ≤ 0.001 SOL, heap frame; nothing else.
|
|
22
|
+
* withdraw ATA create-idempotent for the SIGN-IN wallet · ONE transferChecked
|
|
23
|
+
* of exactly that mint (and amount, when one was asked) from the app
|
|
24
|
+
* wallet's ATA to the sign-in wallet's ATA · optional close of the
|
|
25
|
+
* drained source ATA back to the app wallet.
|
|
26
|
+
* close only CloseAccount, authority = the app wallet, rent to the chosen
|
|
27
|
+
* destination (app wallet or sign-in wallet).
|
|
28
|
+
*
|
|
29
|
+
* UNVERIFIED limits (fail closed): Jupiter `route_v2` / token-ledger variants
|
|
30
|
+
* and exact-out routes are refused, not decoded.
|
|
31
|
+
*/
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
import { PublicKey, TransactionMessage, VersionedTransaction } from '@solana/web3.js';
|
|
34
|
+
export const PROTOCOL_WALLET = '9GjEBsZ1Cos2J6inmgwhBKv9pXGNtLSJ7xQ9xZb29bET';
|
|
35
|
+
export const MAX_SWAP_SOL_FEE_LAMPORTS = 50000000n;
|
|
36
|
+
export const MAX_SWAP_SLIPPAGE_BPS = 1000;
|
|
37
|
+
/** DFM's SWAP_PLATFORM_FEE_BPS — the only token platform fee a route may carry. */
|
|
38
|
+
export const MAX_PLATFORM_FEE_BPS = 20;
|
|
39
|
+
/** Price drift tolerated between the independent quote and the route's own quote. */
|
|
40
|
+
export const QUOTE_DRIFT_BPS = 100n;
|
|
41
|
+
export const MAX_COMPUTE_UNITS = 1400000n;
|
|
42
|
+
export const MAX_PRIORITY_FEE_LAMPORTS = 1000000n;
|
|
43
|
+
const SYSTEM = '11111111111111111111111111111111';
|
|
44
|
+
const COMPUTE = 'ComputeBudget111111111111111111111111111111';
|
|
45
|
+
const TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
|
|
46
|
+
const TOKEN22 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb';
|
|
47
|
+
const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
|
|
48
|
+
export const JUPITER_V6 = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4';
|
|
49
|
+
export const WSOL = 'So11111111111111111111111111111111111111112';
|
|
50
|
+
const disc = (n) => createHash('sha256').update(`global:${n}`).digest().subarray(0, 8).toString('hex');
|
|
51
|
+
export const JUP_ROUTE = disc('route');
|
|
52
|
+
export const JUP_SHARED_ROUTE = disc('shared_accounts_route');
|
|
53
|
+
/** ATA under either token program (the verifier never trusts a server-claimed program). */
|
|
54
|
+
export function ataOf(owner, mint, program = TOKEN) {
|
|
55
|
+
return PublicKey.findProgramAddressSync([new PublicKey(owner).toBuffer(), new PublicKey(program).toBuffer(), new PublicKey(mint).toBuffer()], new PublicKey(ATA_PROGRAM))[0].toBase58();
|
|
56
|
+
}
|
|
57
|
+
const atasOf = (owner, mint) => [ataOf(owner, mint, TOKEN), ataOf(owner, mint, TOKEN22)];
|
|
58
|
+
/**
|
|
59
|
+
* Which account positions of each decoded instruction are STATIC keys (review
|
|
60
|
+
* S24). A lookup table's contents are served by an RPC the agent does not
|
|
61
|
+
* control, so an account the verifier CHECKS must not come from one — `key()`
|
|
62
|
+
* refuses a non-static (or unknown) position. Exported so a test that copies
|
|
63
|
+
* an instruction can carry the mask along.
|
|
64
|
+
*/
|
|
65
|
+
export const STATIC_MASK = new WeakMap();
|
|
66
|
+
export async function decodeTx(txB64, resolveAlt) {
|
|
67
|
+
const tx = VersionedTransaction.deserialize(Buffer.from(txB64, 'base64'));
|
|
68
|
+
if (tx.message.header.numRequiredSignatures !== 1) {
|
|
69
|
+
throw new Error('refusing to sign: the transaction needs a signer other than the app wallet');
|
|
70
|
+
}
|
|
71
|
+
const nStatic = tx.message.staticAccountKeys.length;
|
|
72
|
+
const alts = [];
|
|
73
|
+
for (const lu of tx.message.addressTableLookups ?? []) {
|
|
74
|
+
const alt = await resolveAlt(lu.accountKey);
|
|
75
|
+
if (!alt)
|
|
76
|
+
throw new Error(`refusing to sign: lookup table ${lu.accountKey.toBase58()} could not be resolved`);
|
|
77
|
+
alts.push(alt);
|
|
78
|
+
}
|
|
79
|
+
const msg = TransactionMessage.decompile(tx.message, { addressLookupTableAccounts: alts });
|
|
80
|
+
tx.message.compiledInstructions.forEach((c, i) => {
|
|
81
|
+
if (c.programIdIndex >= nStatic)
|
|
82
|
+
throw new Error('refusing to sign: a program loaded from a lookup table');
|
|
83
|
+
STATIC_MASK.set(msg.instructions[i], c.accountKeyIndexes.map((idx) => idx < nStatic));
|
|
84
|
+
});
|
|
85
|
+
return { payer: msg.payerKey.toBase58(), ixs: msg.instructions };
|
|
86
|
+
}
|
|
87
|
+
const refuse = (why) => {
|
|
88
|
+
throw new Error(`refusing to sign: ${why}`);
|
|
89
|
+
};
|
|
90
|
+
/** A CHECKED account: must exist and be a static key of the message (S24). */
|
|
91
|
+
const key = (ix, i) => {
|
|
92
|
+
const mask = STATIC_MASK.get(ix);
|
|
93
|
+
if (!mask || mask[i] !== true || !ix.keys[i])
|
|
94
|
+
refuse(`a checked account (#${i} of ${ix.programId.toBase58()}) is not a static key of the transaction`);
|
|
95
|
+
return ix.keys[i].pubkey.toBase58();
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* A MINT position may come from a lookup table (real Jupiter shared routes put
|
|
99
|
+
* the source mint there). Safe: every token account it pairs with is a STATIC
|
|
100
|
+
* key compared to an ATA derived HERE from the requested mint, and the token
|
|
101
|
+
* programs refuse an account whose mint differs — a lying ALT cannot make the
|
|
102
|
+
* swap move another mint out of the wallet. The value is still compared.
|
|
103
|
+
*/
|
|
104
|
+
const mintKey = (ix, i) => {
|
|
105
|
+
if (!STATIC_MASK.get(ix) || !ix.keys[i])
|
|
106
|
+
refuse(`a mint account (#${i}) of an instruction this decoder did not produce`);
|
|
107
|
+
return ix.keys[i].pubkey.toBase58();
|
|
108
|
+
};
|
|
109
|
+
const isToken = (p) => p === TOKEN || p === TOKEN22;
|
|
110
|
+
/** ATA program create / create-idempotent, paid by the app wallet, for `owner`. */
|
|
111
|
+
function checkAta(ix, app, owners) {
|
|
112
|
+
const d = ix.data;
|
|
113
|
+
if (!(d.length === 0 || (d.length === 1 && (d[0] === 0 || d[0] === 1))))
|
|
114
|
+
refuse('unexpected associated-token instruction');
|
|
115
|
+
if (key(ix, 0) !== app)
|
|
116
|
+
refuse('an account creation not paid by the app wallet');
|
|
117
|
+
if (!owners.includes(key(ix, 2)))
|
|
118
|
+
refuse('creates a token account for a foreign owner');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* ComputeBudget (review S21): at most one SetComputeUnitLimit (≤ 1.4M), at most
|
|
122
|
+
* one SetComputeUnitPrice, total priority fee ≤ 0.001 SOL, RequestHeapFrame
|
|
123
|
+
* allowed; any other opcode refused. Without a limit ix the worst case (1.4M)
|
|
124
|
+
* prices the fee.
|
|
125
|
+
*/
|
|
126
|
+
export function checkComputeBudget(ixs) {
|
|
127
|
+
let limit = null;
|
|
128
|
+
let price = null;
|
|
129
|
+
for (const ix of ixs) {
|
|
130
|
+
if (ix.programId.toBase58() !== COMPUTE)
|
|
131
|
+
continue;
|
|
132
|
+
const d = Buffer.from(ix.data);
|
|
133
|
+
if (d[0] === 1 && d.length === 5)
|
|
134
|
+
continue; // RequestHeapFrame
|
|
135
|
+
if (d[0] === 2 && d.length === 5) {
|
|
136
|
+
if (limit !== null)
|
|
137
|
+
refuse('two compute unit limits');
|
|
138
|
+
limit = BigInt(d.readUInt32LE(1));
|
|
139
|
+
if (limit > MAX_COMPUTE_UNITS)
|
|
140
|
+
refuse('a compute unit limit above 1.4M');
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (d[0] === 3 && d.length === 9) {
|
|
144
|
+
if (price !== null)
|
|
145
|
+
refuse('two compute unit prices');
|
|
146
|
+
price = d.readBigUInt64LE(1);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
refuse(`an unexpected ComputeBudget instruction (${d[0]})`);
|
|
150
|
+
}
|
|
151
|
+
const fee = ((limit ?? MAX_COMPUTE_UNITS) * (price ?? 0n)) / 1000000n;
|
|
152
|
+
if (fee > MAX_PRIORITY_FEE_LAMPORTS)
|
|
153
|
+
refuse(`a priority fee of ${fee} lamports (max 0.001 SOL)`);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The route's economics (review S20/S22): amount, slippage ≤ requested,
|
|
157
|
+
* platform fee ≤ 20 bps, and min-out (quoted × (1 − slippage)) ≥ the
|
|
158
|
+
* independent quote × (1 − requested slippage) × (1 − 0.5%). Returns the
|
|
159
|
+
* route's platform fee bps.
|
|
160
|
+
*/
|
|
161
|
+
export function checkJupiterEconomics(data, e) {
|
|
162
|
+
const d = Buffer.from(data);
|
|
163
|
+
const tag = d.subarray(0, 8).toString('hex');
|
|
164
|
+
if ((tag !== JUP_ROUTE && tag !== JUP_SHARED_ROUTE) || d.length < 27)
|
|
165
|
+
refuse('an unsupported Jupiter instruction');
|
|
166
|
+
const t = d.length - 19;
|
|
167
|
+
if (d.readBigUInt64LE(t).toString() !== e.amountRaw)
|
|
168
|
+
refuse('the swap input amount differs from the request');
|
|
169
|
+
const quotedOut = d.readBigUInt64LE(t + 8);
|
|
170
|
+
const slip = d.readUInt16LE(t + 16);
|
|
171
|
+
const feeBps = d[t + 18];
|
|
172
|
+
if (slip > Math.min(e.slippageBps, MAX_SWAP_SLIPPAGE_BPS))
|
|
173
|
+
refuse(`route slippage ${slip} bps above the requested ${e.slippageBps}`);
|
|
174
|
+
if (feeBps > MAX_PLATFORM_FEE_BPS)
|
|
175
|
+
refuse(`a platform fee of ${feeBps} bps`);
|
|
176
|
+
const routeMin = (quotedOut * BigInt(10_000 - slip)) / 10000n;
|
|
177
|
+
const floor = (e.ownOutAmount * BigInt(10_000 - e.slippageBps) * (10000n - QUOTE_DRIFT_BPS)) / 100000000n;
|
|
178
|
+
if (e.ownOutAmount <= 0n || routeMin < floor) {
|
|
179
|
+
refuse(`the route's minimum output ${routeMin} is below the independent quote floor ${floor}`);
|
|
180
|
+
}
|
|
181
|
+
return feeBps;
|
|
182
|
+
}
|
|
183
|
+
export function verifySwapIxs(payer, ixs, e) {
|
|
184
|
+
const app = e.appWallet;
|
|
185
|
+
if (payer !== app)
|
|
186
|
+
refuse('fee payer is not the app wallet');
|
|
187
|
+
const wsolAtas = atasOf(app, WSOL);
|
|
188
|
+
const feeAtas = [...atasOf(PROTOCOL_WALLET, e.inputMint), ...atasOf(PROTOCOL_WALLET, e.outputMint)];
|
|
189
|
+
checkComputeBudget(ixs);
|
|
190
|
+
let routes = 0;
|
|
191
|
+
let solFees = 0;
|
|
192
|
+
let tokenFeeBps = 0;
|
|
193
|
+
for (const ix of ixs) {
|
|
194
|
+
const p = ix.programId.toBase58();
|
|
195
|
+
const d = Buffer.from(ix.data);
|
|
196
|
+
if (p === COMPUTE)
|
|
197
|
+
continue;
|
|
198
|
+
if (p === ATA_PROGRAM) {
|
|
199
|
+
checkAta(ix, app, [app]);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (p === SYSTEM) {
|
|
203
|
+
if (d.length !== 12 || d.readUInt32LE(0) !== 2)
|
|
204
|
+
refuse('a system instruction other than a transfer');
|
|
205
|
+
if (key(ix, 0) !== app)
|
|
206
|
+
refuse('a SOL transfer not from the app wallet');
|
|
207
|
+
const to = key(ix, 1);
|
|
208
|
+
const lamports = d.readBigUInt64LE(4);
|
|
209
|
+
if (wsolAtas.includes(to)) {
|
|
210
|
+
// Wrap into its own wSOL account: only a SOL-input swap, and exactly the swapped amount.
|
|
211
|
+
if (e.inputMint !== WSOL || lamports.toString() !== e.amountRaw)
|
|
212
|
+
refuse('a wSOL wrap that is not exactly the swap input');
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (to === PROTOCOL_WALLET && lamports <= MAX_SWAP_SOL_FEE_LAMPORTS && ++solFees === 1)
|
|
216
|
+
continue;
|
|
217
|
+
refuse(`a SOL transfer to ${to}`);
|
|
218
|
+
}
|
|
219
|
+
if (isToken(p)) {
|
|
220
|
+
if (d[0] === 17 && wsolAtas.includes(key(ix, 0)))
|
|
221
|
+
continue; // syncNative own wSOL
|
|
222
|
+
if (d[0] === 9 && wsolAtas.includes(key(ix, 0)) && key(ix, 1) === app && key(ix, 2) === app)
|
|
223
|
+
continue; // unwrap
|
|
224
|
+
refuse(`a top-level token instruction (${d[0]})`);
|
|
225
|
+
}
|
|
226
|
+
if (p === JUPITER_V6) {
|
|
227
|
+
routes += 1;
|
|
228
|
+
tokenFeeBps = checkJupiterEconomics(d, e);
|
|
229
|
+
checkJupiter(ix, d, e, feeAtas);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
refuse(`unknown program ${p}`);
|
|
233
|
+
}
|
|
234
|
+
if (routes !== 1)
|
|
235
|
+
refuse(`expected exactly one Jupiter route, found ${routes}`);
|
|
236
|
+
// The server takes EITHER the token platform fee or the SOL fee, never both (S22).
|
|
237
|
+
if (solFees > 0 && tokenFeeBps > 0)
|
|
238
|
+
refuse('a SOL fee on top of a token platform fee');
|
|
239
|
+
}
|
|
240
|
+
function checkJupiter(ix, d, e, feeAtas) {
|
|
241
|
+
const app = e.appWallet;
|
|
242
|
+
const tag = d.subarray(0, 8).toString('hex');
|
|
243
|
+
if ((tag !== JUP_ROUTE && tag !== JUP_SHARED_ROUTE) || d.length < 27)
|
|
244
|
+
refuse('an unsupported Jupiter instruction');
|
|
245
|
+
const ins = atasOf(app, e.inputMint);
|
|
246
|
+
const outs = atasOf(app, e.outputMint);
|
|
247
|
+
const feeOk = (f) => f === JUPITER_V6 || (f !== undefined && feeAtas.includes(f));
|
|
248
|
+
if (tag === JUP_ROUTE) {
|
|
249
|
+
if (key(ix, 1) !== app)
|
|
250
|
+
refuse('the swap authority is not the app wallet');
|
|
251
|
+
if (!ins.includes(key(ix, 2)))
|
|
252
|
+
refuse('the swap source is not the app wallet input account');
|
|
253
|
+
if (!outs.includes(key(ix, 3)))
|
|
254
|
+
refuse('the swap output does not land in the app wallet');
|
|
255
|
+
if (key(ix, 4) !== JUPITER_V6 && key(ix, 4) !== key(ix, 3))
|
|
256
|
+
refuse('the swap output is redirected');
|
|
257
|
+
if (mintKey(ix, 5) !== e.outputMint)
|
|
258
|
+
refuse('the output mint differs from the request');
|
|
259
|
+
if (!feeOk(key(ix, 6)))
|
|
260
|
+
refuse('a foreign platform fee account');
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
if (key(ix, 2) !== app)
|
|
264
|
+
refuse('the swap authority is not the app wallet');
|
|
265
|
+
if (!ins.includes(key(ix, 3)))
|
|
266
|
+
refuse('the swap source is not the app wallet input account');
|
|
267
|
+
if (!outs.includes(key(ix, 6)))
|
|
268
|
+
refuse('the swap output does not land in the app wallet');
|
|
269
|
+
if (mintKey(ix, 7) !== e.inputMint)
|
|
270
|
+
refuse('the input mint differs from the request');
|
|
271
|
+
if (mintKey(ix, 8) !== e.outputMint)
|
|
272
|
+
refuse('the output mint differs from the request');
|
|
273
|
+
if (!feeOk(key(ix, 9)))
|
|
274
|
+
refuse('a foreign platform fee account');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export function verifyWithdrawIxs(payer, ixs, e) {
|
|
278
|
+
if (payer !== e.appWallet)
|
|
279
|
+
refuse('fee payer is not the app wallet');
|
|
280
|
+
const src = atasOf(e.appWallet, e.mint);
|
|
281
|
+
const dst = atasOf(e.signInWallet, e.mint);
|
|
282
|
+
let transfers = 0;
|
|
283
|
+
let closes = 0;
|
|
284
|
+
for (const ix of ixs) {
|
|
285
|
+
const p = ix.programId.toBase58();
|
|
286
|
+
const d = Buffer.from(ix.data);
|
|
287
|
+
if (p === COMPUTE)
|
|
288
|
+
continue;
|
|
289
|
+
if (p === ATA_PROGRAM) {
|
|
290
|
+
checkAta(ix, e.appWallet, [e.signInWallet]);
|
|
291
|
+
if (mintKey(ix, 3) !== e.mint)
|
|
292
|
+
refuse('creates an account for another mint');
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (isToken(p) && d[0] === 12) {
|
|
296
|
+
transfers += 1;
|
|
297
|
+
if (!src.includes(key(ix, 0)))
|
|
298
|
+
refuse('the withdrawal source is not the app wallet');
|
|
299
|
+
if (mintKey(ix, 1) !== e.mint)
|
|
300
|
+
refuse('the withdrawal mint differs from the request');
|
|
301
|
+
if (!dst.includes(key(ix, 2)))
|
|
302
|
+
refuse('the withdrawal does not go to the sign-in wallet');
|
|
303
|
+
if (key(ix, 3) !== e.appWallet)
|
|
304
|
+
refuse('the withdrawal authority is not the app wallet');
|
|
305
|
+
const amount = d.readBigUInt64LE(1).toString();
|
|
306
|
+
if (e.amount !== undefined && amount !== e.amount)
|
|
307
|
+
refuse('the withdrawal amount differs from the request');
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (isToken(p) && d[0] === 9) {
|
|
311
|
+
closes += 1;
|
|
312
|
+
if (!src.includes(key(ix, 0)) || key(ix, 1) !== e.appWallet || key(ix, 2) !== e.appWallet)
|
|
313
|
+
refuse('an unexpected account close');
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
refuse(`unexpected instruction (${p})`);
|
|
317
|
+
}
|
|
318
|
+
if (transfers > 1 || closes > 1 || transfers + closes === 0)
|
|
319
|
+
refuse('not exactly one withdrawal');
|
|
320
|
+
}
|
|
321
|
+
export function verifyCloseIxs(payer, ixs, e) {
|
|
322
|
+
if (payer !== e.appWallet)
|
|
323
|
+
refuse('fee payer is not the app wallet');
|
|
324
|
+
let closes = 0;
|
|
325
|
+
for (const ix of ixs) {
|
|
326
|
+
const p = ix.programId.toBase58();
|
|
327
|
+
if (p === COMPUTE)
|
|
328
|
+
continue;
|
|
329
|
+
const d = Buffer.from(ix.data);
|
|
330
|
+
if (!isToken(p) || d[0] !== 9 || d.length !== 1)
|
|
331
|
+
refuse(`not a CloseAccount (${p})`);
|
|
332
|
+
if (key(ix, 1) !== e.destination)
|
|
333
|
+
refuse('rent goes to an unexpected destination');
|
|
334
|
+
if (key(ix, 2) !== e.appWallet)
|
|
335
|
+
refuse('closes an account the app wallet does not own');
|
|
336
|
+
closes += 1;
|
|
337
|
+
}
|
|
338
|
+
if (closes === 0)
|
|
339
|
+
refuse('no accounts to close');
|
|
340
|
+
}
|
|
341
|
+
//# sourceMappingURL=owner-tx-verify.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfm-fi/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1941",
|
|
4
4
|
"description": "DFM v2 MCP server — drive the full fund launch → deposit → redeem (+ manage) lifecycle from an AI agent against the live API.",
|
|
5
5
|
"homepage": "https://app.dfm.finance/docs/agent",
|
|
6
6
|
"mcpName": "io.github.DFM-Finance/dfm-agent",
|
package/skills.md
CHANGED
|
@@ -129,15 +129,16 @@ BE PROACTIVE ABOUT THE NEXT STEP, NOT ABOUT NEW SCOPE. Finish the job the person
|
|
|
129
129
|
|
|
130
130
|
## Tools
|
|
131
131
|
|
|
132
|
-
Kim's in-app registry exposes **
|
|
132
|
+
Kim's in-app registry exposes **45 tools** (25 READ + 20 WRITE). External agents mount the official MCP and MUST treat its runtime `listTools` result as the available subset; the two surfaces intentionally are not assumed to be identical. READ tools are free and touch no funds; WRITE tools move only the caller's own funds or administered vaults.
|
|
133
133
|
|
|
134
|
-
**READ (
|
|
134
|
+
**READ (25)** — safe, no funds, no gate:
|
|
135
135
|
- `whoami` — The user's display name, primary DFM app wallet, separate sign-in/funding wallet, cluster, and write status. Call FIRST in a session; when an app wallet exists, do not present the sign-in wallet as their DFM wallet.
|
|
136
136
|
- `app_wallet` — The user's APP WALLET (operating/sub-wallet): whether they have one, its status, its LIVE onchain SOL + USDC, and what they must do next. Call this before telling a user why an autonomous action was refused — the usual reason is no wallet, not activated, or an empty float, and each has a different fix.
|
|
137
137
|
- `create_app_wallet` — Get the user's APP WALLET created. In the app it is created automatically at sign-in and approved in the browser (no key leaves the user); this returns the current app wallet state and, if there is none yet, exactly how to get it. Headless agents use the MCP tool of the same name, which creates a wallet owned by a key generated on their own machine. Moves no funds.
|
|
138
138
|
- `set_profile` — Explain how to set the user's DFM profile (display name, @username, bio, avatar). In the app it is edited on the Profile page; headless agents use the MCP tool of the same name. A chosen name and an avatar are required before an agent can create its own app wallet. Changes nothing.
|
|
139
139
|
- `create_api_key` — Explain how to get a dfm_ API key for an external agent. Keys are minted by the owner (signed-in session) in Settings > API keys or via the MCP tool of the same name; read-only by default, optional caps and expiry, shown once. Kim never mints a key. Changes nothing.
|
|
140
140
|
- `create_identity_wallet` — Explain the agent's IDENTITY wallet: the local Solana keypair a headless agent signs in with (SIWS). The MCP tool of the same name reuses an existing one or creates it once on the agent machine; in the app the user's connected wallet is the identity. Kim never creates keys. Changes nothing.
|
|
141
|
+
- `close_empty_accounts` — Explain how rent locked in the app wallet's EMPTY token accounts comes back. In the app it is reclaimed from the app wallet card; an agent whose app wallet is owned by its own key runs the MCP tool of the same name (decoded and signed locally). Kim never signs it. Changes nothing.
|
|
141
142
|
- `onboard` — Explain the headless-agent onboarding order over MCP: identity wallet, SIWS login, profile (name + avatar), app wallet, API key (optional). The MCP tool of the same name runs the steps and reports the missing one. Changes nothing.
|
|
142
143
|
- `list_app_wallet_assets` — EVERY token the app wallet holds — fund shares included — with amount, decimals, USD value and whether the mint is one of our fund shares. `app_wallet` answers USDC and SOL only (that is what the float is), so it cannot see a single share. Use this when the user asks what they hold, or before proposing a withdrawal.
|
|
143
144
|
- `list_dtfs` — Browse live funds (optional category filter).
|