Skip to content
Merged
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
37 changes: 35 additions & 2 deletions src/modules/curve/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,29 @@ export async function buildCurveStethSwap(

if (ethToSteth) return swapTx;

// stETH → ETH: prepend ERC-20 approval of stETH to the pool. stETH
// is a rebasing OpenZeppelin-style token, no USDT quirk, but
// stETH → ETH: prepend ERC-20 approval of stETH to the pool. The pool
// address is NOT in the global approve-allowlist (Aave / Compound /
// Morpho / Lido Queue / EigenLayer / Uniswap NPM+Router / LiFi
// Diamond), so the user must opt in via
// `acknowledgeNonAllowlistedSpender: true` BEFORE the prepare path
// mints a handle. The flag is the affirmative gate; without it, fail
// fast with a clear message so the agent knows to surface the
// trade-off to the user. With it, stamp the approval tx so
// `assertTransactionSafe` skips the spender-allowlist refusal at
// preview/send time.
if (p.acknowledgeNonAllowlistedSpender !== true) {
throw new Error(
`prepare_curve_swap (steth_to_eth) builds an approve to the Curve stETH/ETH pool ` +
`(${pool}), which is NOT in the protocol approve-allowlist (Aave Pool, Compound Comet, ` +
`Morpho Blue, Lido Queue, EigenLayer, Uniswap NPM, Uniswap SwapRouter02, LiFi Diamond). ` +
`The allowlist is a security recommendation: it limits approvals to a small set of ` +
`well-known spenders to keep prompt-injection drains from sliding through. Curve's ` +
`stETH/ETH pool is a 5+ year immutable contract and the historical canonical venue ` +
`for this pair, but it sits outside that curated set. Surface the trade-off to the ` +
`user, then retry with \`acknowledgeNonAllowlistedSpender: true\` to opt in.`,
);
}
// stETH is a rebasing OpenZeppelin-style token, no USDT quirk, but
// buildApprovalTx still handles the reset path defensively.
const meta = await resolveTokenMeta("ethereum", stETH);
const { approvalAmount, display } = resolveApprovalCap(p.approvalCap, dx, meta.decimals);
Expand All @@ -291,5 +312,17 @@ export async function buildCurveStethSwap(
symbol: meta.symbol,
spenderLabel: `Curve stETH/ETH pool ${pool}`,
});
if (approval !== null) {
// Surface the advisory in the description so the prepare receipt
// tells the user (via the agent) that the spender is non-allowlisted
// — security recommendation, not a hard requirement, but worth
// verifying before signing.
approval.description =
`${approval.description} ⚠ ADVISORY: spender is the Curve stETH/ETH pool, NOT in the ` +
`protocol approve-allowlist; user opted in via acknowledgeNonAllowlistedSpender. ` +
`Verify the on-device approve target matches ${pool}.`;
// Flow the affirmative-ack flag through to assertTransactionSafe.
approval.acknowledgedNonAllowlistedSpender = true;
}
return chainApproval(approval, swapTx);
}
13 changes: 13 additions & 0 deletions src/modules/curve/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ export const prepareCurveSwapInput = z.object({
.describe(
"Required when `slippageBps > 100` (1%). Same gate as `prepare_swap` — sandwich-MEV bots target wide-slippage txs.",
),
acknowledgeNonAllowlistedSpender: z
.literal(true)
.optional()
.describe(
"AFFIRMATIVE GATE — required for `direction: \"steth_to_eth\"`. The approve step targets the canonical Curve stETH/ETH pool " +
"(0xDC24316b9AE028F1497c275EB9192a3Ea0f67022), which is NOT in the global protocol approve-allowlist (Aave Pool, Compound " +
"Comet, Morpho Blue, Lido Queue, EigenLayer, Uniswap NPM, Uniswap SwapRouter02, LiFi Diamond). The allowlist is a security " +
"recommendation, not a hard requirement: it limits approvals to a small set of well-known spenders to keep prompt-injection " +
"drains from sliding through. Setting this flag is the user's affirmative ack that they understand the approval target sits " +
"outside that curated set — the on-device clear-sign of `approve(<curve-pool>, <amount>)` and the prepare-receipt warning " +
"advisory are the verification anchors. Do NOT default this to true silently; surface the trade-off to the user first. " +
"Ignored for `direction: \"eth_to_steth\"` (no approval; native ETH is sent as msg.value).",
),
approvalCap: approvalCapSchema.optional(),
});
export type PrepareCurveSwapArgs = z.infer<typeof prepareCurveSwapInput>;
15 changes: 14 additions & 1 deletion src/signing/pre-sign-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,26 @@ export async function assertTransactionSafe(tx: UnsignedTx): Promise<void> {
if (amount === 0n) return;
const allowlist = buildSpenderAllowlist(tx.chain);
if (!allowlist.has(spender)) {
// Per-prepare-tool affirmative-ack escape hatch. A prepare_* tool
// that legitimately approves a non-allowlisted spender (e.g.
// `prepare_curve_swap` → Curve stETH/ETH pool) takes the user's
// schema-enforced `acknowledgeNonAllowlistedSpender: true` and
// stamps this flag on the tx. The flag flows through the
// server-minted handle, so the agent cannot fabricate it on a tx
// that didn't come through such a path. Without the ack the
// refusal still fires — the allowlist is the default; the ack is
// the explicit opt-out.
if (tx.acknowledgedNonAllowlistedSpender === true) return;
throw new Error(
`Pre-sign check: refusing approve(spender=${spender}, ...) on ${tx.chain} — spender is ` +
`not in the protocol allowlist (Aave Pool, Compound Comet, Morpho Blue, Lido Queue, ` +
`EigenLayer, Uniswap NPM, Uniswap SwapRouter02, LiFi Diamond). This is the canonical phishing/prompt-injection ` +
`pattern. If you need to approve a different spender, do it from the Ledger Live app directly. ` +
`(Revokes — approve(spender, 0) — bypass this check; if you want to revoke an existing ` +
`allowance, run prepare_revoke_approval instead of crafting your own approve.)`
`allowance, run prepare_revoke_approval instead of crafting your own approve. ` +
`A prepare_* tool may also accept an explicit per-tool ` +
`\`acknowledgeNonAllowlistedSpender: true\` to opt out of this default after surfacing ` +
`the trade-off to the user.)`
);
}
return;
Expand Down
23 changes: 23 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,29 @@ export interface UnsignedTx {
* bug, not a user-controllable lever.
*/
safeTxOrigin?: boolean;
/**
* Set when the tx was built by a prepare_* tool that emits an
* approve(spender, amount) where the spender is NOT in the canonical
* protocol allowlist (Aave Pool, Compound Comet, Morpho Blue, Lido
* Queue, EigenLayer, Uniswap NPM, Uniswap SwapRouter02, LiFi Diamond),
* AND the user passed the affirmative `acknowledgeNonAllowlistedSpender:
* true` schema-enforced gate at prepare time. Read by
* `assertTransactionSafe` to skip ONLY the approve-spender-allowlist
* refusal — every other pre-sign defense (chainId, simulation, payload
* hash, ABI-selector check, transfer-on-unknown-token) stays active.
*
* Use case: tools like `prepare_curve_swap` that target a deep-liquidity
* venue whose spender is well-known but isn't in the curated approve
* allowlist. The allowlist remains a security recommendation: a warning
* advisory is surfaced in the prepare receipt so the agent can relay
* the trade-off to the user before they opt in.
*
* Trust note: like `acknowledgedNonProtocolTarget` and `safeTxOrigin`,
* this flag flows through the handle store keyed by the server-minted
* UUID. The agent cannot fabricate it on a tx that didn't come through
* a prepare path that explicitly accepted the schema gate.
*/
acknowledgedNonAllowlistedSpender?: boolean;
/**
* Invariant #14 — durable-binding source-of-truth verification (issue
* #460). When the tx binds funds to a durable on-chain object selected
Expand Down
44 changes: 44 additions & 0 deletions test/curve-v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,11 +391,17 @@ describe("buildCurveStethSwap (issue #615)", () => {
direction: "steth_to_eth",
amount: "1.0",
slippageBps: 50,
acknowledgeNonAllowlistedSpender: true,
});

// Head should be the approval; action sits at the tail.
expect(tx.to).toBe(STETH_TOKEN);
expect(tx.decoded?.functionName).toBe("approve");
// The approval head must carry the affirmative-ack flag so
// assertTransactionSafe accepts the non-allowlisted spender at
// preview/send time.
expect(tx.acknowledgedNonAllowlistedSpender).toBe(true);
expect(tx.description).toMatch(/ADVISORY.*Curve stETH\/ETH pool.*allowlist/i);
type WithNext = typeof tx & { next?: typeof tx };
let cur: typeof tx = tx;
while ((cur as WithNext).next !== undefined) cur = (cur as WithNext).next!;
Expand All @@ -406,6 +412,44 @@ describe("buildCurveStethSwap (issue #615)", () => {
expect(cur.decoded?.args.j).toBe("0");
});

it("steth_to_eth: refuses without acknowledgeNonAllowlistedSpender", async () => {
const client = poolClient({ getDy: 10n ** 18n, allowance: 0n });
vi.doMock("../src/data/rpc.js", () => ({ getClient: () => client }));
vi.doMock("../src/modules/shared/token-meta.js", () => ({
resolveTokenMeta: async () => ({ symbol: "stETH", decimals: 18 }),
}));

const { buildCurveStethSwap } = await import("../src/modules/curve/actions.js");
await expect(
buildCurveStethSwap({
wallet: WALLET,
direction: "steth_to_eth",
amount: "1.0",
slippageBps: 50,
}),
).rejects.toThrow(/acknowledgeNonAllowlistedSpender|approve-allowlist|recommendation/i);
});

it("eth_to_steth: ignores acknowledgeNonAllowlistedSpender (no approval is built)", async () => {
const client = poolClient({ getDy: 10n ** 18n });
vi.doMock("../src/data/rpc.js", () => ({ getClient: () => client }));
vi.doMock("../src/modules/shared/token-meta.js", () => ({
resolveTokenMeta: async () => ({ symbol: "stETH", decimals: 18 }),
}));

const { buildCurveStethSwap } = await import("../src/modules/curve/actions.js");
const tx = await buildCurveStethSwap({
wallet: WALLET,
direction: "eth_to_steth",
amount: "1.0",
slippageBps: 50,
// No ack — eth_to_steth path does not build an approval, so the
// gate is irrelevant.
});
expect(tx.acknowledgedNonAllowlistedSpender).toBeUndefined();
expect(tx.next).toBeUndefined();
});

it("requires slippage gate — refuses when neither slippageBps nor minOut is set", async () => {
const client = poolClient();
vi.doMock("../src/data/rpc.js", () => ({ getClient: () => client }));
Expand Down
56 changes: 56 additions & 0 deletions test/pre-sign-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const USDT_ETH = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const ATTACKER = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
const WALLET = "0x1111111111111111111111111111111111111111";
const STETH_TOKEN_ETH = "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84";

beforeEach(() => {
readContractMock.mockReset();
Expand Down Expand Up @@ -165,6 +166,61 @@ describe("Pre-sign check: approve() spender allowlist", () => {
).rejects.toThrow(/token is not in our recognized set/);
});

it("ACCEPTS approve(non-allowlisted-spender, amount) when acknowledgedNonAllowlistedSpender flag is stamped", async () => {
// prepare_curve_swap (and future tools targeting deep-liquidity
// venues outside the curated approve-allowlist) takes the user's
// schema-enforced `acknowledgeNonAllowlistedSpender: true` at
// prepare time and stamps the flag on the approval tx. The
// pre-sign check skips the spender-allowlist refusal when the flag
// is set, treating the allowlist as a security recommendation
// rather than a hard requirement. Every other defense
// (transfer-on-unknown-token, ABI-selector check, payload-hash
// pin, simulation, chainId) stays active.
const { assertTransactionSafe } = await import("../src/signing/pre-sign-check.js");
const CURVE_STETH_POOL = "0xDC24316b9AE028F1497c275EB9192a3Ea0f67022";
const data = encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [CURVE_STETH_POOL as `0x${string}`, 1_000_000_000n],
});
await expect(
assertTransactionSafe({
chain: "ethereum",
to: STETH_TOKEN_ETH as `0x${string}`,
data,
value: "0",
from: WALLET,
description: "Approve stETH for Curve stETH/ETH pool (acked)",
acknowledgedNonAllowlistedSpender: true,
})
).resolves.toBeUndefined();
});

it("REJECTS approve(non-allowlisted-spender, amount) WITHOUT the ack flag — default refusal still fires", async () => {
// The ack flag is the explicit opt-out; absent the flag, the
// refusal still fires verbatim. This is what protects the
// canonical prompt-injection pattern (a compromised agent that
// doesn't know to fabricate the flag, or a non-Curve prepare path
// that would have built a drain approval).
const { assertTransactionSafe } = await import("../src/signing/pre-sign-check.js");
const CURVE_STETH_POOL = "0xDC24316b9AE028F1497c275EB9192a3Ea0f67022";
const data = encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [CURVE_STETH_POOL as `0x${string}`, 1_000_000_000n],
});
await expect(
assertTransactionSafe({
chain: "ethereum",
to: STETH_TOKEN_ETH as `0x${string}`,
data,
value: "0",
from: WALLET,
description: "Approve stETH for Curve stETH/ETH pool (NOT acked)",
})
).rejects.toThrow(/spender is not in the protocol allowlist/);
});

it("ACCEPTS approve(non-allowlisted-spender, 0) — the revoke pattern (issue #305)", async () => {
// prepare_revoke_approval emits approve(spender, 0) targeting whatever
// spender the user wants to revoke — Permit2, dead routers, deprecated
Expand Down
Loading