@dfm-fi/agent 0.2.1964 → 0.2.1967

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.
@@ -70,7 +70,8 @@ import { DEFAULT_OWNER_KEY_PATH, ownerKeyPath, policyUpdateNotice, runCreateAppW
70
70
  import { getTestWalletPubkey, signBase64Transaction } from './signing.js';
71
71
  import { ONBOARDING_ORDER, ensureIdentityWallet, fundingNextStep, mintAndStoreKey, resolveAvatar, runOnboard } from './onboarding.js';
72
72
  import { makeIdentityGuard, makeKeyOwnerLookup, makeSessionFetch, onboardingApi } from './onboarding-api.js';
73
- import { existingOwnerKey, runOwnerAction } from './owner-actions.js';
73
+ import { existingOwnerKey, fetchOwnQuote, runOwnerAction } from './owner-actions.js';
74
+ import { runOwnerRebalance } from './owner-rebalance.js';
74
75
  import { Connection } from '@solana/web3.js';
75
76
  import { buildWalletIdentity, resolveZapStatusWallet, withActingWalletAlias, withPortfolioWalletAlias, } from './wallet-identity.js';
76
77
  const config = loadConfig();
@@ -189,6 +190,45 @@ async function ownerSignLocally(res, input) {
189
190
  resolveAlt: async (k) => (await conn.getAddressLookupTable(k)).value,
190
191
  }, input));
191
192
  }
193
+ /**
194
+ * Agent-owned app wallet + local owner key: run the converge through the
195
+ * owner lane (sandwich legs signed HERE after local verification, CPI legs by
196
+ * DFM's signer). Null when there is no local owner key or the server says the
197
+ * wallet is not agent-owned (409) — the caller keeps the normal path.
198
+ */
199
+ async function ownerRebalanceLocally(vault, slippageBps) {
200
+ const key = existingOwnerKey();
201
+ if (!key)
202
+ return null;
203
+ await assertSameIdentity();
204
+ const conn = new Connection(config.rpcUrl || 'https://api.mainnet-beta.solana.com', 'confirmed');
205
+ try {
206
+ return text(await runOwnerRebalance({
207
+ key,
208
+ appWallet: async () => (await api.getOperatingWallet())?.operatingWalletAddress,
209
+ prepare: (i) => onboardApi.prepareOwnerAction(i),
210
+ submit: (id, sig) => onboardApi.submitOwnerAction(id, sig),
211
+ resolveAlt: async (k) => (await conn.getAddressLookupTable(k)).value,
212
+ ownQuote: (q) => fetchOwnQuote(q),
213
+ awaitLanded: async (sig) => {
214
+ for (let i = 0; i < 40; i++) {
215
+ const st = (await conn.getSignatureStatuses([sig])).value[0];
216
+ if (st?.err)
217
+ throw new Error(`rebalance leg ${sig} reverted: ${JSON.stringify(st.err)}`);
218
+ if (st?.confirmationStatus === 'confirmed' || st?.confirmationStatus === 'finalized')
219
+ return;
220
+ await new Promise((r) => setTimeout(r, 1500));
221
+ }
222
+ throw new Error(`rebalance leg ${sig} not confirmed after 60 s — check it before re-running`);
223
+ },
224
+ }, { vault, ...(slippageBps !== undefined ? { slippageBps } : {}) }));
225
+ }
226
+ catch (e) {
227
+ if (/DFM API 409\b.*agent-owned|only for agent-owned/i.test(e.message))
228
+ return null;
229
+ return errText(`rebalance_to_targets failed (agent-owned app wallet): ${e.message}`);
230
+ }
231
+ }
192
232
  function ownerApprovalOr(res, what, where) {
193
233
  if (res && typeof res === 'object' && 'ownerSignature' in res && res.ownerSignature) {
194
234
  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}.`);
@@ -1588,6 +1628,10 @@ async ({ vault, slippageBps, confirmPhrase }) => {
1588
1628
  const write = writeGate();
1589
1629
  if (!write.ok)
1590
1630
  return write.result;
1631
+ // Agent-owned app wallet: the owner key signs sandwich legs after local verification.
1632
+ const owned = await ownerRebalanceLocally(vault, slippageBps);
1633
+ if (owned)
1634
+ return owned;
1591
1635
  return keylessWrite('rebalance_to_targets', { vault, slippageBps });
1592
1636
  });
1593
1637
  // ─── Start ────────────────────────────────────────────────────────────────
@@ -28,6 +28,10 @@ export type OwnerActionInput = {
28
28
  } | {
29
29
  kind: 'close_empty';
30
30
  destination?: 'sign_in_wallet' | 'app_wallet';
31
+ } | {
32
+ kind: 'rebalance';
33
+ vault: string;
34
+ slippageBps?: number;
31
35
  };
32
36
  export interface OwnerRpcRequest {
33
37
  status: 'awaiting_owner_signature';
@@ -95,9 +95,12 @@ export async function runOwnerAction(deps, input) {
95
95
  else if (input.kind === 'withdraw_asset') {
96
96
  verifyWithdrawIxs(payer, ixs, { appWallet: app, signInWallet: deps.signInWallet, mint: input.mint, ...(input.amount ? { amount: input.amount } : {}) });
97
97
  }
98
- else {
98
+ else if (input.kind === 'close_empty') {
99
99
  verifyCloseIxs(payer, ixs, { appWallet: app, destination: input.destination === 'sign_in_wallet' ? deps.signInWallet : app });
100
100
  }
101
+ else {
102
+ throw new Error('refusing to sign: a rebalance leg goes through runOwnerRebalance (owner-rebalance.ts)');
103
+ }
101
104
  const res = await deps.submit(r.requestId, deps.key.sign(payload));
102
105
  return {
103
106
  status: 'complete',
@@ -0,0 +1,58 @@
1
+ /**
2
+ * INDEPENDENT decoding of an owner-signed REBALANCE SANDWICH leg (agent-owned
3
+ * app wallets). The owner key is all the authority there is, so this file —
4
+ * not DFM's server — decides whether the leg is safe to sign. Accepted, and
5
+ * nothing else (fail closed):
6
+ *
7
+ * ComputeBudget (same bounds as a swap) · ATA create(-idempotent) paid by the
8
+ * app wallet for the LEG WALLET only · ONE SOL float app → leg wallet, equal
9
+ * to BEGIN's declared float and ≤ 0.005 SOL · at most ONE dtf_vault
10
+ * `approve_token_delegate` before BEGIN (authority = app wallet, this vault,
11
+ * the vault's input-mint ATA, delegate = rebalancer_config PDA of this vault,
12
+ * amount == BEGIN's amount_in) · dtf_rebalancer BEGIN → Jupiter → END,
13
+ * contiguous, exactly once, manager lane (no session key), this vault, END
14
+ * pairing BEGIN's accounts · Jupiter `route` / `shared_accounts_route` whose
15
+ * authority is the leg wallet, source/destination the leg wallet's ATAs of
16
+ * BEGIN's mints, input == amount_in, no platform fee · after END only the
17
+ * Pump volume-account close (leg wallet) + its rent back to the app wallet
18
+ * (≤ 2,000,000 lamports).
19
+ *
20
+ * The app wallet may appear ONLY as the payer/authority slot of those
21
+ * instructions (and as the destination of the rent reclaim): anywhere else its
22
+ * signature would be lent to a program this file did not vet.
23
+ *
24
+ * Vault-side accounts (vault, config, vault ATAs) may come from the vault's
25
+ * lookup table — the RPC serves those, so they are compared but NOT relied on
26
+ * alone: every one is re-derived on-chain (approve pins delegate = the config
27
+ * PDA of DTF_REBALANCER_PROGRAM_ID and token_account = the vault's canonical
28
+ * ATA; BEGIN/END re-derive the config seeds and the canonical ATAs; both
29
+ * require vault.vault_admin == the app wallet). Program ids and every signer
30
+ * position are static keys (`key()`), so a lying RPC cannot move authority.
31
+ */
32
+ import { type TransactionInstruction } from '@solana/web3.js';
33
+ export declare const DTF_VAULT_PROGRAM = "96garpVfmLBkvK33fh7f5edfe85UbhmZ6K67freykxsC";
34
+ /** The LIVE dtf_rebalancer id (rotates on a self-funded redeploy → `DFM_REBALANCER_PROGRAM_ID` overrides; a stale pin fails closed). */
35
+ export declare const DEFAULT_REBALANCER_PROGRAM = "CpaZpZrugfc4MC4h3hoFRYrSZgd7G3puzqVZ8a4KMpGH";
36
+ export declare const PUMP_AMM_PROGRAM = "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA";
37
+ export declare const MAX_LEG_FLOAT_LAMPORTS = 5000000n;
38
+ export declare const MAX_SPEND_ALLOWANCE = 2000000n;
39
+ export declare const APPROVE_DISC: Buffer<ArrayBuffer>;
40
+ export declare const BEGIN_DISC: Buffer<ArrayBuffer>;
41
+ export declare const END_DISC: Buffer<ArrayBuffer>;
42
+ export declare function rebalancerProgramId(): string;
43
+ export declare const rebalancerConfigOf: (vault: string, program?: string) => string;
44
+ export interface RebalanceLegExpect {
45
+ appWallet: string;
46
+ legWallet: string;
47
+ vault: string;
48
+ }
49
+ /** What the leg does, read from the bytes — the caller prices it against its own quote. */
50
+ export interface RebalanceLegFacts {
51
+ inputMint: string;
52
+ outputMint: string;
53
+ amountIn: bigint;
54
+ minOut: bigint;
55
+ approved: boolean;
56
+ }
57
+ export declare function verifyRebalanceLegIxs(payer: string, ixs: TransactionInstruction[], e: RebalanceLegExpect): RebalanceLegFacts;
58
+ //# sourceMappingURL=owner-rebalance-verify.d.ts.map
@@ -0,0 +1,193 @@
1
+ /**
2
+ * INDEPENDENT decoding of an owner-signed REBALANCE SANDWICH leg (agent-owned
3
+ * app wallets). The owner key is all the authority there is, so this file —
4
+ * not DFM's server — decides whether the leg is safe to sign. Accepted, and
5
+ * nothing else (fail closed):
6
+ *
7
+ * ComputeBudget (same bounds as a swap) · ATA create(-idempotent) paid by the
8
+ * app wallet for the LEG WALLET only · ONE SOL float app → leg wallet, equal
9
+ * to BEGIN's declared float and ≤ 0.005 SOL · at most ONE dtf_vault
10
+ * `approve_token_delegate` before BEGIN (authority = app wallet, this vault,
11
+ * the vault's input-mint ATA, delegate = rebalancer_config PDA of this vault,
12
+ * amount == BEGIN's amount_in) · dtf_rebalancer BEGIN → Jupiter → END,
13
+ * contiguous, exactly once, manager lane (no session key), this vault, END
14
+ * pairing BEGIN's accounts · Jupiter `route` / `shared_accounts_route` whose
15
+ * authority is the leg wallet, source/destination the leg wallet's ATAs of
16
+ * BEGIN's mints, input == amount_in, no platform fee · after END only the
17
+ * Pump volume-account close (leg wallet) + its rent back to the app wallet
18
+ * (≤ 2,000,000 lamports).
19
+ *
20
+ * The app wallet may appear ONLY as the payer/authority slot of those
21
+ * instructions (and as the destination of the rent reclaim): anywhere else its
22
+ * signature would be lent to a program this file did not vet.
23
+ *
24
+ * Vault-side accounts (vault, config, vault ATAs) may come from the vault's
25
+ * lookup table — the RPC serves those, so they are compared but NOT relied on
26
+ * alone: every one is re-derived on-chain (approve pins delegate = the config
27
+ * PDA of DTF_REBALANCER_PROGRAM_ID and token_account = the vault's canonical
28
+ * ATA; BEGIN/END re-derive the config seeds and the canonical ATAs; both
29
+ * require vault.vault_admin == the app wallet). Program ids and every signer
30
+ * position are static keys (`key()`), so a lying RPC cannot move authority.
31
+ */
32
+ import { PublicKey } from '@solana/web3.js';
33
+ import { createHash } from 'node:crypto';
34
+ import { atasOf, checkComputeBudget, JUP_ROUTE, JUP_SHARED_ROUTE, JUPITER_V6, key, PROTOCOL_WALLET, refuse } from './owner-tx-verify.js';
35
+ export const DTF_VAULT_PROGRAM = '96garpVfmLBkvK33fh7f5edfe85UbhmZ6K67freykxsC';
36
+ /** The LIVE dtf_rebalancer id (rotates on a self-funded redeploy → `DFM_REBALANCER_PROGRAM_ID` overrides; a stale pin fails closed). */
37
+ export const DEFAULT_REBALANCER_PROGRAM = 'CpaZpZrugfc4MC4h3hoFRYrSZgd7G3puzqVZ8a4KMpGH';
38
+ export const PUMP_AMM_PROGRAM = 'pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA';
39
+ export const MAX_LEG_FLOAT_LAMPORTS = 5000000n;
40
+ export const MAX_SPEND_ALLOWANCE = 2000000n;
41
+ const SYSTEM = '11111111111111111111111111111111';
42
+ const COMPUTE = 'ComputeBudget111111111111111111111111111111';
43
+ const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
44
+ const disc = (n) => createHash('sha256').update(`global:${n}`).digest().subarray(0, 8);
45
+ export const APPROVE_DISC = disc('approve_token_delegate');
46
+ export const BEGIN_DISC = disc('rebalance_leg_begin_owner');
47
+ export const END_DISC = disc('rebalance_leg_end_owner');
48
+ export function rebalancerProgramId() {
49
+ return process.env.DFM_REBALANCER_PROGRAM_ID?.trim() || DEFAULT_REBALANCER_PROGRAM;
50
+ }
51
+ export const rebalancerConfigOf = (vault, program = rebalancerProgramId()) => PublicKey.findProgramAddressSync([Buffer.from('rebalancer'), new PublicKey(vault).toBuffer()], new PublicKey(program))[0].toBase58();
52
+ const at = (ix, i) => ix.keys[i]?.pubkey.toBase58() ?? refuse(`missing account #${i}`);
53
+ const hasDisc = (ix, d) => !!ix && Buffer.from(ix.data).subarray(0, 8).equals(d);
54
+ export function verifyRebalanceLegIxs(payer, ixs, e) {
55
+ const app = e.appWallet;
56
+ const leg = e.legWallet;
57
+ const REB = rebalancerProgramId();
58
+ if (payer !== app)
59
+ refuse('fee payer is not the app wallet');
60
+ if (leg === app || leg === PROTOCOL_WALLET)
61
+ refuse('the second signer is not a throwaway leg wallet');
62
+ checkComputeBudget(ixs);
63
+ const b = ixs.findIndex((ix) => ix.programId.toBase58() === REB && hasDisc(ix, BEGIN_DISC));
64
+ if (b < 0)
65
+ refuse('no rebalance_leg_begin_owner of the pinned dtf_rebalancer');
66
+ const begin = ixs[b];
67
+ const jup = ixs[b + 1];
68
+ const end = ixs[b + 2];
69
+ if (!jup || jup.programId.toBase58() !== JUPITER_V6)
70
+ refuse('BEGIN is not followed by Jupiter');
71
+ if (!end || end.programId.toBase58() !== REB || !hasDisc(end, END_DISC))
72
+ refuse('Jupiter is not followed by END');
73
+ // BEGIN — accounts (IDL): authority, factory, vault, config, session_key(opt), input ATA, output ATA,
74
+ // src_mint, dst_mint, leg_wallet, wallet_src, wallet_dst, token_program, token_program_2022(opt), sysvar.
75
+ const config = rebalancerConfigOf(e.vault, REB);
76
+ if (key(begin, 0) !== app)
77
+ refuse('BEGIN authority is not the app wallet');
78
+ if (at(begin, 2) !== e.vault)
79
+ refuse('BEGIN names another vault');
80
+ if (at(begin, 3) !== config)
81
+ refuse('BEGIN config is not this vault’s rebalancer_config');
82
+ if (at(begin, 4) !== REB)
83
+ refuse('a session key on the owner lane');
84
+ const inputMint = at(begin, 7);
85
+ const outputMint = at(begin, 8);
86
+ const inAta = at(begin, 5);
87
+ const outAta = at(begin, 6);
88
+ if (!atasOf(e.vault, inputMint).includes(inAta) || !atasOf(e.vault, outputMint).includes(outAta))
89
+ refuse('BEGIN does not use the vault’s canonical ATAs');
90
+ if (key(begin, 9) !== leg)
91
+ refuse('BEGIN leg wallet is not the second signer');
92
+ if (!atasOf(leg, inputMint).includes(at(begin, 10)) || !atasOf(leg, outputMint).includes(at(begin, 11)))
93
+ refuse('BEGIN wallet_src/wallet_dst are not the leg wallet’s canonical accounts');
94
+ const bd = Buffer.from(begin.data);
95
+ if (bd.length !== 50)
96
+ refuse('BEGIN params have an unexpected size');
97
+ const amountIn = bd.readBigUInt64LE(8);
98
+ const minOut = bd.readBigUInt64LE(16);
99
+ const float = bd.readBigUInt64LE(34);
100
+ const allowance = bd.readBigUInt64LE(42);
101
+ if (amountIn <= 0n)
102
+ refuse('BEGIN amount_in is zero');
103
+ if (float > MAX_LEG_FLOAT_LAMPORTS)
104
+ refuse(`a leg float of ${float} lamports`);
105
+ if (allowance > MAX_SPEND_ALLOWANCE)
106
+ refuse(`a spend allowance of ${allowance} lamports`);
107
+ // END — authority, vault, config, session_key(opt), input ATA, output ATA, dst_mint, leg_wallet, …
108
+ if (key(end, 0) !== app || at(end, 1) !== e.vault || at(end, 2) !== config || at(end, 3) !== REB)
109
+ refuse('END does not pair with BEGIN');
110
+ if (at(end, 4) !== inAta || at(end, 5) !== outAta || at(end, 6) !== outputMint || key(end, 7) !== leg)
111
+ refuse('END does not pair with BEGIN');
112
+ checkLegJupiter(jup, { leg, inputMint, outputMint, amountIn });
113
+ let floats = 0;
114
+ let approved = false;
115
+ let pumpClosed = false;
116
+ ixs.forEach((ix, i) => {
117
+ const p = ix.programId.toBase58();
118
+ const d = Buffer.from(ix.data);
119
+ const appAt = ix.keys.flatMap((m, j) => (m.pubkey.toBase58() === app ? [j] : []));
120
+ const appOnlyAt = (...ok) => { if (appAt.some((j) => !ok.includes(j)))
121
+ refuse(`the app wallet is lent to instruction ${i} (${p})`); };
122
+ if (i === b || i === b + 2)
123
+ return appOnlyAt(0);
124
+ if (i === b + 1)
125
+ return appOnlyAt();
126
+ if (p === COMPUTE)
127
+ return appOnlyAt();
128
+ if (p === ATA_PROGRAM) {
129
+ if (!(d.length === 0 || (d.length === 1 && (d[0] === 0 || d[0] === 1))))
130
+ refuse('unexpected associated-token instruction');
131
+ if (key(ix, 0) !== app)
132
+ refuse('an account creation not paid by the app wallet');
133
+ if (at(ix, 2) !== leg)
134
+ refuse('creates a token account for someone other than the leg wallet');
135
+ return appOnlyAt(0);
136
+ }
137
+ if (p === SYSTEM) {
138
+ if (d.length !== 12 || d.readUInt32LE(0) !== 2)
139
+ refuse('a system instruction other than a transfer');
140
+ const lamports = d.readBigUInt64LE(4);
141
+ if (i < b && key(ix, 0) === app && key(ix, 1) === leg && lamports === float && ++floats === 1)
142
+ return appOnlyAt(0);
143
+ if (i > b + 2 && pumpClosed && key(ix, 0) === leg && key(ix, 1) === app && lamports <= MAX_SPEND_ALLOWANCE)
144
+ return appOnlyAt(1);
145
+ refuse(`an unexpected SOL transfer (instruction ${i})`);
146
+ }
147
+ if (p === DTF_VAULT_PROGRAM) {
148
+ if (i > b || approved || !hasDisc(ix, APPROVE_DISC) || d.length !== 16)
149
+ refuse('an unexpected dtf_vault instruction');
150
+ if (key(ix, 0) !== app || at(ix, 2) !== e.vault || at(ix, 3) !== inAta)
151
+ refuse('the approve is not the vault admin on this vault’s input ATA');
152
+ if (at(ix, 4) !== config)
153
+ refuse('the approve delegate is not this vault’s rebalancer_config');
154
+ if (d.readBigUInt64LE(8) !== amountIn)
155
+ refuse('the approve amount differs from BEGIN’s amount_in');
156
+ approved = true;
157
+ return appOnlyAt(0);
158
+ }
159
+ if (p === PUMP_AMM_PROGRAM && i > b + 2 && !pumpClosed) {
160
+ // close_user_volume_accumulator: [wallet(signer), accumulator(w), eventAuthority, program] — same shape the server asserts.
161
+ if (ix.keys.length !== 4 || key(ix, 0) !== leg || !ix.keys[0].isSigner)
162
+ refuse('a malformed Pump volume-account close');
163
+ pumpClosed = true;
164
+ return appOnlyAt();
165
+ }
166
+ refuse(`unexpected instruction ${i} (${p})`);
167
+ });
168
+ if (float > 0n && floats !== 1)
169
+ refuse('the leg float transfer is missing');
170
+ return { inputMint, outputMint, amountIn, minOut, approved };
171
+ }
172
+ /** Jupiter on the leg wallet: authority, source and destination are the leg wallet's; input == amount_in; no platform fee. */
173
+ function checkLegJupiter(ix, e) {
174
+ const d = Buffer.from(ix.data);
175
+ const tag = d.subarray(0, 8).toString('hex');
176
+ if ((tag !== JUP_ROUTE && tag !== JUP_SHARED_ROUTE) || d.length < 27)
177
+ refuse('an unsupported Jupiter instruction');
178
+ const t = d.length - 19;
179
+ if (d.readBigUInt64LE(t) !== e.amountIn)
180
+ refuse('the route input differs from BEGIN’s amount_in');
181
+ if (d[t + 18] !== 0)
182
+ refuse('a platform fee on a rebalance leg');
183
+ const [auth, src, dst] = tag === JUP_ROUTE ? [1, 2, 3] : [2, 3, 6];
184
+ if (key(ix, auth) !== e.leg)
185
+ refuse('the route authority is not the leg wallet');
186
+ if (!atasOf(e.leg, e.inputMint).includes(at(ix, src)))
187
+ refuse('the route source is not the leg wallet’s input account');
188
+ if (!atasOf(e.leg, e.outputMint).includes(at(ix, dst)))
189
+ refuse('the route output does not land in the leg wallet');
190
+ if (ix.keys.some((m) => m.isSigner && m.pubkey.toBase58() !== e.leg))
191
+ refuse('a foreign signer inside the route');
192
+ }
193
+ //# sourceMappingURL=owner-rebalance-verify.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `rebalance_to_targets` for an AGENT-owned app wallet (this machine holds the
3
+ * P-256 owner key). The API plans ONE converge leg per call against live
4
+ * balances and either
5
+ * • returns it as an owner request — the rebalance SANDWICH, leg wallet
6
+ * already signed by the server, app-wallet slot empty — which is decoded
7
+ * here (owner-rebalance-verify.ts), priced against an INDEPENDENT Jupiter
8
+ * quote, and only then signed with the owner key and relayed; or
9
+ * • executes it itself (a CPI leg through DFM's policy-bound signer); or
10
+ * • reports there is nothing left (converged / cleanup / pending).
11
+ * The loop re-plans after every leg and waits out the 5 s on-chain cooldown.
12
+ */
13
+ import { type OwnerActionDeps } from './owner-actions.js';
14
+ export declare const MAX_REBALANCE_STEPS = 12;
15
+ export declare const LEG_COOLDOWN_MS = 6000;
16
+ /** Default leg slippage the agent prices the server's `min_out` against (the tool caps it at 300). */
17
+ export declare const DEFAULT_REBALANCE_SLIPPAGE_BPS = 100;
18
+ export interface OwnerRebalanceDeps extends Pick<OwnerActionDeps, 'key' | 'appWallet' | 'prepare' | 'submit' | 'resolveAlt' | 'ownQuote'> {
19
+ ownQuote: NonNullable<OwnerActionDeps['ownQuote']>;
20
+ /**
21
+ * Resolves once the signature is CONFIRMED without error, throws on a revert
22
+ * or timeout. The next leg is re-planned from live balances, so planning on
23
+ * top of an unconfirmed leg would size it against stale holdings.
24
+ */
25
+ awaitLanded(signature: string): Promise<void>;
26
+ sleep?(ms: number): Promise<void>;
27
+ }
28
+ export interface OwnerRebalanceLeg {
29
+ signature: string;
30
+ via: 'sandwich (owner key)' | 'cpi (DFM signer)';
31
+ inputMint?: string;
32
+ outputMint?: string;
33
+ amountIn?: string;
34
+ approvedDelegation?: boolean;
35
+ }
36
+ /** `min_out` must clear the independent quote after the requested slippage, minus the same drift a swap tolerates. */
37
+ export declare function minOutFloor(ownOut: bigint, slippageBps: number): bigint;
38
+ export declare function runOwnerRebalance(deps: OwnerRebalanceDeps, input: {
39
+ vault: string;
40
+ slippageBps?: number;
41
+ }): Promise<Record<string, unknown>>;
42
+ //# sourceMappingURL=owner-rebalance.d.ts.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `rebalance_to_targets` for an AGENT-owned app wallet (this machine holds the
3
+ * P-256 owner key). The API plans ONE converge leg per call against live
4
+ * balances and either
5
+ * • returns it as an owner request — the rebalance SANDWICH, leg wallet
6
+ * already signed by the server, app-wallet slot empty — which is decoded
7
+ * here (owner-rebalance-verify.ts), priced against an INDEPENDENT Jupiter
8
+ * quote, and only then signed with the owner key and relayed; or
9
+ * • executes it itself (a CPI leg through DFM's policy-bound signer); or
10
+ * • reports there is nothing left (converged / cleanup / pending).
11
+ * The loop re-plans after every leg and waits out the 5 s on-chain cooldown.
12
+ */
13
+ import { checkOwnerRpcRequest } from './owner-actions.js';
14
+ import { QUOTE_DRIFT_BPS, decodeTx } from './owner-tx-verify.js';
15
+ import { verifyRebalanceLegIxs } from './owner-rebalance-verify.js';
16
+ export const MAX_REBALANCE_STEPS = 12;
17
+ export const LEG_COOLDOWN_MS = 6_000;
18
+ /** Default leg slippage the agent prices the server's `min_out` against (the tool caps it at 300). */
19
+ export const DEFAULT_REBALANCE_SLIPPAGE_BPS = 100;
20
+ /** `min_out` must clear the independent quote after the requested slippage, minus the same drift a swap tolerates. */
21
+ export function minOutFloor(ownOut, slippageBps) {
22
+ return (ownOut * BigInt(10_000 - slippageBps) * (10000n - QUOTE_DRIFT_BPS)) / 100000000n;
23
+ }
24
+ export async function runOwnerRebalance(deps, input) {
25
+ const slippageBps = input.slippageBps ?? DEFAULT_REBALANCE_SLIPPAGE_BPS;
26
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
27
+ const app = await deps.appWallet();
28
+ if (!app)
29
+ throw new Error('refusing to sign: no app wallet for this owner');
30
+ const legs = [];
31
+ for (let step = 0; step < MAX_REBALANCE_STEPS; step++) {
32
+ if (step > 0)
33
+ await sleep(LEG_COOLDOWN_MS);
34
+ const prepared = await deps.prepare({ kind: 'rebalance', vault: input.vault, ...(input.slippageBps !== undefined ? { slippageBps } : {}) });
35
+ if (prepared.status === 'rebalance_done' || prepared.status === 'nothing_to_do') {
36
+ return { status: 'complete', converged: true, legs, result: prepared.result ?? prepared.reason };
37
+ }
38
+ if (prepared.status === 'executed') {
39
+ legs.push({ signature: String(prepared.signature), via: 'cpi (DFM signer)' });
40
+ await deps.awaitLanded(String(prepared.signature));
41
+ continue;
42
+ }
43
+ if (prepared.status !== 'awaiting_owner_signature')
44
+ throw new Error(`unexpected rebalance step status ${String(prepared.status)}`);
45
+ const r = prepared;
46
+ const payload = checkOwnerRpcRequest(r);
47
+ if (r.appWallet !== app)
48
+ throw new Error('refusing to sign: the request is for a different app wallet than this owner’s');
49
+ const { payer, ixs, legWallet } = await decodeTx(r.body.params.transaction, deps.resolveAlt, { legSigner: true });
50
+ if (!legWallet)
51
+ throw new Error('refusing to sign: a rebalance leg without its leg wallet');
52
+ const facts = verifyRebalanceLegIxs(payer, ixs, { appWallet: app, legWallet, vault: input.vault });
53
+ const ownOut = await deps.ownQuote({
54
+ inputMint: facts.inputMint, outputMint: facts.outputMint, amountRaw: facts.amountIn.toString(), slippageBps,
55
+ });
56
+ const floor = minOutFloor(ownOut, slippageBps);
57
+ if (ownOut <= 0n || facts.minOut < floor) {
58
+ throw new Error(`refusing to sign: the leg's min_out ${facts.minOut} is below the independent quote floor ${floor} (slippage ${slippageBps} bps)`);
59
+ }
60
+ const res = await deps.submit(r.requestId, deps.key.sign(payload));
61
+ if (!res.signature)
62
+ throw new Error('the owner-signed leg was relayed but Privy returned no signature');
63
+ legs.push({
64
+ signature: String(res.signature), via: 'sandwich (owner key)', inputMint: facts.inputMint, outputMint: facts.outputMint,
65
+ amountIn: facts.amountIn.toString(), approvedDelegation: facts.approved,
66
+ });
67
+ await deps.awaitLanded(res.signature);
68
+ }
69
+ return {
70
+ status: 'pending', converged: false, legs,
71
+ note: `stopped after ${MAX_REBALANCE_STEPS} legs; re-run rebalance_to_targets to continue from the live balances`,
72
+ };
73
+ }
74
+ //# sourceMappingURL=owner-rebalance.js.map
@@ -14,6 +14,7 @@ export declare const JUP_ROUTE: string;
14
14
  export declare const JUP_SHARED_ROUTE: string;
15
15
  /** ATA under either token program (the verifier never trusts a server-claimed program). */
16
16
  export declare function ataOf(owner: string, mint: string, program?: string): string;
17
+ export declare const atasOf: (owner: string, mint: string) => string[];
17
18
  export type AltResolver = (key: PublicKey) => Promise<AddressLookupTableAccount | null>;
18
19
  /**
19
20
  * Which account positions of each decoded instruction are STATIC keys (review
@@ -23,10 +24,23 @@ export type AltResolver = (key: PublicKey) => Promise<AddressLookupTableAccount
23
24
  * an instruction can carry the mask along.
24
25
  */
25
26
  export declare const STATIC_MASK: WeakMap<TransactionInstruction, boolean[]>;
26
- export declare function decodeTx(txB64: string, resolveAlt: AltResolver): Promise<{
27
+ /**
28
+ * `legSigner` (rebalance sandwich only): the message may carry exactly ONE more
29
+ * signer, static key #1, whose signature must ALREADY be on the wire and verify
30
+ * over these exact message bytes (the server pre-signs the throwaway leg
31
+ * wallet); the app wallet's slot #0 must still be empty. Everything that
32
+ * signer may do is then checked by the rebalance verifier.
33
+ */
34
+ export declare function decodeTx(txB64: string, resolveAlt: AltResolver, opts?: {
35
+ legSigner?: boolean;
36
+ }): Promise<{
27
37
  payer: string;
28
38
  ixs: TransactionInstruction[];
39
+ legWallet?: string;
29
40
  }>;
41
+ export declare const refuse: (why: string) => never;
42
+ /** A CHECKED account: must exist and be a static key of the message (S24). */
43
+ export declare const key: (ix: TransactionInstruction, i: number) => string;
30
44
  export interface SwapExpect {
31
45
  appWallet: string;
32
46
  inputMint: string;
@@ -29,7 +29,7 @@
29
29
  * UNVERIFIED limits (fail closed): Jupiter `route_v2` / token-ledger variants
30
30
  * and exact-out routes are refused, not decoded.
31
31
  */
32
- import { createHash } from 'node:crypto';
32
+ import { createHash, createPublicKey, verify as verifySig } from 'node:crypto';
33
33
  import { PublicKey, TransactionMessage, VersionedTransaction } from '@solana/web3.js';
34
34
  export const PROTOCOL_WALLET = '9GjEBsZ1Cos2J6inmgwhBKv9pXGNtLSJ7xQ9xZb29bET';
35
35
  export const MAX_SWAP_SOL_FEE_LAMPORTS = 50000000n;
@@ -54,7 +54,7 @@ export const JUP_SHARED_ROUTE = disc('shared_accounts_route');
54
54
  export function ataOf(owner, mint, program = TOKEN) {
55
55
  return PublicKey.findProgramAddressSync([new PublicKey(owner).toBuffer(), new PublicKey(program).toBuffer(), new PublicKey(mint).toBuffer()], new PublicKey(ATA_PROGRAM))[0].toBase58();
56
56
  }
57
- const atasOf = (owner, mint) => [ataOf(owner, mint, TOKEN), ataOf(owner, mint, TOKEN22)];
57
+ export const atasOf = (owner, mint) => [ataOf(owner, mint, TOKEN), ataOf(owner, mint, TOKEN22)];
58
58
  /**
59
59
  * Which account positions of each decoded instruction are STATIC keys (review
60
60
  * S24). A lookup table's contents are served by an RPC the agent does not
@@ -63,9 +63,28 @@ const atasOf = (owner, mint) => [ataOf(owner, mint, TOKEN), ataOf(owner, mint, T
63
63
  * an instruction can carry the mask along.
64
64
  */
65
65
  export const STATIC_MASK = new WeakMap();
66
- export async function decodeTx(txB64, resolveAlt) {
66
+ /**
67
+ * `legSigner` (rebalance sandwich only): the message may carry exactly ONE more
68
+ * signer, static key #1, whose signature must ALREADY be on the wire and verify
69
+ * over these exact message bytes (the server pre-signs the throwaway leg
70
+ * wallet); the app wallet's slot #0 must still be empty. Everything that
71
+ * signer may do is then checked by the rebalance verifier.
72
+ */
73
+ export async function decodeTx(txB64, resolveAlt, opts = {}) {
67
74
  const tx = VersionedTransaction.deserialize(Buffer.from(txB64, 'base64'));
68
- if (tx.message.header.numRequiredSignatures !== 1) {
75
+ const nSig = tx.message.header.numRequiredSignatures;
76
+ let legWallet;
77
+ if (opts.legSigner && nSig === 2) {
78
+ const leg = tx.message.staticAccountKeys[1];
79
+ const sig = tx.signatures[1];
80
+ if (!sig || !ed25519Verifies(leg.toBytes(), tx.message.serialize(), sig)) {
81
+ throw new Error('refusing to sign: the leg wallet signature is missing or does not verify over this transaction');
82
+ }
83
+ if (tx.signatures[0]?.some((b) => b !== 0))
84
+ throw new Error('refusing to sign: the app wallet slot is already signed');
85
+ legWallet = leg.toBase58();
86
+ }
87
+ else if (nSig !== 1) {
69
88
  throw new Error('refusing to sign: the transaction needs a signer other than the app wallet');
70
89
  }
71
90
  const nStatic = tx.message.staticAccountKeys.length;
@@ -82,13 +101,23 @@ export async function decodeTx(txB64, resolveAlt) {
82
101
  throw new Error('refusing to sign: a program loaded from a lookup table');
83
102
  STATIC_MASK.set(msg.instructions[i], c.accountKeyIndexes.map((idx) => idx < nStatic));
84
103
  });
85
- return { payer: msg.payerKey.toBase58(), ixs: msg.instructions };
104
+ return { payer: msg.payerKey.toBase58(), ixs: msg.instructions, ...(legWallet ? { legWallet } : {}) };
105
+ }
106
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
107
+ function ed25519Verifies(pub, message, signature) {
108
+ try {
109
+ const key = createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(pub)]), format: 'der', type: 'spki' });
110
+ return verifySig(null, Buffer.from(message), key, Buffer.from(signature));
111
+ }
112
+ catch {
113
+ return false;
114
+ }
86
115
  }
87
- const refuse = (why) => {
116
+ export const refuse = (why) => {
88
117
  throw new Error(`refusing to sign: ${why}`);
89
118
  };
90
119
  /** A CHECKED account: must exist and be a static key of the message (S24). */
91
- const key = (ix, i) => {
120
+ export const key = (ix, i) => {
92
121
  const mask = STATIC_MASK.get(ix);
93
122
  if (!mask || mask[i] !== true || !ix.keys[i])
94
123
  refuse(`a checked account (#${i} of ${ix.programId.toBase58()}) is not a static key of the transaction`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfm-fi/agent",
3
- "version": "0.2.1964",
3
+ "version": "0.2.1967",
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,7 +55,7 @@
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 && tsx src/default-avatar.test.ts && tsx src/client-header.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/default-avatar.test.ts && tsx src/client-header.test.ts && tsx src/owner-actions.test.ts && tsx src/owner-rebalance.test.ts"
59
59
  },
60
60
  "dependencies": {
61
61
  "@modelcontextprotocol/sdk": "^1.32.1",