@dfm-fi/agent 0.2.1881 → 0.2.1896
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 +27 -0
- package/dist/api-client.js +22 -2
- package/dist/app-wallet-owner.d.ts +83 -0
- package/dist/app-wallet-owner.js +195 -0
- package/dist/mcp-server.js +31 -4
- package/package.json +2 -2
- package/skills.md +3 -2
package/dist/api-client.d.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import type { AgentConfig } from './config.js';
|
|
2
2
|
import { SiwsSession } from './session.js';
|
|
3
|
+
import type { SignerRequest } from './app-wallet-owner.js';
|
|
4
|
+
/** Response of the headless app-wallet routes (see api agent-app-wallet.controller.ts). */
|
|
5
|
+
export type AgentAppWalletStep = {
|
|
6
|
+
status: 'active';
|
|
7
|
+
created: boolean;
|
|
8
|
+
appWallet: {
|
|
9
|
+
operatingWalletAddress?: string;
|
|
10
|
+
status?: string;
|
|
11
|
+
} & Record<string, unknown>;
|
|
12
|
+
} | {
|
|
13
|
+
status: 'awaiting_owner_signature';
|
|
14
|
+
reason?: 'create' | 'policy_update';
|
|
15
|
+
walletId: string;
|
|
16
|
+
address: string;
|
|
17
|
+
signerId: string;
|
|
18
|
+
policyId: string;
|
|
19
|
+
request: SignerRequest;
|
|
20
|
+
};
|
|
3
21
|
import type { VaultListResponse, VaultDetailResponse, ZapV2Status, ZapV2PrepareResponse, ZapV2SingleTxResponse, CreateAssetInput, CreatePrepareResponse, CreateConfirmResponse, RebalanceToTargetsResponse, ReconfigureResponse, ReconfigureTargetInput, OnchainPolicySnapshot, VaultPolicyInput } from './types.js';
|
|
4
22
|
export declare class DfmApiClient {
|
|
5
23
|
private readonly session;
|
|
@@ -77,7 +95,16 @@ export declare class DfmApiClient {
|
|
|
77
95
|
wallet?: string;
|
|
78
96
|
operatingWalletAddress?: string;
|
|
79
97
|
status?: string;
|
|
98
|
+
policyUpdateRequired?: boolean;
|
|
80
99
|
} | null>;
|
|
100
|
+
/**
|
|
101
|
+
* `POST /agent/operating/embedded/agent-wallet` — step 1 of headless app-wallet
|
|
102
|
+
* creation: send ONLY the owner's P-256 public key. Session-only server-side,
|
|
103
|
+
* so this always signs in with SIWS (a local keypair), never with DFM_API_KEY.
|
|
104
|
+
*/
|
|
105
|
+
beginAgentAppWallet(ownerPublicKey: string): Promise<AgentAppWalletStep>;
|
|
106
|
+
/** Step 2: relay the owner's signature over the exact signer request step 1 returned. */
|
|
107
|
+
confirmAgentAppWallet(ownerPublicKey: string, signature: string): Promise<AgentAppWalletStep>;
|
|
81
108
|
/** `GET /profile` — human label for the authenticated identity. */
|
|
82
109
|
/** Public profile of any trader, by wallet or username (social parity, 2026-10-09). */
|
|
83
110
|
getTraderProfile(by: {
|
package/dist/api-client.js
CHANGED
|
@@ -43,7 +43,7 @@ export class DfmApiClient {
|
|
|
43
43
|
// The API requires an X-Idempotency-Key on every mutating request. Generate
|
|
44
44
|
// ONE key per logical call and reuse it across the transparent 401 re-auth
|
|
45
45
|
// retry below, so a retry is treated as the same operation (never doubled).
|
|
46
|
-
const { idempotencyKey: pinnedKey, ...reqInit } = init ?? {};
|
|
46
|
+
const { idempotencyKey: pinnedKey, sessionOnly, ...reqInit } = init ?? {};
|
|
47
47
|
const method = (reqInit.method ?? 'GET').toUpperCase();
|
|
48
48
|
const mutating = method !== 'GET' && method !== 'HEAD';
|
|
49
49
|
const idempotencyKey = mutating ? (pinnedKey ?? randomUUID()) : undefined;
|
|
@@ -60,7 +60,7 @@ export class DfmApiClient {
|
|
|
60
60
|
// key is revocable and capped (read-only unless explicitly widened).
|
|
61
61
|
// There is no re-auth loop here — a key does not expire; a 401 means it was
|
|
62
62
|
// revoked, and silently retrying a revoked credential would be wrong.
|
|
63
|
-
const apiKey = process.env.DFM_API_KEY?.trim();
|
|
63
|
+
const apiKey = sessionOnly ? undefined : process.env.DFM_API_KEY?.trim();
|
|
64
64
|
if (apiKey) {
|
|
65
65
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
66
66
|
...reqInit,
|
|
@@ -203,6 +203,26 @@ export class DfmApiClient {
|
|
|
203
203
|
async getOperatingWallet() {
|
|
204
204
|
return this.authRequest('/agent/operating', { method: 'GET' });
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* `POST /agent/operating/embedded/agent-wallet` — step 1 of headless app-wallet
|
|
208
|
+
* creation: send ONLY the owner's P-256 public key. Session-only server-side,
|
|
209
|
+
* so this always signs in with SIWS (a local keypair), never with DFM_API_KEY.
|
|
210
|
+
*/
|
|
211
|
+
async beginAgentAppWallet(ownerPublicKey) {
|
|
212
|
+
return this.authRequest('/agent/operating/embedded/agent-wallet', {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
body: JSON.stringify({ ownerPublicKey }),
|
|
215
|
+
sessionOnly: true,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Step 2: relay the owner's signature over the exact signer request step 1 returned. */
|
|
219
|
+
async confirmAgentAppWallet(ownerPublicKey, signature) {
|
|
220
|
+
return this.authRequest('/agent/operating/embedded/agent-wallet/confirm', {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
body: JSON.stringify({ ownerPublicKey, signature }),
|
|
223
|
+
sessionOnly: true,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
206
226
|
/** `GET /profile` — human label for the authenticated identity. */
|
|
207
227
|
/** Public profile of any trader, by wallet or username (social parity, 2026-10-09). */
|
|
208
228
|
async getTraderProfile(by) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export declare const DEFAULT_OWNER_KEY_DIR: string;
|
|
2
|
+
export declare const DEFAULT_OWNER_KEY_PATH: string;
|
|
3
|
+
/** DFM's Privy signer key quorum (the production `PRIVY_EMBEDDED_SIGNER_QUORUM_ID`). */
|
|
4
|
+
export declare const DFM_PRIVY_SIGNER_QUORUM_ID = "elhpm1tlfxhqimb397m9opg8";
|
|
5
|
+
/** DFM's Privy app id — public: it ships in the web bundle (`VITE_PRIVY_APP_ID`, web/src/lib/dev-proxy.ts). */
|
|
6
|
+
export declare const DFM_PRIVY_APP_ID = "cmrgqda8l00yo0cl9j49ye64k";
|
|
7
|
+
/** The furthest ahead a `privy-request-expiry` may be before the agent refuses to sign it. */
|
|
8
|
+
export declare const MAX_REQUEST_EXPIRY_AHEAD_MS: number;
|
|
9
|
+
/** The pins, overridable ONLY by the agent operator's local env (never by the server). */
|
|
10
|
+
export declare function signerPins(): {
|
|
11
|
+
signerId: string;
|
|
12
|
+
appId: string;
|
|
13
|
+
};
|
|
14
|
+
/** Owner key path: `DFM_APP_WALLET_OWNER_KEY_PATH` or the default. Never a tool argument. */
|
|
15
|
+
export declare function ownerKeyPath(): string;
|
|
16
|
+
export interface OwnerKey {
|
|
17
|
+
path: string;
|
|
18
|
+
/** base64 DER SPKI — the format Privy's `owner.public_key` and key quorums use. */
|
|
19
|
+
publicKey: string;
|
|
20
|
+
created: boolean;
|
|
21
|
+
sign(payload: string): string;
|
|
22
|
+
}
|
|
23
|
+
/** Load the owner key at `path`, or create it (0600) when absent. */
|
|
24
|
+
export declare function loadOrCreateOwnerKey(path?: string): OwnerKey;
|
|
25
|
+
/** RFC 8785 (JCS) for the JSON shapes Privy signs: sorted keys, ES string/number serialization. */
|
|
26
|
+
export declare function canonicalJson(v: unknown): string;
|
|
27
|
+
export interface SignerRequest {
|
|
28
|
+
method: string;
|
|
29
|
+
url: string;
|
|
30
|
+
body: {
|
|
31
|
+
additional_signers: Array<{
|
|
32
|
+
signer_id: string;
|
|
33
|
+
override_policy_ids: string[];
|
|
34
|
+
}>;
|
|
35
|
+
};
|
|
36
|
+
headers: Record<string, string>;
|
|
37
|
+
payload: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Refuse anything that is not "set THIS wallet's signers to exactly [DFM's
|
|
41
|
+
* pinned quorum with exactly [policyId]]", for DFM's pinned app, expiring
|
|
42
|
+
* within 10 minutes. Returns the locally rebuilt payload to sign.
|
|
43
|
+
*/
|
|
44
|
+
export declare function checkSignerRequest(req: SignerRequest, expect: {
|
|
45
|
+
walletId: string;
|
|
46
|
+
policyId: string;
|
|
47
|
+
}, now?: number): string;
|
|
48
|
+
type Step = {
|
|
49
|
+
status: 'active';
|
|
50
|
+
created: boolean;
|
|
51
|
+
appWallet: Record<string, unknown>;
|
|
52
|
+
} | {
|
|
53
|
+
status: 'awaiting_owner_signature';
|
|
54
|
+
reason?: 'create' | 'policy_update';
|
|
55
|
+
walletId: string;
|
|
56
|
+
address: string;
|
|
57
|
+
signerId: string;
|
|
58
|
+
policyId: string;
|
|
59
|
+
request: SignerRequest;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The whole `create_app_wallet` flow: load/create the owner key, send its
|
|
63
|
+
* PUBLIC half, check + sign the one signer request (a creation or a policy
|
|
64
|
+
* reissue), confirm. Idempotent end to end — re-running resumes the same
|
|
65
|
+
* wallet with the same key.
|
|
66
|
+
*/
|
|
67
|
+
export declare function runCreateAppWallet(api: {
|
|
68
|
+
beginAgentAppWallet(ownerPublicKey: string): Promise<Step>;
|
|
69
|
+
confirmAgentAppWallet(ownerPublicKey: string, signature: string): Promise<Step>;
|
|
70
|
+
}, keyPath?: string): Promise<Record<string, unknown>>;
|
|
71
|
+
export declare const POLICY_UPDATE_NOTICE = "DFM's signing rules changed \u2014 run create_app_wallet to approve them";
|
|
72
|
+
/**
|
|
73
|
+
* The line `app_wallet` / `whoami` add when the API flags a pending policy
|
|
74
|
+
* reissue for this agent-key wallet. Signing keeps working on the old policy
|
|
75
|
+
* meanwhile (web parity), so this informs; it never blocks.
|
|
76
|
+
*/
|
|
77
|
+
export declare function policyUpdateNotice(op: {
|
|
78
|
+
policyUpdateRequired?: unknown;
|
|
79
|
+
} | null | undefined): {
|
|
80
|
+
policyUpdate?: string;
|
|
81
|
+
};
|
|
82
|
+
export {};
|
|
83
|
+
//# sourceMappingURL=app-wallet-owner.d.ts.map
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OWNER KEY of a headless agent's app wallet (2026-10-19).
|
|
3
|
+
*
|
|
4
|
+
* An agent with no browser cannot be a Privy user, so its app wallet is owned
|
|
5
|
+
* by a P-256 key generated HERE, on the agent's machine. DFM only ever sees the
|
|
6
|
+
* public half; the private key signs exactly one kind of request: the Privy
|
|
7
|
+
* PATCH that sets DFM's policy-bound signer on the wallet.
|
|
8
|
+
*
|
|
9
|
+
* Rules this file enforces (it does NOT trust the DFM server):
|
|
10
|
+
* • the PEM is written once, mode 0600, with `wx` (never overwrites); an
|
|
11
|
+
* existing file is reused — rotating it would orphan the wallet. Symlinks
|
|
12
|
+
* (file or parent dir), a file owned by another uid, or one readable by
|
|
13
|
+
* group/others are refused, not "fixed". Only `~/.config/dfm` itself is
|
|
14
|
+
* ever created (0700); any other missing directory is refused;
|
|
15
|
+
* • the request is checked against values PINNED HERE, not against the
|
|
16
|
+
* server's own answer: `signer_id` must be DFM's signer quorum and
|
|
17
|
+
* `privy-app-id` DFM's Privy app (both overridable only by local env), the
|
|
18
|
+
* body exactly one signer with exactly one policy, the headers exactly
|
|
19
|
+
* {privy-app-id, privy-request-expiry} with an expiry in the next 10 min;
|
|
20
|
+
* • the payload is rebuilt locally and must equal the server's.
|
|
21
|
+
* RESIDUAL: the policy id is trusted to DFM's server. Reading a policy's
|
|
22
|
+
* content needs Privy's app secret, which only the server has — the server
|
|
23
|
+
* re-reads it from Privy before handing the request out, the agent cannot.
|
|
24
|
+
*/
|
|
25
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign } from 'node:crypto';
|
|
26
|
+
import { lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { dirname, join, resolve } from 'node:path';
|
|
29
|
+
export const DEFAULT_OWNER_KEY_DIR = join(homedir(), '.config', 'dfm');
|
|
30
|
+
export const DEFAULT_OWNER_KEY_PATH = join(DEFAULT_OWNER_KEY_DIR, 'app-wallet-owner.pem');
|
|
31
|
+
/** DFM's Privy signer key quorum (the production `PRIVY_EMBEDDED_SIGNER_QUORUM_ID`). */
|
|
32
|
+
export const DFM_PRIVY_SIGNER_QUORUM_ID = 'elhpm1tlfxhqimb397m9opg8';
|
|
33
|
+
/** DFM's Privy app id — public: it ships in the web bundle (`VITE_PRIVY_APP_ID`, web/src/lib/dev-proxy.ts). */
|
|
34
|
+
export const DFM_PRIVY_APP_ID = 'cmrgqda8l00yo0cl9j49ye64k';
|
|
35
|
+
/** The furthest ahead a `privy-request-expiry` may be before the agent refuses to sign it. */
|
|
36
|
+
export const MAX_REQUEST_EXPIRY_AHEAD_MS = 10 * 60 * 1000;
|
|
37
|
+
/** The pins, overridable ONLY by the agent operator's local env (never by the server). */
|
|
38
|
+
export function signerPins() {
|
|
39
|
+
return {
|
|
40
|
+
signerId: process.env.DFM_PRIVY_SIGNER_QUORUM_ID?.trim() || DFM_PRIVY_SIGNER_QUORUM_ID,
|
|
41
|
+
appId: process.env.DFM_PRIVY_APP_ID?.trim() || DFM_PRIVY_APP_ID,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Owner key path: `DFM_APP_WALLET_OWNER_KEY_PATH` or the default. Never a tool argument. */
|
|
45
|
+
export function ownerKeyPath() {
|
|
46
|
+
return process.env.DFM_APP_WALLET_OWNER_KEY_PATH?.trim() || DEFAULT_OWNER_KEY_PATH;
|
|
47
|
+
}
|
|
48
|
+
const lstatOrNull = (p) => {
|
|
49
|
+
try {
|
|
50
|
+
return lstatSync(p);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e.code === 'ENOENT')
|
|
54
|
+
return null;
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const myUid = () => (typeof process.getuid === 'function' ? process.getuid() : undefined);
|
|
59
|
+
function assertSafeDir(dir) {
|
|
60
|
+
let st = lstatOrNull(dir);
|
|
61
|
+
if (!st) {
|
|
62
|
+
if (resolve(dir) !== resolve(DEFAULT_OWNER_KEY_DIR))
|
|
63
|
+
throw new Error(`${dir} does not exist — create it yourself (0700) first`);
|
|
64
|
+
mkdirSync(dir, { mode: 0o700 }); // ~/.config/dfm only, not recursive
|
|
65
|
+
st = lstatSync(dir);
|
|
66
|
+
}
|
|
67
|
+
if (st.isSymbolicLink())
|
|
68
|
+
throw new Error(`${dir} is a symlink — refusing to keep an owner key behind it`);
|
|
69
|
+
if (!st.isDirectory())
|
|
70
|
+
throw new Error(`${dir} is not a directory`);
|
|
71
|
+
const uid = myUid();
|
|
72
|
+
if (uid !== undefined && st.uid !== uid)
|
|
73
|
+
throw new Error(`${dir} is not owned by the current user`);
|
|
74
|
+
if ((st.mode & 0o022) !== 0)
|
|
75
|
+
throw new Error(`${dir} is writable by other users`);
|
|
76
|
+
}
|
|
77
|
+
/** Load the owner key at `path`, or create it (0600) when absent. */
|
|
78
|
+
export function loadOrCreateOwnerKey(path = ownerKeyPath()) {
|
|
79
|
+
assertSafeDir(dirname(path));
|
|
80
|
+
let pem;
|
|
81
|
+
let created = false;
|
|
82
|
+
const st = lstatOrNull(path);
|
|
83
|
+
if (st) {
|
|
84
|
+
if (st.isSymbolicLink())
|
|
85
|
+
throw new Error(`${path} is a symlink — refusing to use it as the owner key`);
|
|
86
|
+
if (!st.isFile())
|
|
87
|
+
throw new Error(`${path} exists and is not a regular file`);
|
|
88
|
+
const uid = myUid();
|
|
89
|
+
if (uid !== undefined && st.uid !== uid)
|
|
90
|
+
throw new Error(`${path} is not owned by the current user`);
|
|
91
|
+
if ((st.mode & 0o077) !== 0) {
|
|
92
|
+
throw new Error(`${path} is readable by other users (mode ${(st.mode & 0o777).toString(8)}); run chmod 600 on it first`);
|
|
93
|
+
}
|
|
94
|
+
pem = readFileSync(path, 'utf8');
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
98
|
+
pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
99
|
+
// `wx`: fail if anything (file or symlink) appeared in between — never overwrite an owner key.
|
|
100
|
+
writeFileSync(path, pem, { mode: 0o600, flag: 'wx' });
|
|
101
|
+
created = true;
|
|
102
|
+
}
|
|
103
|
+
const priv = createPrivateKey({ key: pem, format: 'pem' });
|
|
104
|
+
if (priv.asymmetricKeyType !== 'ec' || priv.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
|
|
105
|
+
throw new Error(`${path} is not a P-256 (prime256v1) private key`);
|
|
106
|
+
}
|
|
107
|
+
const publicKey = Buffer.from(createPublicKey(priv).export({ type: 'spki', format: 'der' })).toString('base64');
|
|
108
|
+
return {
|
|
109
|
+
path,
|
|
110
|
+
publicKey,
|
|
111
|
+
created,
|
|
112
|
+
sign: (payload) => sign('sha256', Buffer.from(payload, 'utf8'), priv).toString('base64'),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** RFC 8785 (JCS) for the JSON shapes Privy signs: sorted keys, ES string/number serialization. */
|
|
116
|
+
export function canonicalJson(v) {
|
|
117
|
+
if (v === null || typeof v !== 'object')
|
|
118
|
+
return JSON.stringify(v);
|
|
119
|
+
if (Array.isArray(v))
|
|
120
|
+
return `[${v.map(canonicalJson).join(',')}]`;
|
|
121
|
+
const o = v;
|
|
122
|
+
return `{${Object.keys(o).filter((k) => o[k] !== undefined).sort()
|
|
123
|
+
.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
|
|
124
|
+
}
|
|
125
|
+
const keysAre = (o, keys) => !!o && typeof o === 'object' && !Array.isArray(o) && Object.keys(o).sort().join() === [...keys].sort().join();
|
|
126
|
+
/**
|
|
127
|
+
* Refuse anything that is not "set THIS wallet's signers to exactly [DFM's
|
|
128
|
+
* pinned quorum with exactly [policyId]]", for DFM's pinned app, expiring
|
|
129
|
+
* within 10 minutes. Returns the locally rebuilt payload to sign.
|
|
130
|
+
*/
|
|
131
|
+
export function checkSignerRequest(req, expect, now = Date.now()) {
|
|
132
|
+
const pins = signerPins();
|
|
133
|
+
const url = `https://api.privy.io/v1/wallets/${encodeURIComponent(expect.walletId)}`;
|
|
134
|
+
const s = req?.body?.additional_signers;
|
|
135
|
+
const entry = Array.isArray(s) && s.length === 1 ? s[0] : undefined;
|
|
136
|
+
const ids = entry?.override_policy_ids;
|
|
137
|
+
const expiry = req?.headers?.['privy-request-expiry'];
|
|
138
|
+
const shapeOk = keysAre(req, ['method', 'url', 'body', 'headers', 'payload']) &&
|
|
139
|
+
req.method === 'PATCH' &&
|
|
140
|
+
req.url === url &&
|
|
141
|
+
keysAre(req.body, ['additional_signers']) &&
|
|
142
|
+
keysAre(entry, ['signer_id', 'override_policy_ids']) &&
|
|
143
|
+
entry?.signer_id === pins.signerId &&
|
|
144
|
+
Array.isArray(ids) && ids.length === 1 &&
|
|
145
|
+
typeof expect.policyId === 'string' && expect.policyId.length > 0 &&
|
|
146
|
+
ids[0] === expect.policyId &&
|
|
147
|
+
keysAre(req.headers, ['privy-app-id', 'privy-request-expiry']) &&
|
|
148
|
+
req.headers['privy-app-id'] === pins.appId &&
|
|
149
|
+
typeof expiry === 'string' && /^\d{13}$/.test(expiry) &&
|
|
150
|
+
Number(expiry) > now && Number(expiry) <= now + MAX_REQUEST_EXPIRY_AHEAD_MS;
|
|
151
|
+
if (!shapeOk) {
|
|
152
|
+
throw new Error('refusing to sign: the request is not exactly "set DFM’s pinned signer with one per-wallet policy, expiring within 10 min"');
|
|
153
|
+
}
|
|
154
|
+
const payload = canonicalJson({ version: 1, method: 'PATCH', url, body: req.body, headers: req.headers });
|
|
155
|
+
if (payload !== req.payload)
|
|
156
|
+
throw new Error('refusing to sign: the server payload differs from the request it describes');
|
|
157
|
+
return payload;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The whole `create_app_wallet` flow: load/create the owner key, send its
|
|
161
|
+
* PUBLIC half, check + sign the one signer request (a creation or a policy
|
|
162
|
+
* reissue), confirm. Idempotent end to end — re-running resumes the same
|
|
163
|
+
* wallet with the same key.
|
|
164
|
+
*/
|
|
165
|
+
export async function runCreateAppWallet(api, keyPath = ownerKeyPath()) {
|
|
166
|
+
const key = loadOrCreateOwnerKey(keyPath);
|
|
167
|
+
const ownerKey = { path: key.path, publicKey: key.publicKey, createdNow: key.created };
|
|
168
|
+
let step = await api.beginAgentAppWallet(key.publicKey);
|
|
169
|
+
const signed = step.status === 'awaiting_owner_signature' ? (step.reason ?? 'create') : null;
|
|
170
|
+
if (step.status === 'awaiting_owner_signature') {
|
|
171
|
+
const payload = checkSignerRequest(step.request, step);
|
|
172
|
+
step = await api.confirmAgentAppWallet(key.publicKey, key.sign(payload));
|
|
173
|
+
}
|
|
174
|
+
if (step.status !== 'active')
|
|
175
|
+
throw new Error(`unexpected app wallet status: ${step.status}`);
|
|
176
|
+
return {
|
|
177
|
+
status: 'active',
|
|
178
|
+
created: step.created,
|
|
179
|
+
...(signed === 'policy_update' ? { policyUpdated: true } : {}),
|
|
180
|
+
appWallet: step.appWallet,
|
|
181
|
+
ownerKey,
|
|
182
|
+
note: `The owner key at ${key.path} is the ONLY key that owns this app wallet — back it up offline. ` +
|
|
183
|
+
'DFM cannot recover it. Fund the app wallet with USDC + a little SOL, then deposit/launch as usual.',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export const POLICY_UPDATE_NOTICE = "DFM's signing rules changed — run create_app_wallet to approve them";
|
|
187
|
+
/**
|
|
188
|
+
* The line `app_wallet` / `whoami` add when the API flags a pending policy
|
|
189
|
+
* reissue for this agent-key wallet. Signing keeps working on the old policy
|
|
190
|
+
* meanwhile (web parity), so this informs; it never blocks.
|
|
191
|
+
*/
|
|
192
|
+
export function policyUpdateNotice(op) {
|
|
193
|
+
return op?.policyUpdateRequired === true ? { policyUpdate: POLICY_UPDATE_NOTICE } : {};
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=app-wallet-owner.js.map
|
package/dist/mcp-server.js
CHANGED
|
@@ -66,6 +66,7 @@ import { DfmApiClient } from './api-client.js';
|
|
|
66
66
|
import { isKeylessMode, executeAppWalletWrite, KEYLESS_SETUP_HINT, } from './keyless.js';
|
|
67
67
|
import { runAcceptAdminTransfer, runUpdateVaultPolicy, reconcileKeylessUpdateVaultPolicy, } from './manager-flow.js';
|
|
68
68
|
import { submitSignedTx } from './submit.js';
|
|
69
|
+
import { DEFAULT_OWNER_KEY_PATH, ownerKeyPath, policyUpdateNotice, runCreateAppWallet } from './app-wallet-owner.js';
|
|
69
70
|
import { signBase64Transaction } from './signing.js';
|
|
70
71
|
import { buildWalletIdentity, resolveZapStatusWallet, withActingWalletAlias, withPortfolioWalletAlias, } from './wallet-identity.js';
|
|
71
72
|
const config = loadConfig();
|
|
@@ -443,6 +444,7 @@ server.registerTool('app_wallet', {
|
|
|
443
444
|
? { withdrawable: { usdc, sol: bal.withdrawableSolLamports / 1_000_000_000 } }
|
|
444
445
|
: {}),
|
|
445
446
|
nextStep,
|
|
447
|
+
...policyUpdateNotice(grant),
|
|
446
448
|
});
|
|
447
449
|
}
|
|
448
450
|
catch (e) {
|
|
@@ -507,6 +509,26 @@ server.registerTool('report_bug', {
|
|
|
507
509
|
return errText(`report_bug failed: ${e.message}`);
|
|
508
510
|
}
|
|
509
511
|
});
|
|
512
|
+
server.registerTool('create_app_wallet', {
|
|
513
|
+
title: 'Create my app wallet (headless)',
|
|
514
|
+
description: "Create this agent's DFM APP WALLET without a browser. Generates a P-256 owner key on THIS machine " +
|
|
515
|
+
`(${DEFAULT_OWNER_KEY_PATH} or DFM_APP_WALLET_OWNER_KEY_PATH, mode 0600; reused if it exists, never ` +
|
|
516
|
+
"overwritten), sends DFM only the public key, and signs the one Privy request that sets DFM's " +
|
|
517
|
+
'policy-bound signer (cash-out to the sign-in wallet only). The signer id and Privy app id are pinned in ' +
|
|
518
|
+
'this MCP, not taken from the server; the per-wallet POLICY id is trusted to DFM (its content can only be ' +
|
|
519
|
+
'read with Privy\'s app secret). Call it again when DFM reissues the policy. DFM never holds the key. ' +
|
|
520
|
+
'Needs a SIWS sign-in (a local keypair, DFM_AGENT_KEYPAIR_PATH) — a dfm_ API key cannot create custody. ' +
|
|
521
|
+
'Idempotent: returns the existing app wallet if there is one. Moves no funds.',
|
|
522
|
+
inputSchema: {},
|
|
523
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
524
|
+
}, async () => {
|
|
525
|
+
try {
|
|
526
|
+
return text(await runCreateAppWallet(api, ownerKeyPath()));
|
|
527
|
+
}
|
|
528
|
+
catch (e) {
|
|
529
|
+
return errText(`create_app_wallet failed: ${e.message}`);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
510
532
|
server.registerTool('xp_status', {
|
|
511
533
|
title: 'XP',
|
|
512
534
|
description: "The user's XP (total and by kind: deposit, launch, profile, referral) and the top of the XP leaderboard.",
|
|
@@ -529,8 +551,8 @@ server.registerTool('whoami', {
|
|
|
529
551
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
530
552
|
}, async () => {
|
|
531
553
|
try {
|
|
532
|
-
const [identity, profile] = await Promise.all([
|
|
533
|
-
|
|
554
|
+
const [{ identity, op }, profile] = await Promise.all([
|
|
555
|
+
resolveWalletIdentityWithOp(),
|
|
534
556
|
api.getProfile().catch(() => null),
|
|
535
557
|
]);
|
|
536
558
|
return text({
|
|
@@ -549,6 +571,7 @@ server.registerTool('whoami', {
|
|
|
549
571
|
setupHint: 'No active app-wallet signer. Call app_wallet for its status and next step; ' +
|
|
550
572
|
'an external keypair cannot sign fund operations.',
|
|
551
573
|
}),
|
|
574
|
+
...policyUpdateNotice(op),
|
|
552
575
|
apiUrl: config.apiUrl,
|
|
553
576
|
cluster: config.cluster,
|
|
554
577
|
writeEnabled: config.writeEnabled,
|
|
@@ -673,13 +696,17 @@ function keylessUnsupported(tool) {
|
|
|
673
696
|
* would silently keep reporting (and inspecting) a stale address.
|
|
674
697
|
*/
|
|
675
698
|
async function resolveWalletIdentity() {
|
|
699
|
+
return (await resolveWalletIdentityWithOp()).identity;
|
|
700
|
+
}
|
|
701
|
+
/** Same lookup, also handing back the raw grant (whoami reads `policyUpdateRequired` off it). */
|
|
702
|
+
async function resolveWalletIdentityWithOp() {
|
|
676
703
|
const localSignIn = isKeylessMode(config) ? null : session.walletAddress;
|
|
677
704
|
try {
|
|
678
705
|
const op = await api.getOperatingWallet();
|
|
679
|
-
return buildWalletIdentity(op, localSignIn);
|
|
706
|
+
return { identity: buildWalletIdentity(op, localSignIn), op };
|
|
680
707
|
}
|
|
681
708
|
catch {
|
|
682
|
-
return buildWalletIdentity(null, localSignIn);
|
|
709
|
+
return { identity: buildWalletIdentity(null, localSignIn), op: null };
|
|
683
710
|
}
|
|
684
711
|
}
|
|
685
712
|
async function keylessWrite(tool, args) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfm-fi/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1896",
|
|
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",
|
|
6
6
|
"type": "module",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"lint": "eslint .",
|
|
48
48
|
"example:read": "tsx src/examples/list-and-status.ts",
|
|
49
49
|
"example:lifecycle": "tsx src/examples/launch-deposit-redeem.ts",
|
|
50
|
-
"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"
|
|
50
|
+
"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"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
package/skills.md
CHANGED
|
@@ -129,11 +129,12 @@ 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 **40 tools** (20 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 (20)** — 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
|
+
- `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.
|
|
137
138
|
- `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.
|
|
138
139
|
- `list_dtfs` — Browse live funds (optional category filter).
|
|
139
140
|
- `get_vault` — One vault: weights, deviations, fees, TVL, NAV.
|