diff --git a/src/recipes/borrow/fx/__tests__/fx-mint-close-recipe.test.ts b/src/recipes/borrow/fx/__tests__/fx-mint-close-recipe.test.ts index 0af989f..3b1276c 100644 --- a/src/recipes/borrow/fx/__tests__/fx-mint-close-recipe.test.ts +++ b/src/recipes/borrow/fx/__tests__/fx-mint-close-recipe.test.ts @@ -9,6 +9,7 @@ const { expect } = chai; // matter; everything else is filled with placeholder values that are // shape-correct but not exercised by the constructor-time validation tests. const fakeSwapQuote = { + sellTokenAddress: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', // wstETH (withdraw direction sells collateral) sellTokenValue: '0', spender: '0x000000000000000000000000000000000000beef', crossContractCall: { to: '0x1234', value: 0n, data: '0x5678' }, diff --git a/src/recipes/borrow/fx/__tests__/fx-mint-open-recipe.test.ts b/src/recipes/borrow/fx/__tests__/fx-mint-open-recipe.test.ts index fe372e3..a3213b9 100644 --- a/src/recipes/borrow/fx/__tests__/fx-mint-open-recipe.test.ts +++ b/src/recipes/borrow/fx/__tests__/fx-mint-open-recipe.test.ts @@ -7,6 +7,7 @@ chai.use(chaiAsPromised); const { expect } = chai; const fakeSwapQuote: SwapQuoteData = { + sellTokenAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH (deposit direction) sellTokenValue: '8000000000000000', spender: '0x000000000000000000000000000000000000beef', crossContractCall: { to: '0x1234', value: 0n, data: '0x5678' }, @@ -86,34 +87,174 @@ describe('FxMintOpenRecipe — auto-branch + per-pool validation', () => { }); describe('validatePoolFlow', () => { - it('throws for wstETH-Long without swapQuote', () => { - expect(() => validatePoolFlow('wstETH-Long', undefined)).to.throw( + it('throws for wstETH-Long without swapQuote (deposit)', () => { + expect(() => validatePoolFlow('wstETH-Long', undefined, 'deposit')).to.throw( /wstETH-Long/, ); }); - it('throws for WBTC-Long with swapQuote', () => { - expect(() => validatePoolFlow('WBTC-Long', fakeSwapQuote)).to.throw( - /WBTC-Long/, - ); + it('throws for WBTC-Long with swapQuote (deposit)', () => { + expect(() => + validatePoolFlow('WBTC-Long', fakeSwapQuote, 'deposit'), + ).to.throw(/WBTC-Long/); }); - it('passes for wstETH-Long with swapQuote', () => { - expect(() => validatePoolFlow('wstETH-Long', fakeSwapQuote)).not.to.throw(); + it('passes for wstETH-Long with valid deposit swapQuote', () => { + expect(() => + validatePoolFlow('wstETH-Long', fakeSwapQuote, 'deposit'), + ).not.to.throw(); }); - it('passes for WBTC-Long without swapQuote', () => { - expect(() => validatePoolFlow('WBTC-Long', undefined)).not.to.throw(); + it('passes for WBTC-Long without swapQuote (deposit)', () => { + expect(() => + validatePoolFlow('WBTC-Long', undefined, 'deposit'), + ).not.to.throw(); }); - it('trusts custom pool refs (does not throw)', () => { + it('trusts custom pool refs on presence (does not throw on presence layer)', () => { const customPool = { address: '0x000000000000000000000000000000000000beef' as `0x${string}`, collateralToken: '0x000000000000000000000000000000000000cafe' as `0x${string}`, collateralDecimals: 18n, }; - expect(() => validatePoolFlow(customPool, fakeSwapQuote)).not.to.throw(); - expect(() => validatePoolFlow(customPool, undefined)).not.to.throw(); + // Custom pool + no quote: passes (no shape check applies). + expect(() => + validatePoolFlow(customPool, undefined, 'deposit'), + ).not.to.throw(); + // Custom pool + quote with non-matching buy (cafe ≠ wstETH): shape layer + // would now throw; this is covered explicitly by a shape-check test below. + }); + + // Shape-check tests — direction-aware (added in PR #53 follow-up). + // Canonical mainnet addresses used to construct quotes with valid or + // mismatched directions. WETH is the canonical fxmint swap input across + // every deposit-direction recipe (open/topup/topup-and-borrow); wstETH + // is the canonical wstETH-Long collateral. WRONG_TOKEN and CUSTOM_COLL + // are throwaway placeholder addresses (matches the file's existing + // 0x…beef / 0x…cafe convention for fake addresses in tests). + const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + const WSTETH = '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0'; + const WRONG_TOKEN = '0x000000000000000000000000000000000000fade'; + const CUSTOM_COLL = '0x000000000000000000000000000000000000cafe'; + + // ---- Deposit direction (open/topup/topup-and-borrow) ---- + + it('throws when deposit-direction swapQuote sell token does not match WETH (wstETH-Long)', () => { + const wrongSellQuote = { + ...fakeSwapQuote, + sellTokenAddress: WRONG_TOKEN, // wrong — deposit direction expects WETH + }; + expect(() => + validatePoolFlow('wstETH-Long', wrongSellQuote, 'deposit'), + ).to.throw(/sellTokenAddress mismatch.*deposit.*WETH/i); + }); + + it('throws when deposit-direction swapQuote buy token does not match pool collateralToken (wstETH-Long)', () => { + const wrongBuyQuote = { + ...fakeSwapQuote, + sellTokenAddress: WETH, + buyERC20Amount: { ...fakeSwapQuote.buyERC20Amount, tokenAddress: WRONG_TOKEN }, + }; + expect(() => + validatePoolFlow('wstETH-Long', wrongBuyQuote, 'deposit'), + ).to.throw(/buyERC20Amount\.tokenAddress mismatch.*deposit.*pool collateral/i); + }); + + it('accepts mixed-case (checksum) addresses on the deposit path', () => { + const upperCaseQuote = { + ...fakeSwapQuote, + sellTokenAddress: WETH.toUpperCase(), + buyERC20Amount: { + ...fakeSwapQuote.buyERC20Amount, + tokenAddress: WSTETH.toUpperCase(), + }, + }; + expect(() => + validatePoolFlow('wstETH-Long', upperCaseQuote, 'deposit'), + ).not.to.throw(); + }); + + it('passes for custom pool with correct WETH→collateral deposit quote', () => { + const customPool = { + address: '0x000000000000000000000000000000000000beef' as `0x${string}`, + collateralToken: CUSTOM_COLL as `0x${string}`, + collateralDecimals: 18n, + }; + const correctQuote = { + ...fakeSwapQuote, + sellTokenAddress: WETH, + buyERC20Amount: { ...fakeSwapQuote.buyERC20Amount, tokenAddress: CUSTOM_COLL }, + }; + expect(() => + validatePoolFlow(customPool, correctQuote, 'deposit'), + ).not.to.throw(); + }); + + it('throws for custom pool with wrong-buy deposit quote', () => { + const customPool = { + address: '0x000000000000000000000000000000000000beef' as `0x${string}`, + collateralToken: CUSTOM_COLL as `0x${string}`, + collateralDecimals: 18n, + }; + const wrongBuyQuote = { + ...fakeSwapQuote, + sellTokenAddress: WETH, + buyERC20Amount: { + ...fakeSwapQuote.buyERC20Amount, + tokenAddress: WSTETH, // wstETH ≠ pool.collateralToken (CUSTOM_COLL) + }, + }; + expect(() => + validatePoolFlow(customPool, wrongBuyQuote, 'deposit'), + ).to.throw(/buyERC20Amount\.tokenAddress mismatch.*deposit/i); + }); + + // ---- Withdraw direction (close) ---- + + it('passes for wstETH-Long with correct withdraw-direction quote (wstETH→WETH)', () => { + const withdrawQuote = { + ...fakeSwapQuote, + sellTokenAddress: WSTETH, + buyERC20Amount: { ...fakeSwapQuote.buyERC20Amount, tokenAddress: WETH }, + }; + expect(() => + validatePoolFlow('wstETH-Long', withdrawQuote, 'withdraw'), + ).not.to.throw(); + }); + + it('throws when withdraw-direction quote sells WETH instead of collateral (deposit-shaped)', () => { + // Reusing fakeSwapQuote: sell = WETH, buy = wstETH (deposit shape). + // Run it through withdraw direction — sell side mismatches. + expect(() => + validatePoolFlow('wstETH-Long', fakeSwapQuote, 'withdraw'), + ).to.throw(/sellTokenAddress mismatch.*withdraw.*pool collateral/i); + }); + + it('throws when withdraw-direction quote buys wrong token (not WETH)', () => { + const wrongBuyWithdraw = { + ...fakeSwapQuote, + sellTokenAddress: WSTETH, + buyERC20Amount: { ...fakeSwapQuote.buyERC20Amount, tokenAddress: WRONG_TOKEN }, + }; + expect(() => + validatePoolFlow('wstETH-Long', wrongBuyWithdraw, 'withdraw'), + ).to.throw(/buyERC20Amount\.tokenAddress mismatch.*withdraw.*WETH/i); + }); + + it('passes for custom pool with correct withdraw quote (collateral→WETH)', () => { + const customPool = { + address: '0x000000000000000000000000000000000000beef' as `0x${string}`, + collateralToken: CUSTOM_COLL as `0x${string}`, + collateralDecimals: 18n, + }; + const withdrawQuote = { + ...fakeSwapQuote, + sellTokenAddress: CUSTOM_COLL, + buyERC20Amount: { ...fakeSwapQuote.buyERC20Amount, tokenAddress: WETH }, + }; + expect(() => + validatePoolFlow(customPool, withdrawQuote, 'withdraw'), + ).not.to.throw(); }); }); diff --git a/src/recipes/borrow/fx/__tests__/fx-mint-topup-and-borrow-recipe.test.ts b/src/recipes/borrow/fx/__tests__/fx-mint-topup-and-borrow-recipe.test.ts index cef0126..a157fbe 100644 --- a/src/recipes/borrow/fx/__tests__/fx-mint-topup-and-borrow-recipe.test.ts +++ b/src/recipes/borrow/fx/__tests__/fx-mint-topup-and-borrow-recipe.test.ts @@ -9,6 +9,7 @@ const { expect } = chai; // collDelta on the swap path) matter; everything else is shape-correct // placeholder. Pattern matches fx-mint-topup-recipe.test.ts. const fakeSwapQuote = { + sellTokenAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH (deposit direction) sellTokenValue: '0', spender: '0x000000000000000000000000000000000000beef', crossContractCall: { to: '0x1234', value: 0n, data: '0x5678' }, diff --git a/src/recipes/borrow/fx/__tests__/fx-mint-topup-recipe.test.ts b/src/recipes/borrow/fx/__tests__/fx-mint-topup-recipe.test.ts index 4bcff82..325d715 100644 --- a/src/recipes/borrow/fx/__tests__/fx-mint-topup-recipe.test.ts +++ b/src/recipes/borrow/fx/__tests__/fx-mint-topup-recipe.test.ts @@ -9,6 +9,7 @@ const { expect } = chai; // collDelta on the swap path) matter; everything else is shape-correct // placeholder. Pattern matches fx-mint-close-recipe.test.ts. const fakeSwapQuote = { + sellTokenAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH (deposit direction) sellTokenValue: '0', spender: '0x000000000000000000000000000000000000beef', crossContractCall: { to: '0x1234', value: 0n, data: '0x5678' }, diff --git a/src/recipes/borrow/fx/fx-mint-close-recipe.ts b/src/recipes/borrow/fx/fx-mint-close-recipe.ts index 8dae920..772dfec 100644 --- a/src/recipes/borrow/fx/fx-mint-close-recipe.ts +++ b/src/recipes/borrow/fx/fx-mint-close-recipe.ts @@ -72,7 +72,7 @@ export class FxMintCloseRecipe extends Recipe { constructor(private readonly opts: FxMintCloseRecipeOpts) { super(); - validatePoolFlow(opts.pool, opts.swapQuote); + validatePoolFlow(opts.pool, opts.swapQuote, 'withdraw'); if (opts.swapQuote && opts.slippageBasisPoints === undefined) { throw new Error( 'fxmint: slippageBasisPoints required when swapQuote provided', diff --git a/src/recipes/borrow/fx/fx-mint-open-recipe.ts b/src/recipes/borrow/fx/fx-mint-open-recipe.ts index 0d61adc..02ebdd8 100644 --- a/src/recipes/borrow/fx/fx-mint-open-recipe.ts +++ b/src/recipes/borrow/fx/fx-mint-open-recipe.ts @@ -77,7 +77,7 @@ export class FxMintOpenRecipe extends Recipe { constructor(private readonly opts: FxMintOpenRecipeOpts) { super(); - validatePoolFlow(opts.pool, opts.swapQuote); + validatePoolFlow(opts.pool, opts.swapQuote, 'deposit'); if (opts.swapQuote && opts.slippageBasisPoints === undefined) { throw new Error( 'fxmint: slippageBasisPoints required when swapQuote provided', @@ -139,13 +139,31 @@ export class FxMintOpenRecipe extends Recipe { } } +/** Direction of the swap leg, from the recipe's perspective. */ +export type SwapDirection = 'deposit' | 'withdraw'; + /** * Per-pool flow validation, factored out for reuse by FxMintCloseRecipe, * FxMintTopupRecipe, and FxMintTopupAndBorrowRecipe. * - * v0.1 wstETH-Long REQUIRES swapQuote (WETH → wstETH). - * v0.1 WBTC-Long FORBIDS swapQuote (WBTC direct only). - * Custom pool refs are trusted — caller picks path via swapQuote presence. + * Two layers: + * 1. Presence — named-path requirements (direction-agnostic): + * wstETH-Long REQUIRES swapQuote (WETH ↔ wstETH path). + * WBTC-Long FORBIDS swapQuote (WBTC direct only). + * Custom pool refs are trusted on presence; the caller picks the + * path by whether a swapQuote is provided. + * 2. Shape — runs whenever a swapQuote is provided (named OR custom), + * direction-aware: + * 'deposit' (open/topup/topup-and-borrow): + * sellTokenAddress must equal WETH + * buyERC20Amount.tokenAddress must equal pool.collateralToken + * 'withdraw' (close): + * sellTokenAddress must equal pool.collateralToken + * buyERC20Amount.tokenAddress must equal WETH + * All compares case-insensitive. Failures here would otherwise + * surface as confusing on-chain gas-estimate reverts; the shape + * check turns them into clear `fxmint:` errors at recipe + * construction time. * * Errors are prefixed with `fxmint:` (not the specific recipe name) so * the same helper can be called from open/close/topup/topup-and-borrow @@ -154,18 +172,59 @@ export class FxMintOpenRecipe extends Recipe { export function validatePoolFlow( poolRef: FxMintPoolRef, swapQuote: SwapQuoteData | undefined, + direction: SwapDirection, ): void { - if (typeof poolRef !== 'string') return; // custom pool — trust caller + // 1. Presence check — named-path requirements (unchanged from v0.1). + // Custom pool refs are trusted on presence; the caller picks the + // path by whether a swapQuote is provided. + if (typeof poolRef === 'string') { + const named: FxMintPoolName = poolRef; + if (named === 'wstETH-Long' && !swapQuote) { + throw new Error( + 'fxmint: swapQuote required for wstETH-Long (WETH ↔ wstETH path)', + ); + } + if (named === 'WBTC-Long' && swapQuote) { + throw new Error( + 'fxmint: WBTC-Long uses direct path; swapQuote must be omitted', + ); + } + } + + // 2. Shape check — runs whenever a swapQuote is provided, on BOTH named + // and custom pool refs. Catches the silent gas-estimate failure mode + // where a quote's tokens don't match what the recipe will execute. + if (!swapQuote) return; + + const resolved = resolvePool(poolRef); + const weth = FX_ADDRESSES.WETH.toLowerCase(); + const collateral = resolved.collateralToken.toLowerCase(); + const actualSell = swapQuote.sellTokenAddress.toLowerCase(); + const actualBuy = swapQuote.buyERC20Amount.tokenAddress.toLowerCase(); + + const [expectedSell, expectedBuy, sellLabel, buyLabel] = + direction === 'deposit' + ? [ + weth, + collateral, + `WETH (${FX_ADDRESSES.WETH})`, + `pool collateral (${resolved.collateralToken})`, + ] + : [ + collateral, + weth, + `pool collateral (${resolved.collateralToken})`, + `WETH (${FX_ADDRESSES.WETH})`, + ]; - const named: FxMintPoolName = poolRef; - if (named === 'wstETH-Long' && !swapQuote) { + if (actualSell !== expectedSell) { throw new Error( - 'fxmint: swapQuote required for wstETH-Long (WETH → wstETH path)', + `fxmint: swapQuote.sellTokenAddress mismatch (${direction}) — expected ${sellLabel}, got ${swapQuote.sellTokenAddress}`, ); } - if (named === 'WBTC-Long' && swapQuote) { + if (actualBuy !== expectedBuy) { throw new Error( - 'fxmint: WBTC-Long uses direct path; swapQuote must be omitted', + `fxmint: swapQuote.buyERC20Amount.tokenAddress mismatch (${direction}) — expected ${buyLabel}, got ${swapQuote.buyERC20Amount.tokenAddress}`, ); } } diff --git a/src/recipes/borrow/fx/fx-mint-topup-and-borrow-recipe.ts b/src/recipes/borrow/fx/fx-mint-topup-and-borrow-recipe.ts index 7dffb7d..d54569d 100644 --- a/src/recipes/borrow/fx/fx-mint-topup-and-borrow-recipe.ts +++ b/src/recipes/borrow/fx/fx-mint-topup-and-borrow-recipe.ts @@ -85,7 +85,7 @@ export class FxMintTopupAndBorrowRecipe extends Recipe { super(); // Per-pool flow validation shared with open/close/topup recipes — keeps // the 'fxmint:' error messages consistent across the recipe family. - validatePoolFlow(opts.pool, opts.swapQuote); + validatePoolFlow(opts.pool, opts.swapQuote, 'deposit'); if (opts.swapQuote && opts.slippageBasisPoints === undefined) { throw new Error( 'fxmint: slippageBasisPoints required when swapQuote provided', diff --git a/src/recipes/borrow/fx/fx-mint-topup-recipe.ts b/src/recipes/borrow/fx/fx-mint-topup-recipe.ts index e98c3a2..c8e4371 100644 --- a/src/recipes/borrow/fx/fx-mint-topup-recipe.ts +++ b/src/recipes/borrow/fx/fx-mint-topup-recipe.ts @@ -69,7 +69,7 @@ export class FxMintTopupRecipe extends Recipe { super(); // Per-pool flow validation shared with open/close recipes — keeps the // 'fxmint:' error messages consistent across the recipe family. - validatePoolFlow(opts.pool, opts.swapQuote); + validatePoolFlow(opts.pool, opts.swapQuote, 'deposit'); if (opts.swapQuote && opts.slippageBasisPoints === undefined) { throw new Error( 'fxmint: slippageBasisPoints required when swapQuote provided',