Skip to content

Commit 9e45246

Browse files
authored
Merge pull request #529 from szhygulin/feat/460-inv14-durable-binding
feat(security): Invariant #14 — durable-binding source-of-truth verification (#460)
2 parents b2e122a + e06eea3 commit 9e45246

13 files changed

Lines changed: 439 additions & 1 deletion

File tree

src/modules/btc/multisig.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,15 @@ export interface RegisterBitcoinMultisigWalletResult {
255255
appVersion: string;
256256
/** Index of the user's slot in `cosigners` (0-indexed). */
257257
ourKeyIndex: number;
258+
/**
259+
* Invariant #14 — one durable-binding entry per cosigner xpub.
260+
* The skill renders these in the verification block before the
261+
* user confirms the device-side wallet-policy registration; per
262+
* Inv #14 the user re-verifies each xpub against its origin
263+
* device's backup card (b098 attack class — attacker xpub
264+
* embedded as 'co-signer'). Issue #460.
265+
*/
266+
durableBindings: import("../../security/durable-binding.js").DurableBinding[];
258267
}
259268

260269
export async function registerBitcoinMultisigWallet(
@@ -417,7 +426,14 @@ export async function registerBitcoinMultisigWallet(
417426
multisigByName.set(args.name, wallet);
418427
persistMultisig();
419428

420-
result = { wallet, appVersion: appInfo.version, ourKeyIndex };
429+
const { makeDurableBinding } = await import(
430+
"../../security/durable-binding.js"
431+
);
432+
const durableBindings = validatedCosigners.map((c) =>
433+
makeDurableBinding("btc-multisig-cosigner-xpub", c.xpub),
434+
);
435+
436+
result = { wallet, appVersion: appInfo.version, ourKeyIndex, durableBindings };
421437
} finally {
422438
await transport.close().catch(() => {});
423439
}

src/modules/compound/actions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { cometAbi } from "../../abis/compound-comet.js";
33
import { getClient } from "../../data/rpc.js";
44
import { buildApprovalTx, chainApproval, resolveApprovalCap } from "../shared/approval.js";
55
import { resolveTokenMeta } from "../shared/token-meta.js";
6+
import { makeDurableBinding } from "../../security/durable-binding.js";
67
import type {
78
PrepareCompoundSupplyArgs,
89
PrepareCompoundWithdrawArgs,
@@ -91,6 +92,7 @@ export async function buildCompoundSupply(p: PrepareCompoundSupplyArgs): Promise
9192
from: wallet,
9293
description: `Supply ${p.amount} ${meta.symbol} to Compound V3 ${market} on ${chain}`,
9394
decoded: { functionName: "supply", args: { asset, amount: p.amount, market } },
95+
durableBindings: [makeDurableBinding("compound-comet-address", market)],
9496
};
9597
return chainApproval(approval, supplyTx);
9698
}
@@ -115,6 +117,7 @@ export async function buildCompoundWithdraw(p: PrepareCompoundWithdrawArgs): Pro
115117
from: wallet,
116118
description: `Withdraw ${p.amount === "max" ? "all" : p.amount} ${meta.symbol} from Compound V3 ${market} on ${chain}`,
117119
decoded: { functionName: "withdraw", args: { asset, amount: p.amount, market } },
120+
durableBindings: [makeDurableBinding("compound-comet-address", market)],
118121
};
119122
}
120123

@@ -139,6 +142,7 @@ export async function buildCompoundBorrow(p: PrepareCompoundBorrowArgs): Promise
139142
from: wallet,
140143
description: `Borrow ${p.amount} ${meta.symbol} from Compound V3 ${market} on ${chain}`,
141144
decoded: { functionName: "withdraw(base)", args: { asset: baseToken, amount: p.amount, market } },
145+
durableBindings: [makeDurableBinding("compound-comet-address", market)],
142146
};
143147
}
144148

@@ -182,6 +186,7 @@ export async function buildCompoundRepay(p: PrepareCompoundRepayArgs): Promise<U
182186
from: wallet,
183187
description: `Repay ${p.amount === "max" ? "all" : p.amount} ${meta.symbol} on Compound V3 ${market} on ${chain}`,
184188
decoded: { functionName: "supply(base)", args: { asset: baseToken, amount: p.amount, market } },
189+
durableBindings: [makeDurableBinding("compound-comet-address", market)],
185190
};
186191
return chainApproval(approval, repayTx);
187192
}

src/modules/execution/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2663,6 +2663,9 @@ export async function prepareRevokeApproval(
26632663
const spenderDisplay = knownLabel ? `${knownLabel} (${spender})` : spender;
26642664
const currentFormatted = formatUnits(currentAllowance, meta.decimals);
26652665

2666+
const { makeDurableBinding } = await import(
2667+
"../../security/durable-binding.js"
2668+
);
26662669
return enrichTx({
26672670
chain,
26682671
to: token,
@@ -2686,6 +2689,11 @@ export async function prepareRevokeApproval(
26862689
...(knownLabel ? { spenderLabel: knownLabel } : {}),
26872690
},
26882691
},
2692+
// Inv #14 (#460) — the spender selected from the user's allowance
2693+
// set is the durable object the user must re-verify. Complements the
2694+
// existing set-level enumeration check (Inv #13 / #450 which already
2695+
// ensures the user picks a row, not the agent).
2696+
durableBindings: [makeDurableBinding("approval-spender-address", spender)],
26892697
});
26902698
}
26912699

src/modules/lp/uniswap-v3/actions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
mintAmountsWithSlippage,
3535
type PoolState,
3636
} from "./position-math.js";
37+
import { makeDurableBinding } from "../../../security/durable-binding.js";
3738
import type { SupportedChain, UnsignedTx } from "../../../types/index.js";
3839

3940
const SUPPORTED_FEE_TIERS = [100, 500, 3000, 10000] as const;
@@ -487,6 +488,7 @@ export async function buildUniswapIncrease(
487488
deadline: deadline.toString(),
488489
},
489490
},
491+
durableBindings: [makeDurableBinding("uniswap-v3-lp-token-id", p.tokenId)],
490492
};
491493

492494
// 5. Approvals — one per nonzero side; reuse the chain machinery.
@@ -786,6 +788,7 @@ export async function buildUniswapDecrease(
786788
deadline: deadline.toString(),
787789
},
788790
},
791+
durableBindings: [makeDurableBinding("uniswap-v3-lp-token-id", p.tokenId)],
789792
};
790793
}
791794

@@ -859,6 +862,7 @@ export async function buildUniswapCollect(
859862
amount1Max: "uint128.max",
860863
},
861864
},
865+
durableBindings: [makeDurableBinding("uniswap-v3-lp-token-id", p.tokenId)],
862866
};
863867
}
864868

@@ -922,6 +926,7 @@ export async function buildUniswapBurn(
922926
functionName: "burn",
923927
args: { tokenId: p.tokenId },
924928
},
929+
durableBindings: [makeDurableBinding("uniswap-v3-lp-token-id", p.tokenId)],
925930
};
926931
}
927932

@@ -1210,6 +1215,7 @@ export async function buildUniswapRebalance(
12101215
slippageBps: String(slippageBps),
12111216
},
12121217
},
1218+
durableBindings: [makeDurableBinding("uniswap-v3-lp-token-id", p.tokenId)],
12131219
};
12141220

12151221
// Approvals — after collect routes the tokens back to the wallet, the

src/modules/morpho/actions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getClient } from "../../data/rpc.js";
44
import { CONTRACTS } from "../../config/contracts.js";
55
import { buildApprovalTx, chainApproval, resolveApprovalCap } from "../shared/approval.js";
66
import { resolveTokenMeta } from "../shared/token-meta.js";
7+
import { makeDurableBinding } from "../../security/durable-binding.js";
78
import type {
89
PrepareMorphoSupplyArgs,
910
PrepareMorphoWithdrawArgs,
@@ -95,6 +96,7 @@ export async function buildMorphoSupply(p: PrepareMorphoSupplyArgs): Promise<Uns
9596
functionName: "supply",
9697
args: { marketId: p.marketId, amount: p.amount, onBehalf: wallet },
9798
},
99+
durableBindings: [makeDurableBinding("morpho-blue-market-id", p.marketId)],
98100
};
99101
return chainApproval(approval, supplyTx);
100102
}
@@ -129,6 +131,7 @@ export async function buildMorphoWithdraw(p: PrepareMorphoWithdrawArgs): Promise
129131
functionName: "withdraw",
130132
args: { marketId: p.marketId, amount: p.amount, receiver: wallet },
131133
},
134+
durableBindings: [makeDurableBinding("morpho-blue-market-id", p.marketId)],
132135
};
133136
}
134137

@@ -154,6 +157,7 @@ export async function buildMorphoBorrow(p: PrepareMorphoBorrowArgs): Promise<Uns
154157
functionName: "borrow",
155158
args: { marketId: p.marketId, amount: p.amount, receiver: wallet },
156159
},
160+
durableBindings: [makeDurableBinding("morpho-blue-market-id", p.marketId)],
157161
};
158162
}
159163

@@ -252,6 +256,7 @@ export async function buildMorphoRepay(p: PrepareMorphoRepayArgs): Promise<Unsig
252256
functionName: "repay",
253257
args: { marketId: p.marketId, amount: displayAmount, onBehalf: wallet },
254258
},
259+
durableBindings: [makeDurableBinding("morpho-blue-market-id", p.marketId)],
255260
};
256261
return chainApproval(approval, repayTx);
257262
}
@@ -296,6 +301,7 @@ export async function buildMorphoSupplyCollateral(
296301
functionName: "supplyCollateral",
297302
args: { marketId: p.marketId, amount: p.amount, onBehalf: wallet },
298303
},
304+
durableBindings: [makeDurableBinding("morpho-blue-market-id", p.marketId)],
299305
};
300306
return chainApproval(approval, tx);
301307
}
@@ -329,5 +335,6 @@ export async function buildMorphoWithdrawCollateral(
329335
functionName: "withdrawCollateral",
330336
args: { marketId: p.marketId, amount: p.amount, receiver: wallet },
331337
},
338+
durableBindings: [makeDurableBinding("morpho-blue-market-id", p.marketId)],
332339
};
333340
}

src/modules/solana/actions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,15 @@ export interface PreparedSolanaTx {
182182
estimatedFeeLamports?: number;
183183
/** Surfaced on native_send / spl_send / nonce_close so the summary can show "Nonce: <addr>". */
184184
nonceAccount?: string;
185+
/**
186+
* Invariant #14 — durable-binding source-of-truth verification
187+
* (issue #460). Populated by builders for ops that bind funds to a
188+
* durable on-chain object selected from a multi-candidate set
189+
* (validator vote pubkey, MarginFi bank). Absent for ops where the
190+
* recipient/destination IS the durable identifier and Inv #1 already
191+
* covers it.
192+
*/
193+
durableBindings?: import("../../security/durable-binding.js").DurableBinding[];
185194
}
186195

187196
/**

src/modules/solana/marginfi.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,12 @@ export interface PreparedMarginfiTx {
814814
* the real cost to the user BEFORE they blind-sign (issue #103).
815815
*/
816816
rentLamports?: number;
817+
/**
818+
* Invariant #14 — bank pubkey on supply/withdraw/borrow/repay; absent
819+
* on `marginfi_init` (no candidate-set selection — only one MarginfiAccount
820+
* PDA per (wallet, group)). Issue #460.
821+
*/
822+
durableBindings?: import("../../security/durable-binding.js").DurableBinding[];
817823
}
818824

819825
export interface MarginfiInitParams {
@@ -1266,6 +1272,9 @@ async function wrapWithNonce(
12661272
};
12671273

12681274
const { handle } = issueSolanaDraftHandle(draft);
1275+
const { makeDurableBinding } = await import(
1276+
"../../security/durable-binding.js"
1277+
);
12691278
return {
12701279
handle,
12711280
action: actionAction,
@@ -1275,6 +1284,9 @@ async function wrapWithNonce(
12751284
decoded: draft.meta.decoded,
12761285
nonceAccount: nonceAccountStr,
12771286
marginfiAccount: marginfiAccountStr,
1287+
durableBindings: [
1288+
makeDurableBinding("marginfi-bank-pubkey", ctx.bank.address.toBase58()),
1289+
],
12781290
};
12791291
}
12801292

src/modules/solana/native-stake.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export interface PreparedNativeStakeTx {
102102
rentLamports?: number;
103103
/** Stake account address — surfaced on delegate so the user can refer to it later. */
104104
stakeAccount?: string;
105+
/** Invariant #14 — vote pubkey on delegate; absent on deactivate/withdraw (no candidate-set selection). Issue #460. */
106+
durableBindings?: import("../../security/durable-binding.js").DurableBinding[];
105107
}
106108

107109
const LAMPORTS_PER_SOL = 1_000_000_000;
@@ -265,6 +267,9 @@ export async function buildNativeStakeDelegate(
265267
});
266268

267269
const { handle } = issueSolanaDraftHandle(draft);
270+
const { makeDurableBinding } = await import(
271+
"../../security/durable-binding.js"
272+
);
268273
return {
269274
handle,
270275
action: "native_stake_delegate",
@@ -275,6 +280,9 @@ export async function buildNativeStakeDelegate(
275280
nonceAccount: ctx.noncePubkey.toBase58(),
276281
rentLamports,
277282
stakeAccount: stakePubkey.toBase58(),
283+
durableBindings: [
284+
makeDurableBinding("solana-validator-vote-pubkey", validatorPk.toBase58()),
285+
],
278286
};
279287
}
280288

src/modules/tron/actions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,9 @@ export async function buildTronVote(args: BuildTronVoteArgs): Promise<UnsignedTr
729729
});
730730
await assertBandwidthSufficient(args.from, res.raw_data_hex, apiKey);
731731

732+
const { makeDurableBinding } = await import(
733+
"../../security/durable-binding.js"
734+
);
732735
const tx: UnsignedTronTx = {
733736
chain: "tron",
734737
action: "vote",
@@ -745,6 +748,13 @@ export async function buildTronVote(args: BuildTronVoteArgs): Promise<UnsignedTr
745748
allocation: JSON.stringify(args.votes),
746749
},
747750
},
751+
// Inv #14 — one binding per Super Representative the user is voting
752+
// for. Empty `args.votes` (clear-all-votes) intentionally surfaces
753+
// an empty array rather than absent so the skill can distinguish
754+
// "clear" from "tool didn't emit bindings".
755+
durableBindings: args.votes.map((v) =>
756+
makeDurableBinding("tron-super-representative-address", v.address),
757+
),
748758
};
749759
return issueTronHandle(tx);
750760
}

src/security/durable-binding.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Invariant #14 — durable-binding source-of-truth verification (issue
3+
* #460). For any op that binds funds to a durable on-chain object
4+
* selected from a multi-candidate set (validator pubkey, TRON Super
5+
* Representative, Compound Comet, Morpho marketId, MarginFi bank, LP
6+
* tokenId, BTC multisig xpub, allowance spender), the agent MUST
7+
* source the candidate from an authority outside the MCP, surface it
8+
* verbatim with provenance, and byte-equality-check the prepared
9+
* bytes before signing.
10+
*
11+
* The MCP-side contribution to that defense: every prepare_* tool in
12+
* an Inv #14 op class emits a structured `durableBindings: DurableBinding[]`
13+
* field on its response. The skill consumes it as the assertion target —
14+
* unambiguous, no parsing of the human-readable `decoded.args` text.
15+
*
16+
* Tools intentionally NOT covered:
17+
* - Plain native-coin sends — recipient is the durable object, but
18+
* it's already covered by Invariant #1 (recipient cross-check).
19+
* - Token sends — same; the recipient + the token contract are
20+
* covered by Inv #1 + Inv #11 today.
21+
* - Read-only tools — no bytes prepared, no Inv #14 surface.
22+
*/
23+
24+
/**
25+
* Closed enum of the durable-object kinds Invariant #14 covers. Add a
26+
* new kind here only after wiring the corresponding prepare_* tool to
27+
* emit it; the skill's match logic is keyed on these strings.
28+
*/
29+
export type DurableBindingKind =
30+
| "solana-validator-vote-pubkey"
31+
| "tron-super-representative-address"
32+
| "compound-comet-address"
33+
| "morpho-blue-market-id"
34+
| "marginfi-bank-pubkey"
35+
| "uniswap-v3-lp-token-id"
36+
| "btc-multisig-cosigner-xpub"
37+
| "approval-spender-address";
38+
39+
export interface DurableBinding {
40+
/** Stable kind discriminator the skill matches against. */
41+
kind: DurableBindingKind;
42+
/**
43+
* Full identifier verbatim, no truncation. Format depends on the
44+
* kind: base58 for Solana / TRON pubkeys, 0x-prefixed checksum hex
45+
* for EVM addresses, decimal string for tokenIds, raw xpub string
46+
* for BTC multisig cosigners, lowercase hex for Morpho marketIds.
47+
*/
48+
identifier: string;
49+
/**
50+
* Free-form text suggesting where the user should re-verify
51+
* externally. Per Inv #14, the user MUST source the candidate from
52+
* an authority outside the MCP's enumeration; this hint nudges them
53+
* at the right URL / app. Phrased as a recommendation, not a hard
54+
* statement of trust — the agent renders it verbatim in the
55+
* verification block.
56+
*/
57+
provenanceHint: string;
58+
}
59+
60+
/**
61+
* Canonical provenance hints per kind. Centralized so every prepare
62+
* tool emitting a given kind sends the user to the same external
63+
* authority — surface drift between tools would erode the user's
64+
* mental model of "for this kind, I look here".
65+
*/
66+
const PROVENANCE_HINTS: Record<DurableBindingKind, string> = {
67+
"solana-validator-vote-pubkey":
68+
"Re-verify on stakewiz.com or validators.app — confirm commission, delinquent flag, and that the vote pubkey matches the validator the user actually intends to delegate to.",
69+
"tron-super-representative-address":
70+
"Re-verify on tronscan.org/#/sr — confirm SR identity, ranking, and that the base58 address is the validator the user means (brand-name spoof / base58 confusable swap is the b044 attack class).",
71+
"compound-comet-address":
72+
"Re-verify on v3.compound.finance/markets — confirm the Comet address matches the (chain, base-asset) the user actually intends to interact with (wrong-Comet routing on the wrong asset is the b053 attack class).",
73+
"morpho-blue-market-id":
74+
"Re-verify on app.morpho.org/market/{id} — confirm collateral / loan-token / oracle / IRM / LLTV match the market the user means (b055 attack class: permissionless-market injection with adversarial parameters).",
75+
"marginfi-bank-pubkey":
76+
"Re-verify on app.marginfi.com — confirm bank is operational (not paused / killed-by-bankruptcy), oracle setup is healthy, and the asset matches the user's intent (b059 attack class: lookalike-bank injection).",
77+
"uniswap-v3-lp-token-id":
78+
"Re-verify on app.uniswap.org/positions/v3/<chain>/<tokenId> — confirm the position owner is your wallet, not an attacker-injected LP NFT enumerated into your portfolio (b063 attack class).",
79+
"btc-multisig-cosigner-xpub":
80+
"Re-verify each cosigner xpub against the origin device's backup card / set-up record — never trust an xpub passed to this tool through a third-party communication channel (b098 attack class: attacker xpub embedded as 'co-signer').",
81+
"approval-spender-address":
82+
"Re-verify on etherscan.io/address/<spender> — confirm the spender contract identity matches the protocol the user intends to grant allowance to (a086 / b118 attack class: reverse-revoke distraction).",
83+
};
84+
85+
/**
86+
* Build a `DurableBinding` with the canonical provenance hint for the
87+
* given kind. Prepare tools call this rather than constructing the
88+
* object literal so all kind ↔ hint pairings live in one place.
89+
*/
90+
export function makeDurableBinding(
91+
kind: DurableBindingKind,
92+
identifier: string,
93+
): DurableBinding {
94+
const provenanceHint = PROVENANCE_HINTS[kind];
95+
return { kind, identifier, provenanceHint };
96+
}

0 commit comments

Comments
 (0)