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
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
167 changes: 154 additions & 13 deletions src/recipes/borrow/fx/__tests__/fx-mint-open-recipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion src/recipes/borrow/fx/fx-mint-close-recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
79 changes: 69 additions & 10 deletions src/recipes/borrow/fx/fx-mint-open-recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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}`,
);
}
}
2 changes: 1 addition & 1 deletion src/recipes/borrow/fx/fx-mint-topup-and-borrow-recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/recipes/borrow/fx/fx-mint-topup-recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading