@dfm-fi/agent 0.2.1941 → 0.2.1958
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/api-client.d.ts +16 -6
- package/dist/api-client.js +51 -10
- package/dist/client-header.d.ts +11 -0
- package/dist/client-header.js +25 -0
- package/dist/create-flow.d.ts +2 -0
- package/dist/create-flow.js +1 -0
- package/dist/mcp-server.js +36 -44
- package/dist/owner-actions.js +1 -1
- package/dist/signing.js +51 -1
- package/package.json +3 -3
package/dist/api-client.d.ts
CHANGED
|
@@ -163,9 +163,10 @@ export declare class DfmApiClient {
|
|
|
163
163
|
/**
|
|
164
164
|
* `POST /agent/operating/float/withdraw` — sweep the float back to its OWNER.
|
|
165
165
|
*
|
|
166
|
-
* The
|
|
167
|
-
*
|
|
168
|
-
*
|
|
166
|
+
* The agent never names a destination: the API resolves the owner's SAVED
|
|
167
|
+
* withdrawal wallet first and falls back to the authenticated wallet, so
|
|
168
|
+
* this cannot send anywhere else. Works past a stale rebalancer stamp and a
|
|
169
|
+
* user expiry (the sweep carve-outs) — the float must never be trapped.
|
|
169
170
|
*/
|
|
170
171
|
/**
|
|
171
172
|
* `GET /agent/operating/assets` — every token the app wallet holds.
|
|
@@ -187,9 +188,10 @@ export declare class DfmApiClient {
|
|
|
187
188
|
/**
|
|
188
189
|
* `POST /agent/operating/assets/withdraw` — move ONE mint back to the owner.
|
|
189
190
|
*
|
|
190
|
-
* `destination` is deliberately NOT exposed here: the API
|
|
191
|
-
*
|
|
192
|
-
*
|
|
191
|
+
* `destination` is deliberately NOT exposed here: the API resolves the
|
|
192
|
+
* owner's saved withdrawal wallet first, falling back to the authenticated
|
|
193
|
+
* wallet — an agent that could name a destination would be a drain vector
|
|
194
|
+
* for anything that can put words in its input.
|
|
193
195
|
*/
|
|
194
196
|
withdrawAppWalletAsset(mint: string, amount?: number | string): Promise<{
|
|
195
197
|
signature: string;
|
|
@@ -251,6 +253,12 @@ export declare class DfmApiClient {
|
|
|
251
253
|
* and signs it behind both cap layers plus the tx-assert simulation. Used
|
|
252
254
|
* when the MCP has an API key but no keypair; see `keyless.ts` for when that
|
|
253
255
|
* applies and why it never falls back to local signing.
|
|
256
|
+
*
|
|
257
|
+
* The idempotency key is PINNED to the operation itself (tool + canonical
|
|
258
|
+
* args, hashed) rather than a fresh UUID: when the response is lost — a 504
|
|
259
|
+
* the server outlived, a dropped socket — the agent's retry carries the SAME
|
|
260
|
+
* key, and the server returns the already-running/finished job instead of
|
|
261
|
+
* executing a second time (the two-vaults-two-fees bug, A7).
|
|
254
262
|
*/
|
|
255
263
|
executeViaOperatingWallet(tool: string, args: Record<string, unknown>): Promise<{
|
|
256
264
|
signature?: string;
|
|
@@ -323,6 +331,8 @@ export declare class DfmApiClient {
|
|
|
323
331
|
assets: CreateAssetInput[];
|
|
324
332
|
description?: string;
|
|
325
333
|
tags?: number[];
|
|
334
|
+
/** Let DFM announce this fund publicly on X/Telegram/Moltbook (default false). */
|
|
335
|
+
announceOptIn?: boolean;
|
|
326
336
|
}): Promise<CreatePrepareResponse>;
|
|
327
337
|
/**
|
|
328
338
|
* `POST /vaults/create/confirm` — record the vault doc after the create tx
|
package/dist/api-client.js
CHANGED
|
@@ -13,13 +13,42 @@
|
|
|
13
13
|
* NEVER sees the secret key — signing is delegated to `signing.ts` via the
|
|
14
14
|
* write methods, and auth is delegated to `SiwsSession`.
|
|
15
15
|
*/
|
|
16
|
-
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
17
|
+
import { dfmClientHeaders } from './client-header.js';
|
|
17
18
|
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
18
19
|
function requireBase58(addr, label) {
|
|
19
20
|
if (!BASE58_RE.test(addr)) {
|
|
20
21
|
throw new Error(`Invalid base58 ${label}: ${addr}`);
|
|
21
22
|
}
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Canonical JSON for an operation id: object keys sorted recursively, so two
|
|
26
|
+
* calls that MEAN the same write hash identically regardless of key order.
|
|
27
|
+
* Money fields travel as strings server-side, so no number-precision games.
|
|
28
|
+
*/
|
|
29
|
+
function stableStringify(value) {
|
|
30
|
+
if (value === null || typeof value !== 'object')
|
|
31
|
+
return JSON.stringify(value) ?? 'null';
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
34
|
+
const entries = Object.keys(value)
|
|
35
|
+
.sort()
|
|
36
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`);
|
|
37
|
+
return `{${entries.join(',')}}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The deterministic operation id for ONE keyless write (A7): same tool + same
|
|
41
|
+
* args → same id, so a retry after a LOST response names the first attempt
|
|
42
|
+
* instead of executing twice. This is what "the same logical operation" means
|
|
43
|
+
* here — a genuinely NEW write with byte-identical args is deliberately
|
|
44
|
+
* treated as a retry; change an argument to make a new operation.
|
|
45
|
+
*/
|
|
46
|
+
function operatingOperationKey(tool, args) {
|
|
47
|
+
const digest = createHash('sha256').update(`${tool}\n${stableStringify(args)}`).digest('hex');
|
|
48
|
+
return `opexec-${digest}`;
|
|
49
|
+
}
|
|
50
|
+
/** Launch-surface label (`launchedVia: 'mcp'` on the API side) — never a credential. */
|
|
51
|
+
const CLIENT_HEADERS = dfmClientHeaders();
|
|
23
52
|
export class DfmApiClient {
|
|
24
53
|
session;
|
|
25
54
|
baseUrl;
|
|
@@ -31,7 +60,7 @@ export class DfmApiClient {
|
|
|
31
60
|
async publicRequest(path, init) {
|
|
32
61
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
33
62
|
...init,
|
|
34
|
-
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
|
63
|
+
headers: { 'Content-Type': 'application/json', ...CLIENT_HEADERS, ...(init?.headers ?? {}) },
|
|
35
64
|
});
|
|
36
65
|
if (!res.ok) {
|
|
37
66
|
const body = await res.text().catch(() => '');
|
|
@@ -49,6 +78,7 @@ export class DfmApiClient {
|
|
|
49
78
|
const idempotencyKey = mutating ? (pinnedKey ?? randomUUID()) : undefined;
|
|
50
79
|
const common = (extra) => ({
|
|
51
80
|
'Content-Type': 'application/json',
|
|
81
|
+
...CLIENT_HEADERS,
|
|
52
82
|
...extra,
|
|
53
83
|
...(idempotencyKey ? { 'X-Idempotency-Key': idempotencyKey } : {}),
|
|
54
84
|
...(reqInit.headers ?? {}),
|
|
@@ -58,8 +88,10 @@ export class DfmApiClient {
|
|
|
58
88
|
// resolves server-side to its owner wallet plus the credential's caps.
|
|
59
89
|
// That matters because a leaked keypair IS the wallet, whereas a leaked API
|
|
60
90
|
// key is revocable and capped (read-only unless explicitly widened).
|
|
61
|
-
// There is no re-auth loop here — a key
|
|
62
|
-
//
|
|
91
|
+
// There is no re-auth loop here — a key cannot be refreshed from the MCP
|
|
92
|
+
// side. A 401 means the key was revoked OR expired (the server enforces
|
|
93
|
+
// `expiresAt`); silently retrying a dead credential would be wrong either
|
|
94
|
+
// way, so the caller is told to mint a new one.
|
|
63
95
|
const apiKey = sessionOnly ? undefined : process.env.DFM_API_KEY?.trim();
|
|
64
96
|
if (apiKey) {
|
|
65
97
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
@@ -288,9 +320,10 @@ export class DfmApiClient {
|
|
|
288
320
|
/**
|
|
289
321
|
* `POST /agent/operating/float/withdraw` — sweep the float back to its OWNER.
|
|
290
322
|
*
|
|
291
|
-
* The
|
|
292
|
-
*
|
|
293
|
-
*
|
|
323
|
+
* The agent never names a destination: the API resolves the owner's SAVED
|
|
324
|
+
* withdrawal wallet first and falls back to the authenticated wallet, so
|
|
325
|
+
* this cannot send anywhere else. Works past a stale rebalancer stamp and a
|
|
326
|
+
* user expiry (the sweep carve-outs) — the float must never be trapped.
|
|
294
327
|
*/
|
|
295
328
|
/**
|
|
296
329
|
* `GET /agent/operating/assets` — every token the app wallet holds.
|
|
@@ -304,9 +337,10 @@ export class DfmApiClient {
|
|
|
304
337
|
/**
|
|
305
338
|
* `POST /agent/operating/assets/withdraw` — move ONE mint back to the owner.
|
|
306
339
|
*
|
|
307
|
-
* `destination` is deliberately NOT exposed here: the API
|
|
308
|
-
*
|
|
309
|
-
*
|
|
340
|
+
* `destination` is deliberately NOT exposed here: the API resolves the
|
|
341
|
+
* owner's saved withdrawal wallet first, falling back to the authenticated
|
|
342
|
+
* wallet — an agent that could name a destination would be a drain vector
|
|
343
|
+
* for anything that can put words in its input.
|
|
310
344
|
*/
|
|
311
345
|
async withdrawAppWalletAsset(mint, amount) {
|
|
312
346
|
return this.authRequest('/agent/operating/assets/withdraw', {
|
|
@@ -361,11 +395,18 @@ export class DfmApiClient {
|
|
|
361
395
|
* and signs it behind both cap layers plus the tx-assert simulation. Used
|
|
362
396
|
* when the MCP has an API key but no keypair; see `keyless.ts` for when that
|
|
363
397
|
* applies and why it never falls back to local signing.
|
|
398
|
+
*
|
|
399
|
+
* The idempotency key is PINNED to the operation itself (tool + canonical
|
|
400
|
+
* args, hashed) rather than a fresh UUID: when the response is lost — a 504
|
|
401
|
+
* the server outlived, a dropped socket — the agent's retry carries the SAME
|
|
402
|
+
* key, and the server returns the already-running/finished job instead of
|
|
403
|
+
* executing a second time (the two-vaults-two-fees bug, A7).
|
|
364
404
|
*/
|
|
365
405
|
async executeViaOperatingWallet(tool, args) {
|
|
366
406
|
const first = await this.authRequest('/agent/operating/execute', {
|
|
367
407
|
method: 'POST',
|
|
368
408
|
body: JSON.stringify({ tool, args }),
|
|
409
|
+
idempotencyKey: operatingOperationKey(tool, args),
|
|
369
410
|
});
|
|
370
411
|
// Finished inside the server's inline window — the shape this method has
|
|
371
412
|
// always returned.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `x-dfm-client: mcp/<version>` on every DFM API call this package makes.
|
|
3
|
+
*
|
|
4
|
+
* The API stamps it onto a launched fund as `launchedVia: 'mcp'` (marketing:
|
|
5
|
+
* how many funds AI agents launch through the MCP). It is a LABEL only — the
|
|
6
|
+
* server never grants, widens or skips anything because of it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DFM_CLIENT_HEADER = "x-dfm-client";
|
|
9
|
+
export declare function readPackageVersion(): string;
|
|
10
|
+
export declare function dfmClientHeaders(version?: string): Record<string, string>;
|
|
11
|
+
//# sourceMappingURL=client-header.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/**
|
|
5
|
+
* `x-dfm-client: mcp/<version>` on every DFM API call this package makes.
|
|
6
|
+
*
|
|
7
|
+
* The API stamps it onto a launched fund as `launchedVia: 'mcp'` (marketing:
|
|
8
|
+
* how many funds AI agents launch through the MCP). It is a LABEL only — the
|
|
9
|
+
* server never grants, widens or skips anything because of it.
|
|
10
|
+
*/
|
|
11
|
+
export const DFM_CLIENT_HEADER = 'x-dfm-client';
|
|
12
|
+
export function readPackageVersion() {
|
|
13
|
+
try {
|
|
14
|
+
// dist/client-header.js → ../package.json (same layout in the published tarball).
|
|
15
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
16
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? '0.0.0';
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return '0.0.0';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function dfmClientHeaders(version = readPackageVersion()) {
|
|
23
|
+
return { [DFM_CLIENT_HEADER]: `mcp/${version}` };
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=client-header.js.map
|
package/dist/create-flow.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface LaunchVaultParams {
|
|
|
38
38
|
/** Category enum (0-7, 255). Defaults to 255 (uncategorized/other). */
|
|
39
39
|
category?: number;
|
|
40
40
|
description?: string;
|
|
41
|
+
/** Let DFM announce this fund publicly on X/Telegram/Moltbook (default false). */
|
|
42
|
+
announceOptIn?: boolean;
|
|
41
43
|
}
|
|
42
44
|
export interface LaunchVaultResult {
|
|
43
45
|
ok: boolean;
|
package/dist/create-flow.js
CHANGED
|
@@ -93,6 +93,7 @@ export async function runLaunchVault(config, api, params) {
|
|
|
93
93
|
category: params.category ?? 255,
|
|
94
94
|
assets: params.assets,
|
|
95
95
|
description: params.description,
|
|
96
|
+
...(params.announceOptIn === true && { announceOptIn: true }),
|
|
96
97
|
});
|
|
97
98
|
// 2. SIGN locally + 3. SUBMIT.
|
|
98
99
|
const signed = signBase64Transaction(config, prepared.transaction);
|
package/dist/mcp-server.js
CHANGED
|
@@ -671,7 +671,7 @@ server.registerTool('close_empty_accounts', {
|
|
|
671
671
|
'non-empty account is never included. The transaction is decoded and checked locally (only CloseAccount, ' +
|
|
672
672
|
'authority = the app wallet, rent to the chosen destination) before the owner key signs. Moves only rent.',
|
|
673
673
|
inputSchema: {
|
|
674
|
-
destination: z.enum(['sign_in_wallet', 'app_wallet']).optional().describe('where the rent goes; default
|
|
674
|
+
destination: z.enum(['sign_in_wallet', 'app_wallet']).optional().describe('where the rent goes; default app_wallet (then app_wallet_withdraw moves the SOL to your sign-in wallet)'),
|
|
675
675
|
},
|
|
676
676
|
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
677
677
|
}, async ({ destination }) => {
|
|
@@ -889,27 +889,19 @@ async function keylessWrite(tool, args) {
|
|
|
889
889
|
// ─── WRITE TOOL: launch_vault (create DTF) ────────────────────────────────
|
|
890
890
|
server.registerTool('launch_vault', {
|
|
891
891
|
title: 'Launch a fund',
|
|
892
|
-
description: 'REAL-MONEY (gated). Create a new fund.
|
|
893
|
-
'
|
|
894
|
-
'
|
|
895
|
-
'
|
|
896
|
-
'
|
|
897
|
-
'
|
|
898
|
-
'
|
|
899
|
-
'
|
|
900
|
-
'
|
|
901
|
-
'
|
|
902
|
-
'
|
|
903
|
-
'
|
|
904
|
-
'
|
|
905
|
-
'the share mint automatically gets ' +
|
|
906
|
-
'Metaplex token metadata (name/symbol/uri) so explorers + wallets show the ' +
|
|
907
|
-
'Fund instead of "Unknown Token" (#47, best-effort + non-fatal); pass `metadata:false` ' +
|
|
908
|
-
'to skip its unrecoverable rent; the API preflight reports the current SOL requirement ' +
|
|
909
|
-
'and attach it later from the vault page. ' +
|
|
910
|
-
'Names and symbols must be unique among active DFM funds; closed funds release them. ' +
|
|
911
|
-
'Name matching ignores case and repeated whitespace; symbol matching ignores case. ' +
|
|
912
|
-
'Returns the vault address — fund the first deposit ≥ ~$12.',
|
|
892
|
+
description: 'REAL-MONEY (gated). Create a new fund. Your APP WALLET signs, pays and ' +
|
|
893
|
+
'becomes the vault admin; with no active app wallet this tool refuses rather ' +
|
|
894
|
+
'than sign with a local keypair (the creator is fixed onchain forever and is ' +
|
|
895
|
+
'what registers the fund to you). Any wallet can get one — an identity rule, ' +
|
|
896
|
+
'not a permission. Check `app_wallet` if a write is refused for funds. ' +
|
|
897
|
+
'`assets` = [{ mint, symbol, allocationBps }], allocationBps summing to exactly ' +
|
|
898
|
+
'10000, 2–10 assets. Live factory rules decide mint eligibility; freezable ' +
|
|
899
|
+
'mints are accepted but carry issuer-lock risk. Fees are factory-global, read ' +
|
|
900
|
+
'live; the creator cannot set them. The share mint gets Metaplex metadata so ' +
|
|
901
|
+
'wallets show the fund (#47, non-fatal); `metadata:false` skips its ' +
|
|
902
|
+
'unrecoverable rent (attach it later from the vault page). Names and symbols ' +
|
|
903
|
+
'must be unique among active funds (case-insensitive). Returns the vault ' +
|
|
904
|
+
'address — fund the first deposit ≥ ~$12.',
|
|
913
905
|
inputSchema: {
|
|
914
906
|
name: z.string().min(1).max(32),
|
|
915
907
|
symbol: z
|
|
@@ -931,19 +923,27 @@ server.registerTool('launch_vault', {
|
|
|
931
923
|
// never comes back, and the SOL floor drops from ~0.045 to ~0.03 without
|
|
932
924
|
// it. The vault page can attach it later (creator-signed).
|
|
933
925
|
metadata: z.boolean().optional(),
|
|
926
|
+
announceOptIn: z
|
|
927
|
+
.boolean()
|
|
928
|
+
.optional()
|
|
929
|
+
.describe('Let DFM announce this fund publicly on X/Telegram/Moltbook. Default false (no announcement).'),
|
|
934
930
|
confirmPhrase: confirmPhraseField,
|
|
935
931
|
},
|
|
936
932
|
annotations: { title: 'DFM launch vault', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
937
933
|
},
|
|
938
934
|
// REVIEW: real-money path — parent must review before enabling.
|
|
939
|
-
async ({ name, symbol, assets, category, description, metadata, confirmPhrase }) => {
|
|
935
|
+
async ({ name, symbol, assets, category, description, metadata, announceOptIn, confirmPhrase }) => {
|
|
940
936
|
const gate = confirmGate('launch_vault', confirmPhrase);
|
|
941
937
|
if (!gate.ok)
|
|
942
938
|
return gate.result;
|
|
943
939
|
const write = writeGate();
|
|
944
940
|
if (!write.ok)
|
|
945
941
|
return write.result;
|
|
946
|
-
return keylessWrite('launch_vault', {
|
|
942
|
+
return keylessWrite('launch_vault', {
|
|
943
|
+
name, symbol, assets, category, description,
|
|
944
|
+
...(metadata === false && { metadata }),
|
|
945
|
+
...(announceOptIn === true && { announceOptIn }),
|
|
946
|
+
});
|
|
947
947
|
});
|
|
948
948
|
// ─── WRITE TOOL: deposit (zap-in) ─────────────────────────────────────────
|
|
949
949
|
server.registerTool('deposit', {
|
|
@@ -1530,26 +1530,18 @@ server.registerTool('init_rebalancer', {
|
|
|
1530
1530
|
// `/execute/prepare`, just off the agent surface.)
|
|
1531
1531
|
server.registerTool('rebalance_to_targets', {
|
|
1532
1532
|
title: 'Rebalance a vault to its configured target weights',
|
|
1533
|
-
description: '
|
|
1534
|
-
'
|
|
1535
|
-
'
|
|
1536
|
-
'
|
|
1537
|
-
'
|
|
1538
|
-
'
|
|
1539
|
-
'
|
|
1540
|
-
'
|
|
1541
|
-
'
|
|
1542
|
-
'
|
|
1543
|
-
'
|
|
1544
|
-
'
|
|
1545
|
-
'balances and the registered targets (sell-overweight → buy-underweight), ' +
|
|
1546
|
-
'returns them as ordered admin-signed execute_rebalance txs, and this tool ' +
|
|
1547
|
-
'signs + submits them IN ORDER (honoring the 5s onchain cooldown). To CHANGE ' +
|
|
1548
|
-
'the targets first, use update_vault_assets (reconfigure). Onchain guards are ' +
|
|
1549
|
-
'ORACLE-FREE: a measured min_out floor + hardcoded velocity ceilings + an ' +
|
|
1550
|
-
'output-mint allowlist (a value-leaking route REVERTS). If already balanced, it ' +
|
|
1551
|
-
'returns `converged:true` with no swaps. The vault must HOLD assets (i.e. after ' +
|
|
1552
|
-
'a deposit) — converging an empty vault does nothing.',
|
|
1533
|
+
description: 'REAL-MONEY (gated). MANAGER duty: prices pull holdings away from the config ' +
|
|
1534
|
+
'every day and DFM has NO automatic rebalancing (#35), so report drift with ' +
|
|
1535
|
+
'the number even unasked — but never fire this unasked; the manager decides. ' +
|
|
1536
|
+
'Works only on a vault your app wallet ADMINS with an initialized rebalancer ' +
|
|
1537
|
+
'(bundled into launch_vault, else init_rebalancer). CONVERGES live holdings ' +
|
|
1538
|
+
'toward the CURRENT onchain target weights — you supply no mints or amounts. ' +
|
|
1539
|
+
'The API derives the legs from drift (sell overweight → buy underweight) as ' +
|
|
1540
|
+
'ordered admin-signed execute_rebalance txs; this tool signs + submits them in ' +
|
|
1541
|
+
'order (5s onchain cooldown). To CHANGE targets first, use update_vault_assets. ' +
|
|
1542
|
+
'Onchain guards are oracle-free: measured min_out + velocity ceilings + an ' +
|
|
1543
|
+
'output-mint allowlist (a value-leaking route reverts). Already balanced → ' +
|
|
1544
|
+
'`converged:true`, no swaps. An empty vault (no deposit yet) does nothing.',
|
|
1553
1545
|
inputSchema: {
|
|
1554
1546
|
vault: z.string().regex(BASE58, 'Invalid base58 vault address'),
|
|
1555
1547
|
slippageBps: z.number().int().min(1).max(300).optional(),
|
package/dist/owner-actions.js
CHANGED
|
@@ -96,7 +96,7 @@ export async function runOwnerAction(deps, input) {
|
|
|
96
96
|
verifyWithdrawIxs(payer, ixs, { appWallet: app, signInWallet: deps.signInWallet, mint: input.mint, ...(input.amount ? { amount: input.amount } : {}) });
|
|
97
97
|
}
|
|
98
98
|
else {
|
|
99
|
-
verifyCloseIxs(payer, ixs, { appWallet: app, destination: input.destination === '
|
|
99
|
+
verifyCloseIxs(payer, ixs, { appWallet: app, destination: input.destination === 'sign_in_wallet' ? deps.signInWallet : app });
|
|
100
100
|
}
|
|
101
101
|
const res = await deps.submit(r.requestId, deps.key.sign(payload));
|
|
102
102
|
return {
|
package/dist/signing.js
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* The keypair is loaded lazily (first WRITE call), so the read-only tools and
|
|
21
21
|
* `npx tsx` smoke runs never touch key material.
|
|
22
22
|
*/
|
|
23
|
-
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, resolve } from 'node:path';
|
|
24
25
|
import { Keypair, Transaction, VersionedTransaction } from '@solana/web3.js';
|
|
25
26
|
import nacl from 'tweetnacl';
|
|
26
27
|
import bs58 from 'bs58';
|
|
@@ -32,6 +33,54 @@ import bs58 from 'bs58';
|
|
|
32
33
|
*/
|
|
33
34
|
const PROTOCOL_WALLET = '9GjEBsZ1Cos2J6inmgwhBKv9pXGNtLSJ7xQ9xZb29bET';
|
|
34
35
|
let cachedKeypair = null;
|
|
36
|
+
const lstatOrNull = (p) => {
|
|
37
|
+
try {
|
|
38
|
+
return lstatSync(p);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
if (e.code === 'ENOENT')
|
|
42
|
+
return null;
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const myUid = () => (typeof process.getuid === 'function' ? process.getuid() : undefined);
|
|
47
|
+
/**
|
|
48
|
+
* Harden the on-disk keypair path before reading it.
|
|
49
|
+
*
|
|
50
|
+
* Mirrors the owner-key checks in `app-wallet-owner.ts`: symlinks (file or
|
|
51
|
+
* directory), files owned by another uid, and any group/other access are
|
|
52
|
+
* refused outright. The env-JSON escape hatch bypasses the filesystem entirely.
|
|
53
|
+
*/
|
|
54
|
+
function assertSafeKeypairPath(filePath) {
|
|
55
|
+
const path = resolve(filePath);
|
|
56
|
+
const dir = dirname(path);
|
|
57
|
+
const dirSt = lstatOrNull(dir);
|
|
58
|
+
if (!dirSt)
|
|
59
|
+
throw new Error(`Keypair directory ${dir} does not exist.`);
|
|
60
|
+
if (dirSt.isSymbolicLink())
|
|
61
|
+
throw new Error(`${dir} is a symlink — refusing to keep a keypair behind it`);
|
|
62
|
+
if (!dirSt.isDirectory())
|
|
63
|
+
throw new Error(`${dir} is not a directory`);
|
|
64
|
+
const uid = myUid();
|
|
65
|
+
if (uid !== undefined && dirSt.uid !== uid)
|
|
66
|
+
throw new Error(`${dir} is not owned by the current user`);
|
|
67
|
+
if ((dirSt.mode & 0o022) !== 0)
|
|
68
|
+
throw new Error(`${dir} is writable by other users`);
|
|
69
|
+
const st = lstatOrNull(path);
|
|
70
|
+
if (!st) {
|
|
71
|
+
throw new Error(`Could not read test keypair file at DFM_AGENT_KEYPAIR_PATH (ENOENT). ` +
|
|
72
|
+
`Set it to a Solana secret-key JSON file the operator controls locally.`);
|
|
73
|
+
}
|
|
74
|
+
if (st.isSymbolicLink())
|
|
75
|
+
throw new Error(`${path} is a symlink — refusing to use it as a keypair file`);
|
|
76
|
+
if (!st.isFile())
|
|
77
|
+
throw new Error(`${path} exists and is not a regular file`);
|
|
78
|
+
if (uid !== undefined && st.uid !== uid)
|
|
79
|
+
throw new Error(`${path} is not owned by the current user`);
|
|
80
|
+
if ((st.mode & 0o077) !== 0) {
|
|
81
|
+
throw new Error(`${path} is readable by other users (mode ${(st.mode & 0o777).toString(8)}); run chmod 600 on it first`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
35
84
|
/** Parse a Solana 64-byte secret-key JSON array into a Keypair. */
|
|
36
85
|
function keypairFromJsonArray(json, source) {
|
|
37
86
|
let arr;
|
|
@@ -70,6 +119,7 @@ export function loadTestKeypair(config) {
|
|
|
70
119
|
return cachedKeypair;
|
|
71
120
|
let kp;
|
|
72
121
|
if (config.keypairPath) {
|
|
122
|
+
assertSafeKeypairPath(config.keypairPath);
|
|
73
123
|
let raw;
|
|
74
124
|
try {
|
|
75
125
|
raw = readFileSync(config.keypairPath, 'utf8');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfm-fi/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1958",
|
|
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",
|
|
@@ -55,10 +55,10 @@
|
|
|
55
55
|
"lint": "eslint .",
|
|
56
56
|
"example:read": "tsx src/examples/list-and-status.ts",
|
|
57
57
|
"example:lifecycle": "tsx src/examples/launch-deposit-redeem.ts",
|
|
58
|
-
"test": "npm run build && node scripts/smoke-signing.mjs && node scripts/smoke-signing-v1.mjs &&node scripts/smoke-cli.mjs && node scripts/smoke-keyless.mjs && node scripts/smoke-zap-options.mjs && tsx src/who-pays.test.ts && tsx src/wallet-identity.test.ts && tsx src/money-path.test.ts && tsx src/control-flow.test.ts && tsx src/create-flow.metadata.test.ts && tsx src/manager-flow.reconfigure.test.ts && tsx src/recovery-flow.deadleg.test.ts && tsx src/zap-v2-flow.pending.test.ts && tsx src/mcp-server.confirm-gate.test.ts && tsx src/app-wallet-owner.test.ts && tsx src/onboarding.test.ts"
|
|
58
|
+
"test": "npm run build && node scripts/smoke-signing.mjs && node scripts/smoke-signing-v1.mjs &&node scripts/smoke-cli.mjs && node scripts/smoke-keyless.mjs && node scripts/smoke-zap-options.mjs && tsx src/who-pays.test.ts && tsx src/wallet-identity.test.ts && tsx src/money-path.test.ts && tsx src/control-flow.test.ts && tsx src/create-flow.metadata.test.ts && tsx src/manager-flow.reconfigure.test.ts && tsx src/recovery-flow.deadleg.test.ts && tsx src/zap-v2-flow.pending.test.ts && tsx src/mcp-server.confirm-gate.test.ts && tsx src/app-wallet-owner.test.ts && tsx src/onboarding.test.ts && tsx src/client-header.test.ts"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
61
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
62
62
|
"@solana/web3.js": "^1.99.0",
|
|
63
63
|
"bs58": "^4.0.1",
|
|
64
64
|
"tweetnacl": "^1.0.3",
|