Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ Execution (Ledger-signed):
- `prepare_tron_native_send`, `prepare_tron_token_send`, `prepare_tron_claim_rewards`, `prepare_tron_freeze`, `prepare_tron_unfreeze`, `prepare_tron_withdraw_expire_unfreeze`, `prepare_tron_vote` — TRON tx builders (native TRX send, canonical TRC-20 transfer, WithdrawBalance claim, Stake 2.0 freeze/unfreeze/withdraw-expire-unfreeze, VoteWitness)
- `send_transaction` — forwards a prepared tx for user approval. EVM handles go to Ledger Live via WalletConnect; TRON handles go to the USB-connected Ledger via `@ledgerhq/hw-app-trx` and are broadcast via TronGrid

## Trust model

VaultPilot never holds a private key — every state-changing transaction is prepared here and signed on your Ledger. The remaining question at signing time is: *can your Ledger show you, on its own screen, what you're about to approve?* That's what the trust classifier on every prepared tx answers.

Each prepared transaction carries a `trustMode`:

- **`clear-signable`** — the Ledger hardware app decodes the call on-device ("Supply 1 USDC to Aave V3"). The device is the final trust anchor; neither the agent nor VaultPilot can tamper with what appears on its screen. Approve as usual.
- **`blind-sign`** — the Ledger shows raw calldata hex, but the destination contract is public and its ABI decodes via [swiss-knife.xyz](https://calldata.swiss-knife.xyz/decoder). The prepared tx ships with a `decoderUrl` pointing at swiss-knife with the calldata preloaded. Open it, confirm the decoded function + arguments match the preview VaultPilot showed you, then approve. The `payloadHashShort` fingerprint ties the decoder result to the exact bytes your Ledger will sign — if it matches at prepare time *and* at send time, nothing in between has been swapped.
- **`blind-sign-unavoidable`** — unknown contract, exotic selector, or cross-chain bridge. Even swiss-knife may not decode it. Cross-chain bridges land here structurally: you cannot verify destination-chain execution locally at sign time. **Strongly consider rejecting** if you can't independently verify the call on the decoder.

Coverage today: native sends, ERC-20 transfer/approve, Aave V3 supply/withdraw/borrow/repay, Compound V3 supply/withdraw, Morpho Blue supply/withdraw/borrow/repay/supplyCollateral/withdrawCollateral, Lido `submit`/`requestWithdrawals`/`claimWithdrawal`, EigenLayer `depositIntoStrategy`, and Uniswap V3 SwapRouter02 (`exactInputSingle`/`exactInput`/`multicall`) are all clear-signable. LiFi aggregator calls are blind-sign (same-chain) or blind-sign-unavoidable (cross-chain).

**Swap routing.** `prepare_swap` tries a direct Uniswap V3 route in parallel with LiFi. If the direct route's minOut is within **1.0%** (Ethereum) or **0.5%** (Arbitrum/Polygon/Base) of LiFi's, VaultPilot prefers direct so the user gets clear-signing on-device. Otherwise it falls back to LiFi and surfaces the quote gap via `rejectedAlternative` so the agent can explain why.

**Ledger app caveat.** Classification assumes you have the right Ledger app loaded — **Ethereum** for EVM chains, **Tron** for TRON. A wrong-app condition surfaces as a connection-layer failure (the device refuses to sign), not as an incorrect trust classification.

## Requirements

- Node.js >= 18.17
Expand Down
54 changes: 54 additions & 0 deletions src/abis/uniswap-quoter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Uniswap V3 QuoterV2 — read-only quoting. We call these via eth_call (not
* sign); the router's actual swap calldata is priced against these quotes.
*
* `quoteExactInputSingle` takes a params struct (tokenIn/tokenOut/fee/etc.)
* and returns (amountOut, sqrtPriceX96After, initializedTicksCrossed,
* gasEstimate). `quoteExactInput` takes a packed path bytes and an amountIn
* and returns amountOut plus the same per-hop diagnostics.
*
* QuoterV2 is not `view` by declaration — it reverts-to-return-values
* internally to skirt solidity limitations on deep stack returns. viem's
* `readContract` handles that transparently.
*/
export const uniswapQuoterAbi = [
{
type: "function",
name: "quoteExactInputSingle",
stateMutability: "nonpayable",
inputs: [
{
name: "params",
type: "tuple",
components: [
{ name: "tokenIn", type: "address" },
{ name: "tokenOut", type: "address" },
{ name: "amountIn", type: "uint256" },
{ name: "fee", type: "uint24" },
{ name: "sqrtPriceLimitX96", type: "uint160" },
],
},
],
outputs: [
{ name: "amountOut", type: "uint256" },
{ name: "sqrtPriceX96After", type: "uint160" },
{ name: "initializedTicksCrossed", type: "uint32" },
{ name: "gasEstimate", type: "uint256" },
],
},
{
type: "function",
name: "quoteExactInput",
stateMutability: "nonpayable",
inputs: [
{ name: "path", type: "bytes" },
{ name: "amountIn", type: "uint256" },
],
outputs: [
{ name: "amountOut", type: "uint256" },
{ name: "sqrtPriceX96AfterList", type: "uint160[]" },
{ name: "initializedTicksCrossedList", type: "uint32[]" },
{ name: "gasEstimate", type: "uint256" },
],
},
] as const;
92 changes: 92 additions & 0 deletions src/abis/uniswap-swap-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Uniswap V3 SwapRouter02 — the subset of functions our direct-swap builder
* emits plus the subset we want the pre-sign check to recognize.
*
* `exactInputSingle` / `exactInput` are the two routing entrypoints (single-hop
* vs multi-hop). `multicall(bytes[])` is the wrapping/unwrapping envelope used
* when a swap touches native ETH (the router wraps via WETH9 internally as the
* first step of the multicall). `unwrapWETH9` / `sweepToken` are the trailing
* steps the router uses inside multicall when the output side is native.
*
* This ABI is intentionally narrow: it names exactly the selectors we expect
* to route, so a malicious destination that happens to land on the SwapRouter
* address with some other selector (e.g. the legacy `exactOutputSingle`,
* which our builder never emits) is rejected by the pre-sign check's
* "selector must exist on ABI" rule.
*/
export const uniswapSwapRouterAbi = [
{
type: "function",
name: "exactInputSingle",
stateMutability: "payable",
inputs: [
{
name: "params",
type: "tuple",
components: [
{ name: "tokenIn", type: "address" },
{ name: "tokenOut", type: "address" },
{ name: "fee", type: "uint24" },
{ name: "recipient", type: "address" },
{ name: "amountIn", type: "uint256" },
{ name: "amountOutMinimum", type: "uint256" },
{ name: "sqrtPriceLimitX96", type: "uint160" },
],
},
],
outputs: [{ name: "amountOut", type: "uint256" }],
},
{
type: "function",
name: "exactInput",
stateMutability: "payable",
inputs: [
{
name: "params",
type: "tuple",
components: [
{ name: "path", type: "bytes" },
{ name: "recipient", type: "address" },
{ name: "amountIn", type: "uint256" },
{ name: "amountOutMinimum", type: "uint256" },
],
},
],
outputs: [{ name: "amountOut", type: "uint256" }],
},
{
type: "function",
name: "multicall",
stateMutability: "payable",
inputs: [{ name: "data", type: "bytes[]" }],
outputs: [{ name: "results", type: "bytes[]" }],
},
{
type: "function",
name: "unwrapWETH9",
stateMutability: "payable",
inputs: [
{ name: "amountMinimum", type: "uint256" },
{ name: "recipient", type: "address" },
],
outputs: [],
},
{
type: "function",
name: "sweepToken",
stateMutability: "payable",
inputs: [
{ name: "token", type: "address" },
{ name: "amountMinimum", type: "uint256" },
{ name: "recipient", type: "address" },
],
outputs: [],
},
{
type: "function",
name: "refundETH",
stateMutability: "payable",
inputs: [],
outputs: [],
},
] as const;
26 changes: 26 additions & 0 deletions src/config/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export const CONTRACTS = {
uniswap: {
positionManager: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
// SwapRouter02 — the routing entrypoint our direct-V3 swap builder
// targets. Its `exactInputSingle` / `exactInput` / `multicall` selectors
// are Ledger clear-sign covered (see pre-sign-check.ts), so same-chain
// swaps that pick this path get hardware-verified on-device.
swapRouter02: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45",
// QuoterV2 — read-only quoting with tick-math baked in. Used to price
// direct-V3 routes before committing to them.
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
// WETH9 — canonical wrapper used by SwapRouter02's wrapETH/unwrapWETH9
// steps when the user is swapping to/from native ETH.
weth9: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
},
lido: {
stETH: "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84",
Expand Down Expand Up @@ -62,6 +73,9 @@ export const CONTRACTS = {
uniswap: {
positionManager: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
swapRouter02: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45",
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
weth9: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
},
lido: {
wstETH: "0x5979D7b546E38E414F7E9822514be443A4800529",
Expand Down Expand Up @@ -94,6 +108,12 @@ export const CONTRACTS = {
uniswap: {
positionManager: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
swapRouter02: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45",
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
// On Polygon the native wrapper is WMATIC — SwapRouter02's multicall
// wraps/unwraps the chain's native asset, so this is where "native in/out"
// routes through.
weth9: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270",
},
// Lido has no native deployment on Polygon (stMATIC is a separate protocol
// from a different team); we intentionally omit the `lido` entry so the
Expand Down Expand Up @@ -127,6 +147,12 @@ export const CONTRACTS = {
// cross-chain address; PositionManager too.
positionManager: "0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1",
factory: "0x33128a8fC17869897dcE68Ed026d694621f6FDfD",
// Base uses a different SwapRouter02 and QuoterV2 than the other chains —
// Base's Uniswap V3 was deployed after the cross-chain deterministic
// rollout, so these are chain-specific addresses (see Uniswap deployments).
swapRouter02: "0x2626664c2603336E57B271c5C0b26F421741e481",
quoterV2: "0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a",
weth9: "0x4200000000000000000000000000000000000006",
},
// Lido and EigenLayer are L1-only — no `lido`/`eigenlayer` keys means the
// staking reader short-circuits for Base, matching how Polygon is handled.
Expand Down
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,23 @@ async function main() {
"their real Ledger Live install. The Ledger device's on-screen confirmation is the",
"ultimate authority — tell the user to verify the recipient, amount, and chain on",
"the device, not just in chat.",
"",
"TRUST MODES: every `prepare_*` tool returns `trustMode` + `trustDetails` on the",
"unsigned tx. These tell the user whether the Ledger hardware will decode the call",
"on-device or only show raw calldata. Surface this to the user BEFORE they approve:",
"- `clear-signable`: say \"✓ Hardware-verified on Ledger via {ledgerPlugin}: {description}\"",
" and invite them to approve on the device.",
"- `blind-sign`: say \"⚠ Blind-sign: the Ledger will show raw calldata. Verify the",
" decoded call at {decoderUrl} BEFORE pressing approve. Fingerprint: {payloadHashShort}\"",
" (or, when `decoderPasteInstructions` is set instead of `decoderUrl`, relay those",
" paste instructions verbatim).",
"- `blind-sign-unavoidable`: say \"⚠ CAUTION: this transaction cannot be decoded on your",
" Ledger and may not be decodable by external tools either (unrecognized contract or",
" cross-chain bridge). Fingerprint: {payloadHashShort}. If {decoderUrl} does not show a",
" clear function call, STRONGLY consider REJECTING on the device.\"",
"After calling `send_transaction`, the response echoes the same `payloadHashShort` —",
"state it back to the user so they can confirm \"this is the tx I verified\" before the",
"Ledger prompt appears.",
].join("\n"),
}
);
Expand Down
19 changes: 18 additions & 1 deletion src/modules/execution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ export async function prepareTokenSend(args: PrepareTokenSendArgs): Promise<Unsi
async function sendTronTransaction(args: SendTransactionArgs): Promise<{
txHash: string;
chain: "tron";
trustMode?: string;
payloadHashShort?: string;
}> {
const tx: UnsignedTronTx = consumeTronHandle(args.handle);
// If the user paired this `from` via `pair_ledger_tron`, use the path they
Expand All @@ -365,7 +367,14 @@ async function sendTronTransaction(args: SendTransactionArgs): Promise<{
// TronGrid error), the handle stays valid and the caller can retry
// within the 15-min TTL without re-preparing.
retireTronHandle(args.handle);
return { txHash: txID, chain: "tron" };
return {
txHash: txID,
chain: "tron",
...(tx.trustMode ? { trustMode: tx.trustMode } : {}),
...(tx.trustDetails?.payloadHashShort
? { payloadHashShort: tx.trustDetails.payloadHashShort }
: {}),
};
}

/**
Expand All @@ -382,6 +391,10 @@ export async function sendTransaction(args: SendTransactionArgs): Promise<{
txHash: `0x${string}` | string;
chain: SupportedChain | "tron";
nextHandle?: string;
/** Trust classification echoed back so the user can cross-check with what they approved at prepare time. */
trustMode?: string;
/** First 8 hex chars of the payload fingerprint — state this back to the user before they approve on device. */
payloadHashShort?: string;
}> {
if (hasTronHandle(args.handle)) {
return sendTronTransaction(args);
Expand Down Expand Up @@ -440,6 +453,10 @@ export async function sendTransaction(args: SendTransactionArgs): Promise<{
txHash: hash,
chain: tx.chain,
...(tx.next?.handle ? { nextHandle: tx.next.handle } : {}),
...(tx.trustMode ? { trustMode: tx.trustMode } : {}),
...(tx.trustDetails?.payloadHashShort
? { payloadHashShort: tx.trustDetails.payloadHashShort }
: {}),
};
}

Expand Down
Loading
Loading