diff --git a/.gitignore b/.gitignore index 95da0a4..2924e1f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ src/test/test-config-overrides.test.ts # Quickstart assets test.db artifacts-* + +# Local brainstorming artifacts — design docs and implementation plans +# generated during interactive sessions, kept on disk but never committed +# (so they don't end up in the upstream PR diff). +docs/superpowers/ diff --git a/src/api/borrow/fx/__tests__/fx-position-reader.test.ts b/src/api/borrow/fx/__tests__/fx-position-reader.test.ts new file mode 100644 index 0000000..355d969 --- /dev/null +++ b/src/api/borrow/fx/__tests__/fx-position-reader.test.ts @@ -0,0 +1,101 @@ +import chai from 'chai'; +import { JsonRpcProvider } from 'ethers'; +import { getFxPool, getFxPosition } from '../fx-position-reader'; + +const { expect } = chai; + +describe('fx-position-reader — getFxPool [requires RUN_FORK_TESTS=1]', function () { + this.timeout(30_000); + + if (!process.env.RUN_FORK_TESTS) { + it.skip('skipped — set RUN_FORK_TESTS=1 to run', () => {}); + return; + } + + // Use a public RPC if MAINNET_RPC_URL isn't set — publicnode worked + // in Task 1's discovery. Fork tests are read-only so any reliable + // public RPC works. + const rpcUrl = + process.env.MAINNET_RPC_URL ?? 'https://ethereum-rpc.publicnode.com'; + const provider = new JsonRpcProvider(rpcUrl); + + it('reads wstETH-Long pool state', async () => { + const pool = await getFxPool('wstETH-Long', provider); + expect(pool.name).to.equal('wstETH-Long'); + expect(pool.collateralDecimals).to.equal(18n); + // Chai's greaterThan doesn't accept bigint; compare-then-assert. + expect(pool.debtCapacity > 0n).to.equal(true); + // Mainnet expectations from Task 1 discovery (May 2026): + expect(pool.borrowFeeRatio).to.equal(5_000_000n); + expect(pool.repayFeeRatio).to.equal(2_000_000n); + expect(pool.liquidationDebtRatio).to.equal(950_000_000_000_000_000n); // 0.95e18 + expect(pool.liquidationBonusRatio).to.equal(40_000_000n); // 4e7 + }); + + it('reads WBTC-Long pool state', async () => { + const pool = await getFxPool('WBTC-Long', provider); + expect(pool.name).to.equal('WBTC-Long'); + expect(pool.collateralDecimals).to.equal(8n); + expect(pool.debtCapacity > 0n).to.equal(true); + expect(pool.liquidationDebtRatio).to.equal(950_000_000_000_000_000n); + }); + + it('returns name=undefined for custom pool refs', async () => { + const pool = await getFxPool( + { + address: '0x6Ecfa38FeE8a5277B91eFdA204c235814F0122E8', // wstETH-Long + collateralToken: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + collateralDecimals: 18n, + }, + provider, + ); + expect(pool.name).to.equal(undefined); + expect(pool.address.toLowerCase()).to.equal( + '0x6Ecfa38FeE8a5277B91eFdA204c235814F0122E8'.toLowerCase(), + ); + }); +}); + +describe('fx-position-reader — getFxPosition [requires RUN_FORK_TESTS=1]', function () { + this.timeout(30_000); + + if (!process.env.RUN_FORK_TESTS) { + it.skip('skipped — set RUN_FORK_TESTS=1 to run', () => {}); + return; + } + + const rpcUrl = + process.env.MAINNET_RPC_URL ?? 'https://ethereum-rpc.publicnode.com'; + const provider = new JsonRpcProvider(rpcUrl); + + it('reads a known wstETH-Long position', async () => { + // positionId 1903 is the v0 calibration mint (tx 0x3f7c7c42... from + // README's calibration table). Verified on mainnet via + // `cast call 'getPosition(uint256)' 1903`: rawColls ≈ 5.86e13, + // rawDebts ≈ 3.58e16 — both > 0, so we can assert actual values + // rather than just shape. If this position is ever closed/burned the + // assertions degrade gracefully (call returns zeros, the > 0n + // assertions fail informatively). + const knownPositionId = 1903n; + + const position = await getFxPosition( + knownPositionId, + 'wstETH-Long', + provider, + ); + expect(position.positionId).to.equal(knownPositionId); + expect(position.collateralDecimals).to.equal(18n); + expect(position.pool.address.toLowerCase()).to.equal( + '0x6Ecfa38FeE8a5277B91eFdA204c235814F0122E8'.toLowerCase(), + ); + // Real value assertions — position 1903 was minted with nonzero + // collateral and debt and (as of Task 13) hasn't been closed. + // chai 4.x's .greaterThan typings don't accept bigint; compare-then-assert. + expect(position.rawColls > 0n).to.equal(true); + expect(position.rawDebts > 0n).to.equal(true); + expect(position.debt > 0n).to.equal(true); + expect(position.debt).to.equal(position.rawDebts); // debt is an alias for rawDebts + expect(position.debtRatio > 0n).to.equal(true); + expect(position.collateralAmount > 0n).to.equal(true); + }); +}); diff --git a/src/api/borrow/fx/fx-position-reader.ts b/src/api/borrow/fx/fx-position-reader.ts new file mode 100644 index 0000000..d9422b3 --- /dev/null +++ b/src/api/borrow/fx/fx-position-reader.ts @@ -0,0 +1,325 @@ +import { Contract, type Provider } from 'ethers'; +import { + FX_ADDRESSES, + FX_POOL_ABI, + FX_POOL_MANAGER_ABI, + FX_POOL_CONFIGURATION_ABI, + DEFAULT_FXMINT_OPERATOR, + resolvePool, + type Address, + type FxMintPoolRef, + type FxMintPoolName, +} from '../../../steps/borrow/fx/fx-mint-util'; + +// DEFAULT_FXMINT_OPERATOR moved to fx-mint-util.ts (alongside FX_ADDRESSES) +// as the single source of truth — package consumers continue to import it +// from `@railgun-community/cookbook` unchanged, since fx-mint-util is also +// re-exported at the package root. + +// ============================================================================= +// FxPool — pool-level state + per-(pool, operator) fee ratios. +// ============================================================================= + +export type FxPool = { + /** Pool name when known (e.g., 'wstETH-Long'); undefined for custom pools. */ + name?: FxMintPoolName; + /** Pool contract address (also the position-NFT contract). */ + address: Address; + collateralToken: Address; + collateralDecimals: bigint; + + // Per-pool capacity / state from PoolManager.getPoolInfo: + debtCapacity: bigint; + debtBalance: bigint; + // Pool.getTotalRawCollaterals(): + totalRawColls: bigint; + // PoolManager.getPoolInfo[1] — actual collateral balance in native units. + collateralBalance: bigint; + + /** + * Liquidation threshold in 1e18-scaled debt-ratio form. Read via + * Pool.getLiquidateRatios()[0]. A position is liquidatable when its + * getPositionDebtRatio() >= this value. Mainnet (May 2026): 0.95e18 + * (95%) on both wstETH-Long and WBTC-Long. + */ + liquidationDebtRatio: bigint; + + /** + * Liquidator bonus in 1e9-scaled form. Pool.getLiquidateRatios()[1]. + * The fraction of seized collateral the liquidator keeps as incentive. + * Mainnet: 4e7 (4%) on both pools. + */ + liquidationBonusRatio: bigint; + + /** + * Rebalance threshold in 1e18-scaled debt-ratio form. Read via + * Pool.getRebalanceRatios()[0]. Sits BELOW the liquidation threshold: + * when a position crosses this ratio, f(x)'s rebalancer service + * progressively unwinds collateral to keep the position from ever + * reaching the liquidation ratio (and getting fully seized). Wallet + * integrators displaying position risk should treat the band + * [rebalanceDebtRatio, liquidationDebtRatio) as a yellow zone — the + * position is alive but the protocol is actively reducing exposure + * on the user's behalf. Mainnet (May 2026): 0.88e18 (88%) on + * wstETH-Long. + */ + rebalanceDebtRatio: bigint; + + /** + * Rebalancer bonus in 1e9-scaled form. Pool.getRebalanceRatios()[1]. + * Smaller than liquidationBonusRatio because rebalancing is the + * less-punitive intervention. Mainnet: 2.5e7 (2.5%) on wstETH-Long. + */ + rebalanceBonusRatio: bigint; + + /** + * Per-(pool, operator) fee ratios from + * PoolConfiguration.getPoolFeeRatio(pool, operator), 1e9-denominated. + * Operator defaults to the Railgun relay-adapter (the canonical + * operator for fxmint). Pass a different operator to query fees from + * another perspective. + * Tuple positions confirmed in Task 1 discovery: + * [0]=supplyFeeRatio, [1]=withdrawFeeRatio, [2]=borrowFeeRatio, [3]=repayFeeRatio + * Only borrowFeeRatio + repayFeeRatio are non-zero for v0.1 mainnet + * (no supply/withdraw fee on f(x)). + */ + borrowFeeRatio: bigint; + repayFeeRatio: bigint; +}; + +/** + * Reads pool-level state and per-operator fee ratios from f(x) contracts. + * + * Performs four sequential on-chain reads (PoolManager.getPoolInfo, + * Pool.getTotalRawCollaterals, Pool.getLiquidateRatios, + * Pool.getRebalanceRatios, PoolConfiguration.getPoolFeeRatio). Cookbook + * v0.1 doesn't bake in batching; integrators wanting a single-roundtrip + * read can compose with ethers' Multicall3 helper (or any external + * batcher) against these same function selectors. + * + * Default operator is the Railgun relay-adapter. Pass `operator` + * explicitly to query fees from a different operator's perspective. + */ +export async function getFxPool( + poolRef: FxMintPoolRef, + provider: Provider, + operator: Address = DEFAULT_FXMINT_OPERATOR, +): Promise { + const resolved = resolvePool(poolRef); + + const poolManagerContract = new Contract( + FX_ADDRESSES.fxPoolManager, + FX_POOL_MANAGER_ABI, + provider, + ); + const poolInfo = (await poolManagerContract.getPoolInfo( + resolved.address, + )) as readonly [bigint, bigint, bigint, bigint, bigint]; + // PoolManager.getPoolInfo tuple positions: + // [collateralCapacity, collateralBalance, rawCollateral, + // debtCapacity, debtBalance]. Naming every slot (with _ + // for the ones we don't use here) keeps the position + // labels glued to the right value. + const [ + _collateralCapacity, + collateralBalance, + _rawCollateral, + debtCapacity, + debtBalance, + ] = poolInfo; + + // Pool contract — used for the next three reads + // (getTotalRawCollaterals, getLiquidateRatios, getRebalanceRatios). + const poolContract = new Contract( + resolved.address, + FX_POOL_ABI, + provider, + ); + + const totalRawColls = (await poolContract.getTotalRawCollaterals()) as bigint; + + // Pool.getLiquidateRatios() — added to FX_POOL_ABI in v0.1 Task 13. + const liquidateRatios = (await poolContract.getLiquidateRatios()) as readonly [ + bigint, + bigint, + ]; + const [liquidationDebtRatio, liquidationBonusRatio] = liquidateRatios; + + // Pool.getRebalanceRatios() — added to FX_POOL_ABI after launch when + // wallet integrators flagged the gap. Same tuple shape as liquidate. + // The rebalance threshold sits below liquidation; see FxPool type doc + // for the yellow-zone display recommendation. + const rebalanceRatios = (await poolContract.getRebalanceRatios()) as readonly [ + bigint, + bigint, + ]; + const [rebalanceDebtRatio, rebalanceBonusRatio] = rebalanceRatios; + + // Per-(pool, operator) fee ratios. + const poolConfigContract = new Contract( + FX_ADDRESSES.fxPoolConfiguration, + FX_POOL_CONFIGURATION_ABI, + provider, + ); + const fees = (await poolConfigContract.getPoolFeeRatio( + resolved.address, + operator, + )) as readonly [bigint, bigint, bigint, bigint]; + // PoolConfiguration.getPoolFeeRatio tuple: + // [supplyFeeRatio, withdrawFeeRatio, borrowFeeRatio, repayFeeRatio]. + // Only borrow + repay are non-zero on v0.1 mainnet (no supply/ + // withdraw fee on f(x)). + const [ + _supplyFeeRatio, + _withdrawFeeRatio, + borrowFeeRatio, + repayFeeRatio, + ] = fees; + + // typeof narrowing: FxMintPoolRef = FxMintPoolName | {address;...}, so + // the string branch is already FxMintPoolName — no cast needed. + const name = typeof poolRef === 'string' ? poolRef : undefined; + + return { + name, + address: resolved.address, + collateralToken: resolved.collateralToken, + collateralDecimals: resolved.collateralDecimals, + debtCapacity, + debtBalance, + totalRawColls, + collateralBalance, + liquidationDebtRatio, + liquidationBonusRatio, + rebalanceDebtRatio, + rebalanceBonusRatio, + borrowFeeRatio, + repayFeeRatio, + }; +} + +// ============================================================================= +// FxPosition — per-position state, native-decimals collateral. +// ============================================================================= + +export type FxPosition = { + positionId: bigint; + pool: { address: Address; name?: FxMintPoolName }; + collateralToken: Address; + collateralDecimals: bigint; + + /** + * Native-decimals collateral amount: what 0x quotes and price feeds + * multiply directly. WBTC: 8 decimals; wstETH: 18 decimals. Computed + * via the same rawColls × collateralBalance / totalRawColls formula + * computeFxClose uses internally. + */ + collateralAmount: bigint; + + /** + * f(x)-internal raw representation, exposed for callers that need to + * reconstruct close-math themselves. rawColls is internal-units (scaled + * by collateralBalance/totalRawColls); rawDebts is fxUSD wei. + */ + rawColls: bigint; + rawDebts: bigint; + + /** + * Alias for rawDebts; kept under a friendlier name for wallet display + * code that doesn't care about the rawColls/totalRawColls scaling. + */ + debt: bigint; + + /** + * Position's current debt ratio in f(x)'s 1e18-scaled representation + * (1e18 = 100%, higher = closer to liquidation). Compare against + * FxPool.liquidationDebtRatio to compute liquidation distance. + */ + debtRatio: bigint; +}; + +/** + * Reads a single position's state. Caller obtains positionId from their + * own NFT enumeration (wallet-side, since only the wallet's keys can + * decrypt shielded NFT ownership). All shielded fxmint positions on-chain + * are owned-of-record by the Railgun relay-adapter, so list-by-owner is + * not meaningful at this layer — wallets enumerate the user's shielded + * NFTs, then call this once per id. + * + * Performs four sequential on-chain reads (Pool.getPosition, + * Pool.getPositionDebtRatio, Pool.getTotalRawCollaterals, + * PoolManager.getPoolInfo) and computes collateralAmount inline. + * Integrators wanting batched reads can compose with ethers' Multicall3 + * helper (or any external batcher) against these same function selectors. + * + * v0.2 may bake in a multicall variant (one roundtrip instead of four) + * once the wallet integrator patterns settle — deferred from v0.1 to + * keep the surface small for the upstream PR. + */ +export async function getFxPosition( + positionId: bigint, + poolRef: FxMintPoolRef, + provider: Provider, +): Promise { + const resolved = resolvePool(poolRef); + + // Pool contract — three of the four reads come from it. + const poolContract = new Contract( + resolved.address, + FX_POOL_ABI, + provider, + ); + + // Pool.getPosition returns (rawColls, rawDebts). + const positionTuple = (await poolContract.getPosition(positionId)) as readonly [ + bigint, + bigint, + ]; + const [rawColls, rawDebts] = positionTuple; + + // Pool.getPositionDebtRatio returns the 1e18-scaled debt ratio. + // Note: this is a single-uint256 return per the existing FX_POOL_ABI. + // ethers' Contract returns the raw bigint (not wrapped in a tuple) for + // single-return-value functions. + const debtRatio = (await poolContract.getPositionDebtRatio( + positionId, + )) as bigint; + + // Convert rawColls → native-decimals collateralAmount via the same + // ratio computeFxClose uses: collateralAmount = rawColls × collateralBalance / totalRawColls. + // Need totalRawColls + collateralBalance — fetch them; v0.1 prefers + // clarity over a shared multicall. + const totalRawColls = (await poolContract.getTotalRawCollaterals()) as bigint; + + const poolManagerContract = new Contract( + FX_ADDRESSES.fxPoolManager, + FX_POOL_MANAGER_ABI, + provider, + ); + const poolInfo = (await poolManagerContract.getPoolInfo( + resolved.address, + )) as readonly [bigint, bigint, bigint, bigint, bigint]; + // Only collateralBalance (slot 1) is used here; + // see getFxPool above for the full tuple shape. + const [_collateralCapacity, collateralBalance] = poolInfo; + + // collateralAmount = (rawColls × collateralBalance) / totalRawColls. + // Guard against div-by-zero in the empty-pool case (no positions yet — + // shouldn't happen for production pools but worth being safe). + const collateralAmount = + totalRawColls === 0n ? 0n : (rawColls * collateralBalance) / totalRawColls; + + const name = typeof poolRef === 'string' ? poolRef : undefined; + + return { + positionId, + pool: { address: resolved.address, name }, + collateralToken: resolved.collateralToken, + collateralDecimals: resolved.collateralDecimals, + collateralAmount, + rawColls, + rawDebts, + debt: rawDebts, + debtRatio, + }; +} diff --git a/src/api/borrow/fx/index.ts b/src/api/borrow/fx/index.ts new file mode 100644 index 0000000..9d6f23f --- /dev/null +++ b/src/api/borrow/fx/index.ts @@ -0,0 +1 @@ +export * from './fx-position-reader'; diff --git a/src/api/borrow/index.ts b/src/api/borrow/index.ts new file mode 100644 index 0000000..ef2f499 --- /dev/null +++ b/src/api/borrow/index.ts @@ -0,0 +1 @@ +export * from './fx'; diff --git a/src/api/index.ts b/src/api/index.ts index 77b2075..09ab0cc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,3 +2,4 @@ export * from './uni-v2-like'; export * from './zero-x'; export * from './zero-x-v2'; export * from './beefy'; +export * from './borrow'; diff --git a/src/recipes/borrow/fx/README.md b/src/recipes/borrow/fx/README.md new file mode 100644 index 0000000..6259f0a --- /dev/null +++ b/src/recipes/borrow/fx/README.md @@ -0,0 +1,418 @@ +# f(x) Protocol — fxMINT recipes + +Cookbook recipes for opening, closing, and managing [f(x) Protocol](https://fx.aladdin.club) +debt positions privately, by routing each operation through a Railgun +relay-adapter cross-contract call. + +The recipes plug into any wallet that already consumes +`@railgun-community/cookbook` — adoption is the same shape as for any other +recipe (`new FxMintOpenRecipe(...).getRecipeOutput(input)`). + +## What this is for + +f(x) Protocol lets a user mint fxUSD against tier one collaterals +(wstETH, WBTC) without recurring costs (one time fee) and with a unique liquidation design that prevents any hard liquidation. + +Every position is wrapped into an NFT while the collateral is pooled at protocol level to make progressive liquidations possible. It unlocks the possibility of borrowing privately through Railgun while having a liquidatable debt position. + +These recipes wrap that flow as a Railgun cross-contract +call so a user can: + +- **Open** a debt position: deposit shielded WETH (or shielded WBTC for + WBTC-Long), mint fxUSD against an f(x) Long position; shield the fxUSD + and the position NFT back to the user's `0zk` address. +- **Close** (or partially close): unshield fxUSD, repay debt against the + position, withdraw collateral, optionally swap back to WETH; reshield + the proceeds and (for partial close) the surviving NFT. +- **Top up collateral**: add collateral to an existing position without + changing debt. The position's debt ratio falls — risk-management dial. +- **Top up + borrow more**: add collateral AND mint additional fxUSD in + one atomic `operate()`. Increases exposure (lever-up). +- **Borrow more**: mint additional fxUSD against an existing position + without adding collateral. Raises the debt ratio — caller should + sanity-check against the pool's liquidation threshold first. +- **Repay debt**: burn fxUSD to reduce an existing position's debt + without withdrawing collateral. Symmetric counterpart to top-up — the + other risk-management dial. + +What's hidden: the link between the user's wallet and the on-chain f(x) +position. What's still public: the position's collateral, debt, and health +on f(x); the on-chain owner-of-record (a Railgun relay-adapter address +shared by all users); the fact that a Railgun cross-contract call +interacted with f(x). f(x)'s liquidation flow operates against the public +position state regardless of who deposited. + +## Install + +Once these recipes land in upstream `@railgun-community/cookbook`: + +```bash +npm install @railgun-community/cookbook +``` + +Until then, integrators can pin the fork directly: + +```bash +npm install github:Squabble9/cookbook#fxmint-v0.1 +``` + +## Pool support + +Two mainnet pools are wired up by name. For new pools, pass an explicit +`{ address, collateralToken, collateralDecimals }` object as the `pool` arg. + +| Name | Pool address | Collateral | Decimals | +| ------------- | -------------------------------------------- | ----------------------------------------- | -------- | +| `wstETH-Long` | `0x6Ecfa38FeE8a5277B91eFdA204c235814F0122E8` | wstETH (`0x7f39C581…E2Ca0`) | 18 | +| `WBTC-Long` | `0xAB709e26Fa6B0A30c119D8c55B887DeD24952473` | WBTC (`0x2260FAC5…2C599`) | 8 | + +The recipes auto-branch their step graph based on the `swapQuote` opt: + +- **wstETH-Long** uses the **swap path** — WETH input + 0x v2 swap leg + (WETH↔wstETH). `swapQuote` is required. +- **WBTC-Long** uses the **direct path** — WBTC input/output, no swap + leg. `swapQuote` must be omitted. +- **Custom pool refs** are trusted: caller picks the path by providing or + omitting `swapQuote`. + +f(x) shorts (fxBASE-side products) and the fxSAVE yield product are out +of scope of these recipes. + +## On-chain parameters — fetch dynamically + +f(x) governance can rotate the per-pool borrow fee, repay fee, and +liquidation threshold via `PoolConfiguration` and `Pool` admin functions. +Recipes that touch these parameters take them as constructor args; the +caller is expected to fetch live values via `getFxPool` (see Read API) +rather than hardcoding. None of the v0.1 recipes embed fee or threshold +constants. + +## Usage — open a position + +```ts +import { + FxMintOpenRecipe, + FX_ADDRESSES, + getFxPool, +} from '@railgun-community/cookbook'; +import { NetworkName } from '@railgun-community/shared-models'; + +// Fetch live borrow fee + other pool state (single helper, see Read API): +const pool = await getFxPool('wstETH-Long', provider); + +const recipe = new FxMintOpenRecipe({ + pool: 'wstETH-Long', // or 'WBTC-Long' (omit swapQuote) + targetDebt: 5_000_000_000_000_000_000n, // 5 fxUSD + predictedPositionId: 1903n, // Pool.getNextPositionId() + borrowFeeRatio: pool.borrowFeeRatio, // 1e9-denom, governance-upgradable + swapQuote, // SwapQuoteData (WETH → wstETH); omit for WBTC-Long + slippageBasisPoints: 5, +}); + +const recipeOutput = await recipe.getRecipeOutput({ + networkName: NetworkName.Ethereum, + railgunAddress, // user's 0zk address + erc20Amounts: [ + { tokenAddress: FX_ADDRESSES.WETH, amount: 8_000_000_000_000_000n }, // 0.008 WETH + ], + nfts: [], +}); + +// Feed `recipeOutput.crossContractCalls` / `.erc20AmountRecipients` / +// `.nftAmountRecipients` to the standard Railgun proof+populate+broadcast +// pipeline (gasEstimateForUnprovenCrossContractCalls → +// generateCrossContractCallsProof → populateProvedCrossContractCalls). +``` + +For WBTC-Long, swap the input asset to WBTC, omit `swapQuote` + +`slippageBasisPoints`. The recipe assembles a 2-step direct path +(`Approve(PoolMgr) → operate(open)`) instead of the 4-step swap path. + +## Usage — close a position + +```ts +import { + FxMintCloseRecipe, + computeFxClose, + getFxPool, + getFxPosition, + FX_ADDRESSES, +} from '@railgun-community/cookbook'; +import { NFTTokenType } from '@railgun-community/shared-models'; + +// Fetch live pool state + position state in two reads. +const pool = await getFxPool('wstETH-Long', provider); +const position = await getFxPosition(positionId, 'wstETH-Long', provider); + +// Pre-compute the operate amounts. Handles three non-trivial pieces of +// math: the rawColls→native pro-rata conversion, f(x)'s repay fee, and +// Railgun's 25 bps unshield fee. +const amounts = computeFxClose({ + rawColls: position.rawColls, + rawDebts: position.rawDebts, + collateralBalance: pool.collateralBalance, + totalRawColls: pool.totalRawColls, + shieldedFxUSD, // wallet.balanceForERC20Token(fxUSD) + repayFeeRatio: pool.repayFeeRatio, + railgunUnshieldFeeBps: 25n, +}); + +const recipe = new FxMintCloseRecipe({ + pool: 'wstETH-Long', + positionId, + ...amounts, // { repayAmount, withdrawColl, approveAmount, partialClose, ... } + swapQuote, // SwapQuoteData (wstETH → WETH); omit for WBTC-Long + slippageBasisPoints: 5, +}); + +const recipeOutput = await recipe.getRecipeOutput({ + networkName: NetworkName.Ethereum, + railgunAddress, + erc20Amounts: [{ tokenAddress: FX_ADDRESSES.fxUSD, amount: shieldedFxUSD }], + nfts: [{ + nftAddress: pool.address, // per-pool NFT contract + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }], +}); +``` + +## Usage — top up collateral + +```ts +import { FxMintTopupRecipe, FX_ADDRESSES } from '@railgun-community/cookbook'; + +const recipe = new FxMintTopupRecipe({ + pool: 'wstETH-Long', // or 'WBTC-Long' (omit swapQuote) + positionId: 1903n, + swapQuote, // WETH → wstETH; omit for WBTC-Long + slippageBasisPoints: 5, +}); + +const recipeOutput = await recipe.getRecipeOutput({ + networkName: NetworkName.Ethereum, + railgunAddress, + erc20Amounts: [{ + tokenAddress: FX_ADDRESSES.WETH, // or WBTC for WBTC-Long + amount: 2_000_000_000_000_000n, // 0.002 WETH + }], + nfts: [{ + nftAddress: poolAddress, + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }], +}); +``` + +Inputs: WETH (swap path) or pool collateral (direct path), plus the +position NFT. Outputs (shielded back): the same position NFT (top-up +preserves the NFT identifier; only full close burns it). No fxUSD output +— debt is unchanged. + +## Usage — top up and borrow more + +```ts +import { FxMintTopupAndBorrowRecipe, getFxPool } from '@railgun-community/cookbook'; + +const pool = await getFxPool('wstETH-Long', provider); + +const recipe = new FxMintTopupAndBorrowRecipe({ + pool: 'wstETH-Long', + positionId: 1903n, + additionalDebt: 1_000_000_000_000_000_000n, // mint +1 fxUSD on top of the existing debt + borrowFeeRatio: pool.borrowFeeRatio, + swapQuote, // WETH → wstETH; omit for WBTC-Long + slippageBasisPoints: 5, +}); + +const recipeOutput = await recipe.getRecipeOutput({ + networkName: NetworkName.Ethereum, + railgunAddress, + erc20Amounts: [{ tokenAddress: FX_ADDRESSES.WETH, amount: 2_000_000_000_000_000n }], + nfts: [{ nftAddress: poolAddress, tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, amount: 1n }], +}); +``` + +Combines a collateral top-up with a fxUSD mint in a single atomic +`PoolManager.operate(positionId, +coll, +debt)`. Outputs: position NFT + +fxUSD (post-borrow-fee net). + +## Usage — borrow more + +```ts +import { FxMintBorrowMoreRecipe, getFxPool } from '@railgun-community/cookbook'; + +const pool = await getFxPool('wstETH-Long', provider); + +const recipe = new FxMintBorrowMoreRecipe({ + pool: 'wstETH-Long', // pool-agnostic; works the same on WBTC-Long + positionId: 1903n, + additionalDebt: 1_000_000_000_000_000_000n, + borrowFeeRatio: pool.borrowFeeRatio, +}); + +const recipeOutput = await recipe.getRecipeOutput({ + networkName: NetworkName.Ethereum, + railgunAddress, + erc20Amounts: [], // no ERC-20 input + nfts: [{ nftAddress: poolAddress, tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, amount: 1n }], +}); +``` + +No collateral movement, no swap leg. Pool-agnostic: same step graph +regardless of which pool. Outputs: position NFT + fxUSD (post-borrow-fee). +**Caller responsibility:** sanity-check the projected debt ratio against +`pool.liquidationDebtRatio` before constructing — borrowing more raises +risk. + +## Usage — repay debt (partial repay) + +```ts +import { + FxMintRepayDebtRecipe, + computeFxRepay, + getFxPool, + getFxPosition, + FX_ADDRESSES, +} from '@railgun-community/cookbook'; + +const pool = await getFxPool('wstETH-Long', provider); +const position = await getFxPosition(positionId, 'wstETH-Long', provider); + +// Caller decides how much to repay; the helper caps at the three-way min +// (fee ceiling, current debt, user's intent) and computes the approve +// amount (= repayAmount × (1 + repayFeeRatio/1e9)) for the relay-adapter. +const { repayAmount, approveAmount } = computeFxRepay({ + rawDebts: position.rawDebts, + shieldedFxUSD, // wallet.balanceForERC20Token(fxUSD) + desiredRepayAmount: 2_000_000_000_000_000_000n, // repay up to 2 fxUSD + repayFeeRatio: pool.repayFeeRatio, + railgunUnshieldFeeBps: 25n, +}); + +const recipe = new FxMintRepayDebtRecipe({ + pool: 'wstETH-Long', + positionId, + repayAmount, + approveAmount, + repayFeeRatio: pool.repayFeeRatio, +}); + +const recipeOutput = await recipe.getRecipeOutput({ + networkName: NetworkName.Ethereum, + railgunAddress, + erc20Amounts: [{ tokenAddress: FX_ADDRESSES.fxUSD, amount: shieldedFxUSD }], + nfts: [{ nftAddress: poolAddress, tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, amount: 1n }], +}); +``` + +Pool-agnostic (no collateral movement, no swap leg). Outputs: position +NFT, plus any unspent fxUSD (cookbook's recipe-engine epilogue shields +unconsumed input ERC-20s back automatically). + +## Read API + +For wallet integrators driving position-management UIs. + +```ts +import { getFxPool, getFxPosition } from '@railgun-community/cookbook'; + +// Pool-level state + per-(pool, operator) fee ratios. +const pool = await getFxPool('wstETH-Long', provider); +// pool.{address, collateralToken, collateralDecimals, +// debtCapacity, debtBalance, totalRawColls, collateralBalance, +// liquidationDebtRatio, // 1e18-scaled; mainnet 0.95e18 (95%) +// liquidationBonusRatio, // 1e9-scaled; liquidator's bonus +// rebalanceDebtRatio, // 1e18-scaled; mainnet 0.88e18 (88%) — yellow zone +// rebalanceBonusRatio, // 1e9-scaled; rebalancer's bonus (2.5%) +// borrowFeeRatio, // 1e9-scaled +// repayFeeRatio} // 1e9-scaled + +// Per-position state, native-decimals collateral. +const position = await getFxPosition(positionId, 'wstETH-Long', provider); +// position.{positionId, pool: {address, name?}, collateralToken, +// collateralDecimals, +// collateralAmount, // native-decimals (8 for WBTC, 18 for wstETH) +// rawColls, rawDebts, // f(x)-internal scale +// debt, // alias for rawDebts (fxUSD wei, no scaling step) +// debtRatio} // 1e18-scaled, compare with pool.liquidationDebtRatio +``` + +`getFxPool`'s optional third arg is the operator address whose +per-(pool, operator) fee table is queried. Defaults to the Railgun +relay-adapter (`DEFAULT_FXMINT_OPERATOR`) — exported as a constant from +the package root for non-Railgun consumers that want to override. + +> **Integrator note: `DEFAULT_FXMINT_OPERATOR` is pinned to today's +> Railgun relay-adapter address.** If the Railgun engine rotates that +> address in a future release, this constant has to be updated in +> lockstep — apps that import the cookbook will silently query the wrong +> fee table until they upgrade. Wallet integrators that care about +> long-lived correctness should pass `operator` explicitly to `getFxPool` +> (read it from their own Railgun engine version), rather than relying +> on the default. + +Both functions perform sequential on-chain reads (no batching baked in). +Integrators wanting a single-roundtrip read can compose with ethers' +Multicall3 helper (or any external batcher) against the same function +selectors. A v0.2 batched reader is on the roadmap if integrators ask. + +All shielded fxmint positions on-chain are owned-of-record by the +Railgun relay-adapter, so a "list positions by owner" call at this layer +would return every fxmint user's positions — not meaningful for a single +wallet. Wallets enumerate the user's shielded NFTs (their own job, since +only the wallet's keys can decrypt shielded ownership) and feed each +`positionId` into `getFxPosition`. + +## Calibration + +The math and gotchas baked into these recipes were calibrated against +real-mainnet broadcasts during bring-up: + +### v0 (Apr 2026) + +- Open: [`0x10f5ca84`](https://etherscan.io/tx/0x10f5ca84e4f5b1e622112dd089de6d0b07a1a90ff2e7aa4769694fd8980bd42d) + (manual), [`0x3f7c7c42`](https://etherscan.io/tx/0x3f7c7c4256e2473c4e663daef84fa3d7137e711d7f24d2d47d2b98e4f14a09c5) (CLI) +- Close: [`0xfc299fb7`](https://etherscan.io/tx/0xfc299fb738fccb36841fcb6b61fd80e8a9ab1216d8df20d4090e88651c18edb0) + (manual), [`0x5d0685ab`](https://etherscan.io/tx/0x5d0685abeb39879b411b95aa275ea3b4ca3813f6a9127f8dedd0f7cc725dfd94) (CLI) + +### v0.1 (May 2026) + +| Op | Pool | Path | Tx | +|---|---|---|---| +| Open (re-cal, dynamic borrow fee) | wstETH-Long | swap (WETH→wstETH) | [`0x71439e52`](https://etherscan.io/tx/0x71439e529690fd1a3d412d2d5a9461d94cd5d95a4e3e4966336d138d17e4ab32) | +| Topup | wstETH-Long | swap | [`0xac787ca8`](https://etherscan.io/tx/0xac787ca87e8b24418010994579ec79399b28c1d6144ba394f048257998e35613) | +| Borrow-more | wstETH-Long | n/a (debt-only) | [`0x09a3bd9d`](https://etherscan.io/tx/0x09a3bd9db0d3ffdb1860d3f35df774f7632c5b1fd8e5a118dc88fa4986f1e40c) | +| Repay-debt (partial) | wstETH-Long | n/a (debt-only) | [`0x4c8967b3`](https://etherscan.io/tx/0x4c8967b38d5d0728154f19fe69fb9a06d0baa8189a2219111f5767cfe458760c) | +| Open | WBTC-Long | direct (WBTC) | [`0x08b555df`](https://etherscan.io/tx/0x08b555df37b16c44a0bcb9b5a802e60726f1413f1572431deae2ac597d3fd1cd) | +| Close (partial) | WBTC-Long | direct (WBTC out) | [`0x92315478`](https://etherscan.io/tx/0x92315478123142346db24e1d9eda8d6767467e261bc86139492d296ddc581b65) | + +WBTC-Long topup is structurally identical to wstETH-Long topup at the +step-graph level (same `FxMintAdjustPositionStep` with `collDelta > 0, +debtDelta = 0`; only the leading approve target differs); the WBTC +direct input path is exercised by the WBTC-Long open above, so the topup +calibration was skipped for v0.1. + +## File layout + +``` +src/recipes/borrow/fx/ + fx-mint-open-recipe.ts FxMintOpenRecipe + fx-mint-close-recipe.ts FxMintCloseRecipe + fx-mint-topup-recipe.ts FxMintTopupRecipe + fx-mint-topup-and-borrow-recipe.ts FxMintTopupAndBorrowRecipe + fx-mint-borrow-more-recipe.ts FxMintBorrowMoreRecipe + fx-mint-repay-debt-recipe.ts FxMintRepayDebtRecipe +src/steps/borrow/fx/ + fx-mint-open-position-step.ts FxMintOpenPositionStep + fx-mint-close-position-step.ts FxMintClosePositionStep + fx-mint-adjust-position-step.ts FxMintAdjustPositionStep (shared by topup/borrow-more/repay) + fx-mint-util.ts addresses, ABIs, KNOWN_POOLS, computeFxClose, computeFxRepay +src/api/borrow/fx/ + fx-position-reader.ts getFxPool, getFxPosition +``` diff --git a/src/recipes/borrow/fx/__tests__/fx-mint-borrow-more-recipe.test.ts b/src/recipes/borrow/fx/__tests__/fx-mint-borrow-more-recipe.test.ts new file mode 100644 index 0000000..323ffba --- /dev/null +++ b/src/recipes/borrow/fx/__tests__/fx-mint-borrow-more-recipe.test.ts @@ -0,0 +1,54 @@ +import chai from 'chai'; +import { FxMintBorrowMoreRecipe } from '../fx-mint-borrow-more-recipe'; + +const { expect } = chai; + +describe('FxMintBorrowMoreRecipe', () => { + const baseOpts = { + positionId: 1903n, + additionalDebt: 5_000_000_000_000_000_000n, + borrowFeeRatio: 5_000_000n, + }; + + it('constructs for wstETH-Long (no swap leg, no validatePoolFlow involvement)', () => { + expect( + () => + new FxMintBorrowMoreRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + }), + ).not.to.throw(); + }); + + it('constructs for WBTC-Long (same — pool-agnostic)', () => { + expect( + () => + new FxMintBorrowMoreRecipe({ + ...baseOpts, + pool: 'WBTC-Long', + }), + ).not.to.throw(); + }); + + it('throws if additionalDebt is 0', () => { + expect( + () => + new FxMintBorrowMoreRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + additionalDebt: 0n, + }), + ).to.throw(/additionalDebt/i); + }); + + it('throws if additionalDebt is negative', () => { + expect( + () => + new FxMintBorrowMoreRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + additionalDebt: -1n, + }), + ).to.throw(/additionalDebt/i); + }); +}); 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 new file mode 100644 index 0000000..0af989f --- /dev/null +++ b/src/recipes/borrow/fx/__tests__/fx-mint-close-recipe.test.ts @@ -0,0 +1,90 @@ +import chai from 'chai'; +import { FxMintCloseRecipe } from '../fx-mint-close-recipe'; +import type { SwapQuoteData } from '../../../../models/export-models'; + +const { expect } = chai; + +// Minimal SwapQuoteData stub. Only the fields the recipe actually reads +// (spender for the post-close approve, buyERC20Amount for ZeroXV2SwapStep) +// matter; everything else is filled with placeholder values that are +// shape-correct but not exercised by the constructor-time validation tests. +const fakeSwapQuote = { + sellTokenValue: '0', + spender: '0x000000000000000000000000000000000000beef', + crossContractCall: { to: '0x1234', value: 0n, data: '0x5678' }, + buyERC20Amount: { + tokenAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH + decimals: 18n, + amount: 1n, + }, +} as unknown as SwapQuoteData; + +describe('FxMintCloseRecipe — auto-branch + per-pool validation', () => { + // Shared close-side numbers. approveAmount = repayAmount × (1e9 + 5e6) / 1e9 + // matches the close-fee shape produced by computeFxClose at a 0.5% repay + // fee (5_000_000 / 1e9). The recipe doesn't recompute these — it just + // plumbs them — so the values here only need to be internally consistent. + const baseOpts = { + positionId: 1903n, + repayAmount: 5_000_000_000_000_000_000n, + withdrawColl: 4_000_000_000_000_000n, + approveAmount: 5_025_000_000_000_000_000n, + partialClose: true, + }; + + it('wstETH-Long: throws if swapQuote is missing', () => { + expect( + () => + new FxMintCloseRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + }), + ).to.throw(/swapQuote required.*wstETH-Long|wstETH-Long.*swapQuote/i); + }); + + it('WBTC-Long: throws if swapQuote is provided', () => { + expect( + () => + new FxMintCloseRecipe({ + ...baseOpts, + pool: 'WBTC-Long', + swapQuote: fakeSwapQuote, + slippageBasisPoints: 5, + }), + ).to.throw(/WBTC-Long.*direct|WBTC-Long.*omit|swapQuote.*forbidden/i); + }); + + it('wstETH-Long with swapQuote: constructs', () => { + expect( + () => + new FxMintCloseRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + swapQuote: fakeSwapQuote, + slippageBasisPoints: 5, + }), + ).not.to.throw(); + }); + + it('WBTC-Long without swapQuote: constructs', () => { + expect( + () => + new FxMintCloseRecipe({ + ...baseOpts, + pool: 'WBTC-Long', + }), + ).not.to.throw(); + }); + + it('throws if swapQuote provided without slippageBasisPoints', () => { + expect( + () => + new FxMintCloseRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + swapQuote: fakeSwapQuote, + // slippageBasisPoints intentionally omitted + } as never), + ).to.throw(/slippageBasisPoints/); + }); +}); 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 new file mode 100644 index 0000000..fe372e3 --- /dev/null +++ b/src/recipes/borrow/fx/__tests__/fx-mint-open-recipe.test.ts @@ -0,0 +1,119 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { FxMintOpenRecipe, validatePoolFlow } from '../fx-mint-open-recipe'; +import type { SwapQuoteData } from '../../../../models/export-models'; + +chai.use(chaiAsPromised); +const { expect } = chai; + +const fakeSwapQuote: SwapQuoteData = { + sellTokenValue: '8000000000000000', + spender: '0x000000000000000000000000000000000000beef', + crossContractCall: { to: '0x1234', value: 0n, data: '0x5678' }, + buyERC20Amount: { + tokenAddress: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', // wstETH + decimals: 18n, + amount: 6_500_000_000_000_000n, + }, +} as unknown as SwapQuoteData; + +describe('FxMintOpenRecipe — auto-branch + per-pool validation', () => { + it('wstETH-Long: throws if swapQuote is missing', () => { + expect( + () => + new FxMintOpenRecipe({ + pool: 'wstETH-Long', + targetDebt: 5_000_000_000_000_000_000n, + predictedPositionId: 1903n, + borrowFeeRatio: 5_000_000n, + }), + ).to.throw(/swapQuote required.*wstETH-Long|wstETH-Long.*swapQuote/i); + }); + + it('WBTC-Long: throws if swapQuote is provided', () => { + expect( + () => + new FxMintOpenRecipe({ + pool: 'WBTC-Long', + targetDebt: 5_000_000_000_000_000_000n, + predictedPositionId: 1903n, + borrowFeeRatio: 5_000_000n, + swapQuote: fakeSwapQuote, + slippageBasisPoints: 5, + }), + ).to.throw(/WBTC-Long.*direct|swapQuote.*forbidden|WBTC-Long.*omit/i); + }); + + it('wstETH-Long with swapQuote constructs', () => { + expect( + () => + new FxMintOpenRecipe({ + pool: 'wstETH-Long', + targetDebt: 5_000_000_000_000_000_000n, + predictedPositionId: 1903n, + borrowFeeRatio: 5_000_000n, + swapQuote: fakeSwapQuote, + slippageBasisPoints: 5, + }), + ).not.to.throw(); + }); + + it('WBTC-Long without swapQuote constructs', () => { + expect( + () => + new FxMintOpenRecipe({ + pool: 'WBTC-Long', + targetDebt: 5_000_000_000_000_000_000n, + predictedPositionId: 1903n, + borrowFeeRatio: 5_000_000n, + }), + ).not.to.throw(); + }); + + it('throws if swapQuote provided without slippageBasisPoints', () => { + expect( + () => + new FxMintOpenRecipe({ + pool: 'wstETH-Long', + targetDebt: 5_000_000_000_000_000_000n, + predictedPositionId: 1903n, + borrowFeeRatio: 5_000_000n, + swapQuote: fakeSwapQuote, + // slippageBasisPoints intentionally omitted + } as never), + ).to.throw(/slippageBasisPoints/); + }); +}); + +describe('validatePoolFlow', () => { + it('throws for wstETH-Long without swapQuote', () => { + expect(() => validatePoolFlow('wstETH-Long', undefined)).to.throw( + /wstETH-Long/, + ); + }); + + it('throws for WBTC-Long with swapQuote', () => { + expect(() => validatePoolFlow('WBTC-Long', fakeSwapQuote)).to.throw( + /WBTC-Long/, + ); + }); + + it('passes for wstETH-Long with swapQuote', () => { + expect(() => validatePoolFlow('wstETH-Long', fakeSwapQuote)).not.to.throw(); + }); + + it('passes for WBTC-Long without swapQuote', () => { + expect(() => validatePoolFlow('WBTC-Long', undefined)).not.to.throw(); + }); + + it('trusts custom pool refs (does not throw)', () => { + 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(); + }); +}); diff --git a/src/recipes/borrow/fx/__tests__/fx-mint-repay-debt-recipe.test.ts b/src/recipes/borrow/fx/__tests__/fx-mint-repay-debt-recipe.test.ts new file mode 100644 index 0000000..c5da502 --- /dev/null +++ b/src/recipes/borrow/fx/__tests__/fx-mint-repay-debt-recipe.test.ts @@ -0,0 +1,83 @@ +import chai from 'chai'; +import { FxMintRepayDebtRecipe } from '../fx-mint-repay-debt-recipe'; + +const { expect } = chai; + +describe('FxMintRepayDebtRecipe', () => { + const baseOpts = { + positionId: 1903n, + repayAmount: 3_000_000_000_000_000_000n, // 3 fxUSD + approveAmount: 3_006_000_000_000_000_000n, // = 3e18 × (1e9 + 2e6) / 1e9 (mainnet's 0.2% repay fee) + repayFeeRatio: 2_000_000n, // mainnet repay fee + }; + + it('constructs for wstETH-Long', () => { + expect( + () => + new FxMintRepayDebtRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + }), + ).not.to.throw(); + }); + + it('constructs for WBTC-Long (pool-agnostic)', () => { + expect( + () => + new FxMintRepayDebtRecipe({ + ...baseOpts, + pool: 'WBTC-Long', + }), + ).not.to.throw(); + }); + + it('throws if repayAmount is 0', () => { + expect( + () => + new FxMintRepayDebtRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + repayAmount: 0n, + }), + ).to.throw(/repayAmount/i); + }); + + it('throws if repayAmount is negative', () => { + expect( + () => + new FxMintRepayDebtRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + repayAmount: -1n, + }), + ).to.throw(/repayAmount/i); + }); + + it('throws if approveAmount < repayAmount (caller forgot fee uplift)', () => { + expect( + () => + new FxMintRepayDebtRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + repayAmount: 3_000_000_000_000_000_000n, + approveAmount: 2_500_000_000_000_000_000n, // less than repay — bug + }), + ).to.throw(/approveAmount.*repayAmount|computeFxRepay/i); + }); + + it('throws if approveAmount diverges from the fee-uplifted target (strict equality)', () => { + expect( + () => + new FxMintRepayDebtRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + repayAmount: 3_000_000_000_000_000_000n, + // > repayAmount but != repayAmount × (FEE_DENOM + repayFeeRatio) / FEE_DENOM. + // PoolManager pulls the exact uplifted amount via transferFrom; + // an over-approve still trips the step-validator's balanced + // accounting. Strict equality keeps the error close to the cause. + approveAmount: 3_100_000_000_000_000_000n, + }), + ).to.throw(/computeFxRepay/i); + }); +}); 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 new file mode 100644 index 0000000..cef0126 --- /dev/null +++ b/src/recipes/borrow/fx/__tests__/fx-mint-topup-and-borrow-recipe.test.ts @@ -0,0 +1,114 @@ +import chai from 'chai'; +import { FxMintTopupAndBorrowRecipe } from '../fx-mint-topup-and-borrow-recipe'; +import type { SwapQuoteData } from '../../../../models/export-models'; + +const { expect } = chai; + +// Minimal SwapQuoteData stub. Only the fields the recipe actually reads +// (spender for the pre-swap approve, buyERC20Amount.amount for the encoded +// collDelta on the swap path) matter; everything else is shape-correct +// placeholder. Pattern matches fx-mint-topup-recipe.test.ts. +const fakeSwapQuote = { + sellTokenValue: '0', + spender: '0x000000000000000000000000000000000000beef', + crossContractCall: { to: '0x1234', value: 0n, data: '0x5678' }, + buyERC20Amount: { + tokenAddress: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', // wstETH + decimals: 18n, + amount: 1n, + }, +} as unknown as SwapQuoteData; + +describe('FxMintTopupAndBorrowRecipe', () => { + // Shared positive-debt opts; each test extends with the path-specific + // pool/swapQuote shape. Mirrors topup recipe's `baseOpts` style but adds + // the lever-up-only fields (additionalDebt, borrowFeeRatio). + const baseOpts = { + positionId: 1903n, + additionalDebt: 1_000_000_000_000_000_000n, // 1 fxUSD + borrowFeeRatio: 5_000_000n, // 0.5% in 1e9-denominated units + }; + + it('wstETH-Long: throws if swapQuote is missing', () => { + expect( + () => + new FxMintTopupAndBorrowRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + }), + ).to.throw(/swapQuote required.*wstETH-Long|wstETH-Long.*swapQuote/i); + }); + + it('WBTC-Long: throws if swapQuote is provided', () => { + expect( + () => + new FxMintTopupAndBorrowRecipe({ + ...baseOpts, + pool: 'WBTC-Long', + swapQuote: fakeSwapQuote, + slippageBasisPoints: 5, + }), + ).to.throw(/WBTC-Long.*direct|WBTC-Long.*omit|swapQuote.*forbidden/i); + }); + + it('wstETH-Long with swapQuote: constructs', () => { + expect( + () => + new FxMintTopupAndBorrowRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + swapQuote: fakeSwapQuote, + slippageBasisPoints: 5, + }), + ).not.to.throw(); + }); + + it('WBTC-Long without swapQuote: constructs', () => { + expect( + () => + new FxMintTopupAndBorrowRecipe({ + ...baseOpts, + pool: 'WBTC-Long', + }), + ).not.to.throw(); + }); + + it('throws if additionalDebt is 0n (use FxMintTopupRecipe instead)', () => { + expect( + () => + new FxMintTopupAndBorrowRecipe({ + ...baseOpts, + pool: 'WBTC-Long', + additionalDebt: 0n, + }), + ).to.throw( + /additionalDebt.*positive|use FxMintTopupRecipe|additionalDebt.*> 0/i, + ); + }); + + it('throws if additionalDebt is negative', () => { + expect( + () => + new FxMintTopupAndBorrowRecipe({ + ...baseOpts, + pool: 'WBTC-Long', + additionalDebt: -1n, + }), + ).to.throw(/additionalDebt/i); + }); + + it('throws if swapQuote provided without slippageBasisPoints', () => { + // The constructor's slippage-pair guard: any time a swapQuote is + // supplied, slippageBasisPoints must accompany it — otherwise the + // ZeroXV2SwapStep ends up with no slippage bound at recipe time. + // Mirrors the same guard on FxMintTopupRecipe / FxMintOpenRecipe. + expect( + () => + new FxMintTopupAndBorrowRecipe({ + ...baseOpts, + pool: 'wstETH-Long', + swapQuote: fakeSwapQuote, + } as never), + ).to.throw(/slippageBasisPoints/); + }); +}); 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 new file mode 100644 index 0000000..4bcff82 --- /dev/null +++ b/src/recipes/borrow/fx/__tests__/fx-mint-topup-recipe.test.ts @@ -0,0 +1,77 @@ +import chai from 'chai'; +import { FxMintTopupRecipe } from '../fx-mint-topup-recipe'; +import type { SwapQuoteData } from '../../../../models/export-models'; + +const { expect } = chai; + +// Minimal SwapQuoteData stub. Only the fields the recipe actually reads +// (spender for the pre-swap approve, buyERC20Amount.amount for the encoded +// collDelta on the swap path) matter; everything else is shape-correct +// placeholder. Pattern matches fx-mint-close-recipe.test.ts. +const fakeSwapQuote = { + sellTokenValue: '0', + spender: '0x000000000000000000000000000000000000beef', + crossContractCall: { to: '0x1234', value: 0n, data: '0x5678' }, + buyERC20Amount: { + tokenAddress: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', // wstETH + decimals: 18n, + amount: 1n, + }, +} as unknown as SwapQuoteData; + +describe('FxMintTopupRecipe', () => { + it('wstETH-Long: throws if swapQuote is missing', () => { + expect( + () => + new FxMintTopupRecipe({ + pool: 'wstETH-Long', + positionId: 1903n, + }), + ).to.throw(/swapQuote required.*wstETH-Long|wstETH-Long.*swapQuote/i); + }); + + it('WBTC-Long: throws if swapQuote is provided', () => { + expect( + () => + new FxMintTopupRecipe({ + pool: 'WBTC-Long', + positionId: 1903n, + swapQuote: fakeSwapQuote, + slippageBasisPoints: 5, + }), + ).to.throw(/WBTC-Long.*direct|WBTC-Long.*omit|swapQuote.*forbidden/i); + }); + + it('wstETH-Long with swapQuote: constructs', () => { + expect( + () => + new FxMintTopupRecipe({ + pool: 'wstETH-Long', + positionId: 1903n, + swapQuote: fakeSwapQuote, + slippageBasisPoints: 5, + }), + ).not.to.throw(); + }); + + it('WBTC-Long without swapQuote: constructs', () => { + expect( + () => + new FxMintTopupRecipe({ + pool: 'WBTC-Long', + positionId: 1903n, + }), + ).not.to.throw(); + }); + + it('throws if swapQuote provided without slippageBasisPoints', () => { + expect( + () => + new FxMintTopupRecipe({ + pool: 'wstETH-Long', + positionId: 1903n, + swapQuote: fakeSwapQuote, + } as never), + ).to.throw(/slippageBasisPoints/); + }); +}); diff --git a/src/recipes/borrow/fx/fx-mint-borrow-more-recipe.ts b/src/recipes/borrow/fx/fx-mint-borrow-more-recipe.ts new file mode 100644 index 0000000..9ea6e0b --- /dev/null +++ b/src/recipes/borrow/fx/fx-mint-borrow-more-recipe.ts @@ -0,0 +1,110 @@ +import { Recipe } from '../../recipe'; +import { Step } from '../../../steps'; +import type { RecipeConfig, StepInput } from '../../../models/export-models'; +import { NetworkName } from '@railgun-community/shared-models'; +import { + resolvePool, + type FxMintPoolRef, +} from '../../../steps/borrow/fx/fx-mint-util'; +import { FxMintAdjustPositionStep } from '../../../steps/borrow/fx/fx-mint-adjust-position-step'; + +export type FxMintBorrowMoreRecipeOpts = { + pool: FxMintPoolRef; + /** Existing position to mint additional debt against. */ + positionId: bigint; + /** + * Additional fxUSD to mint, in 18-decimal units. Must be > 0 (a zero or + * negative call would either no-op via PoolManager.operate(positionId, + * 0, 0) — which reverts — or be misused by callers expecting a repay). + * Use FxMintRepayDebtRecipe (Task 12) for repay; use FxMintTopupRecipe + * (Task 9) for collateral-only adds. + */ + additionalDebt: bigint; + /** + * f(x)'s borrow fee for this (pool, operator), 1e9-denominated. + * Caller fetches via PoolConfiguration.getPoolFeeRatio(pool, operator)[2] + * (see getFxPool in cookbook/src/api/borrow/fx — Task 13). + * Required because debtDelta > 0 by definition for this recipe. + */ + borrowFeeRatio: bigint; +}; + +/** + * Cookbook recipe: Mint additional fxUSD against an existing f(x) Long + * position WITHOUT adding collateral. + * + * operate(positionId, 0, +additionalDebt) + * + * Inputs (RecipeInput): position NFT only — no ERC-20 input. The relay- + * adapter doesn't need to approve anything on the collateral side because + * no collateral moves; PoolManager mints fxUSD directly to msg.sender + * (the relay-adapter), which then shields it back to the user. + * + * Outputs (shielded back): position NFT (same id; adjust ops never + * change positionId — only full close burns) + fxUSD (post-borrow-fee + * net amount; the step accounts for the fee in its outputERC20Amounts). + * + * Pool-agnostic by design: + * - No swap leg (no input ERC-20 needs converting). + * - No collateral movement, so no PoolManager approve. + * - No call to validatePoolFlow — that helper enforces collateral-axis + * rules (wstETH-Long needs WETH→wstETH swap, WBTC-Long is direct), + * which don't apply to debt-only recipes. Single-step recipe. + * + * Use case: position has appreciated, user wants to extract more fxUSD + * without adding fresh collateral. This INCREASES the position's debt + * ratio — caller should sanity-check against the pool's + * liquidationDebtRatio before constructing the recipe (see getFxPool, + * Task 13). The on-chain operate() will revert if the new ratio crosses + * the liquidation threshold, but failing client-side is friendlier. + */ +export class FxMintBorrowMoreRecipe extends Recipe { + readonly id = 'fxmint-borrow-more-v1'; + readonly config: RecipeConfig = { + name: 'fxMINT Borrow More', + description: + 'Mint additional fxUSD against an existing f(x) Long position.', + // Single-step recipe — operate() with debt-only delta is the lightest + // adjust op, but we keep a 1M floor so wstETH oracle calls + price + // fallback paths fit comfortably under the relay-adapter gas cap. + minGasLimit: 1_000_000n, + }; + + constructor(private readonly opts: FxMintBorrowMoreRecipeOpts) { + super(); + // Guard at construction time so callers see the misuse before + // gas-estimate / dry-run. The step-level guard would also catch + // (collDelta=0, debtDelta=0), but we want a recipe-specific message + // and we want to reject debtDelta <= 0 explicitly (the step accepts + // negative debtDelta as a repay — wrong recipe for that). + if (opts.additionalDebt <= 0n) { + throw new Error( + 'fxmint: FxMintBorrowMoreRecipe — additionalDebt must be > 0', + ); + } + } + + protected supportsNetwork(networkName: NetworkName): boolean { + return networkName === NetworkName.Ethereum; + } + + protected async getInternalSteps(_first: StepInput): Promise { + const pool = resolvePool(this.opts.pool); + + // Single AdjustStep: collDelta=0n, debtDelta=+additionalDebt. + // No leading approve — the relay-adapter neither sends nor pulls + // collateral here; it just calls PoolManager.operate which mints + // fxUSD to msg.sender (the relay-adapter itself). + return [ + new FxMintAdjustPositionStep({ + pool: pool.address, + collateralToken: pool.collateralToken, + collateralDecimals: pool.collateralDecimals, + positionId: this.opts.positionId, + collDelta: 0n, + debtDelta: this.opts.additionalDebt, + borrowFeeRatio: this.opts.borrowFeeRatio, + }), + ]; + } +} diff --git a/src/recipes/borrow/fx/fx-mint-close-recipe.ts b/src/recipes/borrow/fx/fx-mint-close-recipe.ts new file mode 100644 index 0000000..8dae920 --- /dev/null +++ b/src/recipes/borrow/fx/fx-mint-close-recipe.ts @@ -0,0 +1,137 @@ +import { Recipe } from '../../recipe'; +import { ApproveERC20SpenderStep, ZeroXV2SwapStep, Step } from '../../../steps'; +import type { + RecipeConfig, + StepInput, + SwapQuoteData, +} from '../../../models/export-models'; +import { NetworkName } from '@railgun-community/shared-models'; +import { + FX_ADDRESSES, + resolvePool, + type Address, + type FxMintPoolRef, +} from '../../../steps/borrow/fx/fx-mint-util'; +import { FxMintClosePositionStep } from '../../../steps/borrow/fx/fx-mint-close-position-step'; +import { validatePoolFlow } from './fx-mint-open-recipe'; + +export type FxMintCloseRecipeOpts = { + pool: FxMintPoolRef; + positionId: bigint; + /** Debt reduction in fxUSD wei (pre-computed via computeFxClose). */ + repayAmount: bigint; + /** Collateral withdrawal in native units (pre-computed via computeFxClose). */ + withdrawColl: bigint; + /** = repayAmount × (FEE_DENOM + repayFeeRatio) / FEE_DENOM. Pre-computed via computeFxClose. */ + approveAmount: bigint; + /** True if position survives the close (caller's computeFxClose result). */ + partialClose: boolean; + /** + * 0x v2 swap quote (collateralToken → WETH). + * + * Required iff the user wants WETH output — wstETH-Long convention. + * Forbidden for WBTC-Long, which shields the collateral directly back + * to the user (WBTC out, no swap leg). Custom pool refs trust the + * caller's choice. See validatePoolFlow in fx-mint-open-recipe. + */ + swapQuote?: SwapQuoteData; + /** Required iff swapQuote provided. */ + slippageBasisPoints?: number; +}; + +/** + * Cookbook recipe: Close (or partially close) an f(x) Long position. + * + * Two paths, branched on swapQuote presence: + * + * swap path (wstETH-Long, WETH out): + * approve(PoolManager, fxUSD) → operate(close) + * → approve(0x AllowanceTarget, wstETH) → swap(wstETH → WETH) + * + * direct path (WBTC-Long, WBTC out): + * approve(PoolManager, fxUSD) → operate(close) + * + * Inputs (RecipeInput): fxUSD (the unshielded balance) + the position NFT. + * Outputs (shielded back): WETH (swap path) or pool collateral (direct + * path) + (if partial close) the position NFT. + * + * Convention-wise the v0.1 pairings invert symmetrically vs. open: + * WBTC-Long close → WBTC out (direct); wstETH-Long close → WETH out (swap). + * + * `computeFxClose` is unchanged on the close-side math — f(x) has no + * withdraw fee, so `withdrawColl` stays a clean ratio. + */ +export class FxMintCloseRecipe extends Recipe { + readonly id = 'fxmint-close-v1'; + readonly config: RecipeConfig = { + name: 'fxMINT Close', + description: + 'Close f(x) Long position; swap-path or direct-path based on pool.', + minGasLimit: 2_500_000n, + }; + + constructor(private readonly opts: FxMintCloseRecipeOpts) { + super(); + validatePoolFlow(opts.pool, opts.swapQuote); + if (opts.swapQuote && opts.slippageBasisPoints === undefined) { + throw new Error( + 'fxmint: slippageBasisPoints required when swapQuote provided', + ); + } + } + + protected supportsNetwork(networkName: NetworkName): boolean { + return networkName === NetworkName.Ethereum; + } + + protected async getInternalSteps(_first: StepInput): Promise { + const pool = resolvePool(this.opts.pool); + const fxUSD = FX_ADDRESSES.fxUSD as Address; + const poolManager = FX_ADDRESSES.fxPoolManager as Address; + + const closeStep = new FxMintClosePositionStep({ + pool: pool.address, + collateralToken: pool.collateralToken, + collateralDecimals: pool.collateralDecimals, + positionId: this.opts.positionId, + repayAmount: this.opts.repayAmount, + withdrawColl: this.opts.withdrawColl, + partialClose: this.opts.partialClose, + }); + + const approveFxUSD = new ApproveERC20SpenderStep( + poolManager, + { tokenAddress: fxUSD, decimals: 18n }, + this.opts.approveAmount, + ); + + if (this.opts.swapQuote) { + // Swap path: collateral lands in relay-adapter, swapped to WETH. + // Decimals on the post-close approve + swap step come from the pool + // registry (wstETH = 18, hypothetical 8-decimal swap-path collateral + // would be plumbed correctly) — pre-Task-8 these were hardcoded 18n, + // which would silently mis-account a non-18-decimal swap collateral. + return [ + approveFxUSD, + closeStep, + new ApproveERC20SpenderStep( + this.opts.swapQuote.spender, + { + tokenAddress: pool.collateralToken, + decimals: pool.collateralDecimals, + }, + this.opts.withdrawColl, + ), + new ZeroXV2SwapStep(this.opts.swapQuote, { + tokenAddress: pool.collateralToken, + decimals: pool.collateralDecimals, + }), + ]; + } + + // Direct path: collateral shields back to user as-is (WBTC-Long). + // No post-close approve / swap leg — the relay-adapter just holds the + // pool collateral and Railgun shields it back into the user's account. + return [approveFxUSD, closeStep]; + } +} diff --git a/src/recipes/borrow/fx/fx-mint-open-recipe.ts b/src/recipes/borrow/fx/fx-mint-open-recipe.ts new file mode 100644 index 0000000..0d61adc --- /dev/null +++ b/src/recipes/borrow/fx/fx-mint-open-recipe.ts @@ -0,0 +1,171 @@ +import { Recipe } from '../../recipe'; +import { ApproveERC20SpenderStep, ZeroXV2SwapStep, Step } from '../../../steps'; +import type { + RecipeConfig, + StepInput, + SwapQuoteData, +} from '../../../models/export-models'; +import { NetworkName } from '@railgun-community/shared-models'; +import { + FX_ADDRESSES, + resolvePool, + type Address, + type FxMintPoolRef, + type FxMintPoolName, +} from '../../../steps/borrow/fx/fx-mint-util'; +import { FxMintOpenPositionStep } from '../../../steps/borrow/fx/fx-mint-open-position-step'; + +export type FxMintOpenRecipeOpts = { + pool: FxMintPoolRef; + /** Absolute fxUSD debt to mint. */ + targetDebt: bigint; + /** Predicted positionId from Pool.getNextPositionId(). Race-checked at gas-estimate. */ + predictedPositionId: bigint; + /** + * f(x)'s borrow fee for this (pool, operator), 1e9-denominated. + * Caller fetches via PoolConfiguration.getPoolFeeRatio(pool, operator)[2] + * (see getFxPool in cookbook/src/api/borrow/fx — Task 13). + * Was hardcoded 0.5% in v0; now dynamic to track f(x) governance changes. + */ + borrowFeeRatio: bigint; + /** + * 0x v2 swap quote (input → collateralToken). + * + * Required iff the input asset doesn't match pool.collateralToken — i.e., + * wstETH-Long uses WETH input + WETH→wstETH swap. Forbidden for + * WBTC-Long (which expects direct WBTC input — no swap). Custom pool + * refs trust the caller's choice. See validatePoolFlow below. + */ + swapQuote?: SwapQuoteData; + /** Required iff swapQuote provided. */ + slippageBasisPoints?: number; +}; + +/** + * Cookbook recipe: Open a new f(x) Long position. + * + * Two paths, branched on swapQuote presence: + * + * swap path (wstETH-Long, WETH input): + * approve(0x AllowanceTarget, WETH) → swap(WETH → wstETH) + * → approve(PoolManager, wstETH) → operate(open) + * + * direct path (WBTC-Long, WBTC input): + * approve(PoolManager, WBTC) → operate(open) + * + * Inputs (RecipeInput.erc20Amounts): WETH (swap path) or pool collateral + * (direct path). + * Outputs (shielded back): fxUSD + position NFT (predictedPositionId). + * + * The leading ApproveERC20SpenderStep on the swap path is required even + * with `ZeroXV2SwapStep` because that step verifies, but does not grant, + * the AllowanceTarget approval. + * + * Per-pool flow validation (validatePoolFlow): + * - wstETH-Long requires swapQuote (WETH → wstETH). + * - WBTC-Long forbids swapQuote (WBTC direct). + * - Custom pool refs are trusted (caller's choice via swapQuote). + */ +export class FxMintOpenRecipe extends Recipe { + readonly id = 'fxmint-open-v1'; + readonly config: RecipeConfig = { + name: 'fxMINT Open', + description: + 'Open f(x) Long position; swap-path or direct-path based on pool.', + minGasLimit: 1_500_000n, + }; + + constructor(private readonly opts: FxMintOpenRecipeOpts) { + super(); + validatePoolFlow(opts.pool, opts.swapQuote); + if (opts.swapQuote && opts.slippageBasisPoints === undefined) { + throw new Error( + 'fxmint: slippageBasisPoints required when swapQuote provided', + ); + } + } + + protected supportsNetwork(networkName: NetworkName): boolean { + return networkName === NetworkName.Ethereum; + } + + protected async getInternalSteps(_first: StepInput): Promise { + const pool = resolvePool(this.opts.pool); + const poolManager = FX_ADDRESSES.fxPoolManager as Address; + + const openStep = new FxMintOpenPositionStep({ + pool: pool.address, + collateralToken: pool.collateralToken, + collateralDecimals: pool.collateralDecimals, + targetDebt: this.opts.targetDebt, + predictedPositionId: this.opts.predictedPositionId, + borrowFeeRatio: this.opts.borrowFeeRatio, + }); + + if (this.opts.swapQuote) { + // Swap path: WETH (or any non-collateral input) → collateral via 0x. + // Post-swap approve + operate use whatever collateral the swap actually + // produces (non-deterministic by slippage). Cookbook rejects fixed + // amounts on steps following a non-deterministic step, so the + // approve/operate steps read input.expectedBalance at run time. + const inputToken = FX_ADDRESSES.WETH as Address; + return [ + new ApproveERC20SpenderStep(this.opts.swapQuote.spender, { + tokenAddress: inputToken, + decimals: 18n, + }), + new ZeroXV2SwapStep(this.opts.swapQuote, { + tokenAddress: inputToken, + decimals: 18n, + }), + new ApproveERC20SpenderStep(poolManager, { + tokenAddress: pool.collateralToken, + decimals: pool.collateralDecimals, + }), + openStep, + ]; + } + + // Direct path: input asset already matches pool collateral; no swap leg. + // The recipe input ERC20 is the pool collateral itself (e.g. WBTC), + // so we just approve PoolManager and call operate(). + return [ + new ApproveERC20SpenderStep(poolManager, { + tokenAddress: pool.collateralToken, + decimals: pool.collateralDecimals, + }), + openStep, + ]; + } +} + +/** + * 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. + * + * Errors are prefixed with `fxmint:` (not the specific recipe name) so + * the same helper can be called from open/close/topup/topup-and-borrow + * without lying about which recipe produced the error. + */ +export function validatePoolFlow( + poolRef: FxMintPoolRef, + swapQuote: SwapQuoteData | undefined, +): void { + if (typeof poolRef !== 'string') return; // custom pool — trust caller + + 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', + ); + } +} diff --git a/src/recipes/borrow/fx/fx-mint-repay-debt-recipe.ts b/src/recipes/borrow/fx/fx-mint-repay-debt-recipe.ts new file mode 100644 index 0000000..de6b665 --- /dev/null +++ b/src/recipes/borrow/fx/fx-mint-repay-debt-recipe.ts @@ -0,0 +1,136 @@ +import { Recipe } from '../../recipe'; +import { ApproveERC20SpenderStep, Step } from '../../../steps'; +import type { RecipeConfig, StepInput } from '../../../models/export-models'; +import { NetworkName } from '@railgun-community/shared-models'; +import { + FX_ADDRESSES, + FEE_DENOM, + resolvePool, + type Address, + type FxMintPoolRef, +} from '../../../steps/borrow/fx/fx-mint-util'; +import { FxMintAdjustPositionStep } from '../../../steps/borrow/fx/fx-mint-adjust-position-step'; + +export type FxMintRepayDebtRecipeOpts = { + pool: FxMintPoolRef; + positionId: bigint; + /** > 0; debt reduction in fxUSD wei (pre-computed via computeFxRepay). */ + repayAmount: bigint; + /** + * = repayAmount × (FEE_DENOM + repayFeeRatio) / FEE_DENOM. Pre-computed + * via computeFxRepay. PoolManager pulls this exact amount of fxUSD from + * the relay-adapter via transferFrom; the recipe's leading + * ApproveERC20SpenderStep authorizes that pull. + */ + approveAmount: bigint; + /** + * 1e9-denominated; from getFxPool (Task 13). + * PoolConfiguration.getPoolFeeRatio(pool, operator)[3]. + */ + repayFeeRatio: bigint; +}; + +/** + * Cookbook recipe: Reduce debt on an existing f(x) Long position WITHOUT + * withdrawing collateral. Symmetric counterpart to FxMintTopupRecipe — + * the two together are the position's risk-management dials. + * + * approve(PoolManager, fxUSD, approveAmount) + * → operate(positionId, 0, -repayAmount) + * + * Inputs (RecipeInput): fxUSD (caller's unshielded balance, >= approveAmount) + * + position NFT. + * Outputs (shielded back): position NFT (same id), plus any unspent fxUSD + * (cookbook's recipe-engine epilogue handles unconsumed input ERC-20s + * automatically). + * + * Pool-agnostic: no swap leg, no collateral movement. No call to + * validatePoolFlow — that helper enforces collateral-axis rules + * (wstETH-Long needs WETH→wstETH swap, WBTC-Long is direct), which don't + * apply to debt-only recipes. + * + * Use case: position's CR is deteriorating from collateral price drop — + * caller computes computeFxRepay({ rawDebts, shieldedFxUSD, + * desiredRepayAmount, repayFeeRatio, railgunUnshieldFeeBps }) → + * { repayAmount, approveAmount } and passes both here. + */ +export class FxMintRepayDebtRecipe extends Recipe { + readonly id = 'fxmint-repay-debt-v1'; + readonly config: RecipeConfig = { + name: 'fxMINT Repay Debt', + description: + 'Burn fxUSD to reduce debt on an f(x) Long position; collateral unchanged.', + minGasLimit: 1_500_000n, + }; + + constructor(private readonly opts: FxMintRepayDebtRecipeOpts) { + super(); + // Recipe-level guards — fail at construction so callers see the misuse + // before gas-estimate / dry-run, with a recipe-specific message rather + // than a downstream PoolManager revert. Two distinct failure modes: + // + // 1. repayAmount <= 0 — wrong recipe (use FxMintBorrowMoreRecipe for + // debtDelta > 0). Step-level guard would catch (0, 0) but accepts + // negative debtDelta as a repay; we want this recipe to refuse + // anything but a strictly positive repay magnitude. + // + // 2. approveAmount != repayAmount × (FEE_DENOM + repayFeeRatio) / + // FEE_DENOM — caller hand-rolled the fee uplift and got it wrong. + // PoolManager.operate() will pull exactly this amount via + // transferFrom; a mismatch lands the recipe-engine's step-validator + // with a generic "input != spent+outputs+fees" error far from the + // cause. Enforce strict equality here so the error names the + // actual problem and points at computeFxRepay. + if (opts.repayAmount <= 0n) { + throw new Error( + 'fxmint: FxMintRepayDebtRecipe — repayAmount must be > 0', + ); + } + const expectedApprove = + (opts.repayAmount * (FEE_DENOM + opts.repayFeeRatio)) / FEE_DENOM; + if (opts.approveAmount !== expectedApprove) { + throw new Error( + `fxmint: FxMintRepayDebtRecipe — approveAmount must equal repayAmount × (FEE_DENOM + repayFeeRatio) / FEE_DENOM ` + + `(expected ${expectedApprove}, got ${opts.approveAmount}). Use computeFxRepay to derive both fields.`, + ); + } + } + + protected supportsNetwork(networkName: NetworkName): boolean { + return networkName === NetworkName.Ethereum; + } + + protected async getInternalSteps(_first: StepInput): Promise { + const pool = resolvePool(this.opts.pool); + const fxUSD = FX_ADDRESSES.fxUSD as Address; + const poolManager = FX_ADDRESSES.fxPoolManager as Address; + + // Two-step recipe: approve fxUSD to PoolManager, then operate() with a + // negative debtDelta (== repay). + // + // Approve amount = repayAmount × (FEE_DENOM + repayFeeRatio) / FEE_DENOM + // — caller pre-computes both via computeFxRepay and passes them in. + // We do NOT recompute here: the same uplift formula is also applied + // to the unshield path's railgun fee, and the only authoritative + // source is computeFxRepay. + // + // debtDelta = -repayAmount (signed; the step's operate() helper + // handles negative magnitudes as repay branches in PoolManager). + return [ + new ApproveERC20SpenderStep( + poolManager, + { tokenAddress: fxUSD, decimals: 18n }, + this.opts.approveAmount, + ), + new FxMintAdjustPositionStep({ + pool: pool.address, + collateralToken: pool.collateralToken, + collateralDecimals: pool.collateralDecimals, + positionId: this.opts.positionId, + collDelta: 0n, + debtDelta: -this.opts.repayAmount, + repayFeeRatio: this.opts.repayFeeRatio, + }), + ]; + } +} 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 new file mode 100644 index 0000000..7dffb7d --- /dev/null +++ b/src/recipes/borrow/fx/fx-mint-topup-and-borrow-recipe.ts @@ -0,0 +1,197 @@ +import { Recipe } from '../../recipe'; +import { ApproveERC20SpenderStep, ZeroXV2SwapStep, Step } from '../../../steps'; +import type { + RecipeConfig, + StepInput, + SwapQuoteData, +} from '../../../models/export-models'; +import { NetworkName } from '@railgun-community/shared-models'; +import { + FX_ADDRESSES, + resolvePool, + type Address, + type FxMintPoolRef, +} from '../../../steps/borrow/fx/fx-mint-util'; +import { FxMintAdjustPositionStep } from '../../../steps/borrow/fx/fx-mint-adjust-position-step'; +import { validatePoolFlow } from './fx-mint-open-recipe'; + +export type FxMintTopupAndBorrowRecipeOpts = { + pool: FxMintPoolRef; + /** Existing position to lever-up. Same NFT in and out (operate adjusts state, doesn't reissue). */ + positionId: bigint; + /** + * Additional fxUSD to mint on top of the existing position debt. + * + * Must be > 0. If you want to top up collateral WITHOUT minting more + * debt, use FxMintTopupRecipe (Task 9). If you want to mint without + * adding collateral, use FxMintBorrowMoreRecipe (Task 11). + */ + additionalDebt: bigint; + /** + * f(x)'s borrow fee for this (pool, operator), 1e9-denominated. + * Caller fetches via PoolConfiguration.getPoolFeeRatio(pool, operator)[2] + * (see getFxPool in cookbook/src/api/borrow/fx — Task 13). + * Required because debtDelta > 0 by definition for this recipe. + */ + borrowFeeRatio: bigint; + /** + * 0x v2 swap quote (input → collateralToken). + * + * Required iff input asset doesn't match pool.collateralToken + * (wstETH-Long with WETH input). Forbidden for WBTC-Long. + * Custom pool refs are trusted via validatePoolFlow. + */ + swapQuote?: SwapQuoteData; + /** Required iff swapQuote provided. */ + slippageBasisPoints?: number; +}; + +/** + * Cookbook recipe: Top up collateral AND borrow additional fxUSD against + * the (now-larger) position in a single PoolManager.operate() call. + * + * Two paths, branched on swapQuote presence (mirrors open/close/topup): + * + * swap path (wstETH-Long, WETH input): + * approve(0x AllowanceTarget, WETH) → swap(WETH → wstETH) + * → approve(PoolManager, wstETH) → operate(positionId, +coll, +debt) + * + * direct path (WBTC-Long, WBTC input): + * approve(PoolManager, WBTC) → operate(positionId, +coll, +debt) + * + * Inputs (RecipeInput): WETH (swap path) or pool collateral (direct), plus + * the position NFT. + * Outputs (shielded back): position NFT (same id) + fxUSD (post-borrow-fee + * net = additionalDebt × (FEE_DENOM - borrowFeeRatio) / FEE_DENOM, declared + * by FxMintAdjustPositionStep). + * + * Semantically "lever-up" — increases both collateral AND exposure. For + * "top up, keep debt unchanged," use FxMintTopupRecipe. For "borrow more + * without adding collateral," use FxMintBorrowMoreRecipe. The single + * operate() call is what makes lever-up cheaper than topup + borrowMore + * back-to-back: one rate-bound check, one storage write. + * + * Builds on FxMintAdjustPositionStep with collDelta > 0, debtDelta > 0. + */ +export class FxMintTopupAndBorrowRecipe extends Recipe { + readonly id = 'fxmint-topup-and-borrow-v1'; + readonly config: RecipeConfig = { + name: 'fxMINT Topup + Borrow', + description: 'Add collateral and mint additional fxUSD in one operate().', + minGasLimit: 1_500_000n, + }; + + constructor(private readonly opts: FxMintTopupAndBorrowRecipeOpts) { + 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); + if (opts.swapQuote && opts.slippageBasisPoints === undefined) { + throw new Error( + 'fxmint: slippageBasisPoints required when swapQuote provided', + ); + } + // additionalDebt > 0 is the definitional constraint: this recipe + // exists for the lever-up case. 0n debtDelta would route through + // FxMintTopupRecipe; <0 would burn fxUSD which is the repay axis + // (FxMintRepayDebtRecipe / FxMintCloseRecipe). Catching here gives + // a clear error vs. a confusing low-level mismatch later. + if (opts.additionalDebt <= 0n) { + throw new Error( + 'fxmint: FxMintTopupAndBorrowRecipe.additionalDebt must be > 0; use FxMintTopupRecipe for collateral-only top-up', + ); + } + } + + protected supportsNetwork(networkName: NetworkName): boolean { + return networkName === NetworkName.Ethereum; + } + + protected async getInternalSteps(first: StepInput): Promise { + const pool = resolvePool(this.opts.pool); + const poolManager = FX_ADDRESSES.fxPoolManager as Address; + + // Determine collDelta for the adjust step: + // + // swap path: encode from the 0x quote's promised buy amount. + // The actual collateral arriving at the adjust step is + // non-deterministic (slippage) — but FxMintAdjustPositionStep + // declares spentERC20Amounts using inputColl.expectedBalance, so + // accounting tracks the true amount even though `collDelta` here + // is the optimistic quoted amount. Mirrors FxMintTopupRecipe. + // + // direct path: read from RecipeInput.erc20Amounts — the caller's + // input collateral is what gets deposited as-is. `first` is the + // StepInput passed by the recipe engine (Recipe.getRecipeOutput + // calls getInternalSteps with the initial step input populated + // from RecipeInput), so first.erc20Amounts has the input balances. + let collDelta: bigint; + if (this.opts.swapQuote) { + collDelta = this.opts.swapQuote.buyERC20Amount.amount; + } else { + const collInput = first.erc20Amounts.find( + a => + a.tokenAddress.toLowerCase() === pool.collateralToken.toLowerCase(), + ); + if (!collInput) { + // Direct path needs the pool collateral in RecipeInput. Throwing + // here gives a clear error if the caller forgot to include it + // (vs. a confusing low-level revert at gas-estimate time). + throw new Error( + `fxmint: FxMintTopupAndBorrowRecipe — no ${pool.collateralToken} in input.erc20Amounts (direct path requires collateral input)`, + ); + } + collDelta = collInput.expectedBalance; + } + + // Both deltas non-zero: collDelta > 0 (collateral added), debtDelta > 0 + // (fxUSD minted). Step requires borrowFeeRatio when debtDelta > 0 so + // it can declare the post-fee fxUSD output amount. We do NOT pass + // repayFeeRatio because debtDelta is positive (mint, not burn). + const adjustStep = new FxMintAdjustPositionStep({ + pool: pool.address, + collateralToken: pool.collateralToken, + collateralDecimals: pool.collateralDecimals, + positionId: this.opts.positionId, + collDelta, + debtDelta: this.opts.additionalDebt, + borrowFeeRatio: this.opts.borrowFeeRatio, + }); + + if (this.opts.swapQuote) { + // Swap path: WETH input → 0x swap → wstETH → PoolManager.operate. + // Leading approve(0x AllowanceTarget) is required even with + // ZeroXV2SwapStep (which verifies but does not grant the approval). + const inputToken = FX_ADDRESSES.WETH as Address; + return [ + new ApproveERC20SpenderStep(this.opts.swapQuote.spender, { + tokenAddress: inputToken, + decimals: 18n, + }), + new ZeroXV2SwapStep(this.opts.swapQuote, { + tokenAddress: inputToken, + decimals: 18n, + }), + // Post-swap approve + operate consume whatever the swap produced + // (non-deterministic by slippage); cookbook rejects fixed amounts + // on steps following a non-deterministic step, so we omit the + // explicit amount on the approve and let it use expectedBalance. + new ApproveERC20SpenderStep(poolManager, { + tokenAddress: pool.collateralToken, + decimals: pool.collateralDecimals, + }), + adjustStep, + ]; + } + + // Direct path: input asset already matches pool collateral; no swap + // leg. Approve PoolManager and call operate() with both deltas. + return [ + new ApproveERC20SpenderStep(poolManager, { + tokenAddress: pool.collateralToken, + decimals: pool.collateralDecimals, + }), + adjustStep, + ]; + } +} diff --git a/src/recipes/borrow/fx/fx-mint-topup-recipe.ts b/src/recipes/borrow/fx/fx-mint-topup-recipe.ts new file mode 100644 index 0000000..e98c3a2 --- /dev/null +++ b/src/recipes/borrow/fx/fx-mint-topup-recipe.ts @@ -0,0 +1,171 @@ +import { Recipe } from '../../recipe'; +import { ApproveERC20SpenderStep, ZeroXV2SwapStep, Step } from '../../../steps'; +import type { + RecipeConfig, + StepInput, + SwapQuoteData, +} from '../../../models/export-models'; +import { NetworkName } from '@railgun-community/shared-models'; +import { + FX_ADDRESSES, + resolvePool, + type Address, + type FxMintPoolRef, +} from '../../../steps/borrow/fx/fx-mint-util'; +import { FxMintAdjustPositionStep } from '../../../steps/borrow/fx/fx-mint-adjust-position-step'; +import { validatePoolFlow } from './fx-mint-open-recipe'; + +export type FxMintTopupRecipeOpts = { + pool: FxMintPoolRef; + /** Existing position to top up. */ + positionId: bigint; + /** + * 0x v2 swap quote (input → collateralToken). + * + * Required iff input asset doesn't match pool.collateralToken + * (wstETH-Long with WETH input). Forbidden for WBTC-Long. + * Custom pool refs are trusted via validatePoolFlow. + */ + swapQuote?: SwapQuoteData; + /** Required iff swapQuote provided. */ + slippageBasisPoints?: number; +}; + +/** + * Cookbook recipe: Top up collateral on an existing f(x) Long position + * WITHOUT changing debt. + * + * Two paths, branched on swapQuote presence (mirrors open/close): + * + * swap path (wstETH-Long, WETH input): + * approve(0x AllowanceTarget, WETH) → swap(WETH → wstETH) + * → approve(PoolManager, wstETH) → operate(positionId, +coll, 0) + * + * direct path (WBTC-Long, WBTC input): + * approve(PoolManager, WBTC) → operate(positionId, +coll, 0) + * + * Inputs (RecipeInput): WETH (swap path) or pool collateral (direct), plus + * the position NFT. + * Outputs (shielded back): position NFT (same id; survives the topup — + * adjust ops never change positionId, only full close burns). + * + * No fxUSD output — debt is unchanged. For "top up + borrow more in one + * tx," use FxMintTopupAndBorrowRecipe (Task 10). For "borrow more without + * adding collateral," use FxMintBorrowMoreRecipe (Task 11). + * + * Builds on FxMintAdjustPositionStep with collDelta > 0, debtDelta = 0n. + * Since debtDelta = 0n the step does NOT require borrowFeeRatio / + * repayFeeRatio (those are debt-axis only — see step-level guard). + */ +export class FxMintTopupRecipe extends Recipe { + readonly id = 'fxmint-topup-v1'; + readonly config: RecipeConfig = { + name: 'fxMINT Topup', + description: 'Add collateral to an f(x) Long position; debt unchanged.', + minGasLimit: 1_500_000n, + }; + + constructor(private readonly opts: FxMintTopupRecipeOpts) { + 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); + if (opts.swapQuote && opts.slippageBasisPoints === undefined) { + throw new Error( + 'fxmint: slippageBasisPoints required when swapQuote provided', + ); + } + } + + protected supportsNetwork(networkName: NetworkName): boolean { + return networkName === NetworkName.Ethereum; + } + + protected async getInternalSteps(first: StepInput): Promise { + const pool = resolvePool(this.opts.pool); + const poolManager = FX_ADDRESSES.fxPoolManager as Address; + + // Determine collDelta for the adjust step: + // + // swap path: encode from the 0x quote's promised buy amount. + // The actual collateral arriving at the adjust step is + // non-deterministic (slippage) — but FxMintAdjustPositionStep + // declares spentERC20Amounts using inputColl.expectedBalance, + // which is the QUOTED (not on-chain-actual) amount — cookbook's + // amount-accounting uses expected/min balance pairs precisely + // because the step layer can't know on-chain actual delivery. + // Mirrors FxMintOpenPositionStep. + // + // direct path: read from RecipeInput.erc20Amounts — the caller's + // input collateral is what gets deposited as-is. `first` is the + // StepInput passed by the recipe engine (Recipe.getRecipeOutput + // calls getInternalSteps with the initial step input populated + // from RecipeInput), so first.erc20Amounts has the input balances. + let collDelta: bigint; + if (this.opts.swapQuote) { + collDelta = this.opts.swapQuote.buyERC20Amount.amount; + } else { + const collInput = first.erc20Amounts.find( + a => + a.tokenAddress.toLowerCase() === pool.collateralToken.toLowerCase(), + ); + if (!collInput) { + // Direct path needs the pool collateral in RecipeInput. Throwing + // here gives a clear error if the caller forgot to include it + // (vs. a confusing low-level revert at gas-estimate time). + throw new Error( + `FxMintTopupRecipe: no ${pool.collateralToken} in input.erc20Amounts (direct path requires collateral input)`, + ); + } + collDelta = collInput.expectedBalance; + } + + // debtDelta = 0n — pure collateral top-up, no fxUSD mint or burn. + // Step accepts (collDelta>0, debtDelta=0) without requiring borrowFee + // /repayFee ratios; only operate(0,0) would revert. + const adjustStep = new FxMintAdjustPositionStep({ + pool: pool.address, + collateralToken: pool.collateralToken, + collateralDecimals: pool.collateralDecimals, + positionId: this.opts.positionId, + collDelta, + debtDelta: 0n, + }); + + if (this.opts.swapQuote) { + // Swap path: WETH input → 0x swap → wstETH → PoolManager.operate. + // Leading approve(0x AllowanceTarget) is required even with + // ZeroXV2SwapStep (which verifies but does not grant the approval). + const inputToken = FX_ADDRESSES.WETH as Address; + return [ + new ApproveERC20SpenderStep(this.opts.swapQuote.spender, { + tokenAddress: inputToken, + decimals: 18n, + }), + new ZeroXV2SwapStep(this.opts.swapQuote, { + tokenAddress: inputToken, + decimals: 18n, + }), + // Post-swap approve + operate consume whatever the swap produced + // (non-deterministic by slippage); cookbook rejects fixed amounts + // on steps following a non-deterministic step, so we omit the + // explicit amount on the approve and let it use expectedBalance. + new ApproveERC20SpenderStep(poolManager, { + tokenAddress: pool.collateralToken, + decimals: pool.collateralDecimals, + }), + adjustStep, + ]; + } + + // Direct path: input asset already matches pool collateral; no swap + // leg. Approve PoolManager and call operate(). + return [ + new ApproveERC20SpenderStep(poolManager, { + tokenAddress: pool.collateralToken, + decimals: pool.collateralDecimals, + }), + adjustStep, + ]; + } +} diff --git a/src/recipes/borrow/index.ts b/src/recipes/borrow/index.ts new file mode 100644 index 0000000..308e019 --- /dev/null +++ b/src/recipes/borrow/index.ts @@ -0,0 +1,6 @@ +export * from './fx/fx-mint-open-recipe'; +export * from './fx/fx-mint-close-recipe'; +export * from './fx/fx-mint-topup-recipe'; +export * from './fx/fx-mint-topup-and-borrow-recipe'; +export * from './fx/fx-mint-borrow-more-recipe'; +export * from './fx/fx-mint-repay-debt-recipe'; diff --git a/src/recipes/index.ts b/src/recipes/index.ts index 2ef0ad2..8f8659a 100644 --- a/src/recipes/index.ts +++ b/src/recipes/index.ts @@ -3,3 +3,4 @@ export * from './adapt'; export * from './swap'; export * from './liquidity'; export * from './vault'; +export * from './borrow'; diff --git a/src/steps/borrow/fx/__tests__/fx-mint-adjust-position-step.test.ts b/src/steps/borrow/fx/__tests__/fx-mint-adjust-position-step.test.ts new file mode 100644 index 0000000..4a02e5d --- /dev/null +++ b/src/steps/borrow/fx/__tests__/fx-mint-adjust-position-step.test.ts @@ -0,0 +1,252 @@ +import chai from 'chai'; +import { FxMintAdjustPositionStep } from '../fx-mint-adjust-position-step'; +import { resolvePool, FX_ADDRESSES } from '../fx-mint-util'; +import { NetworkName, NFTTokenType } from '@railgun-community/shared-models'; +import type { StepInput } from '../../../../models/export-models'; + +const { expect } = chai; + +describe('FxMintAdjustPositionStep', () => { + const pool = resolvePool('wstETH-Long'); + const positionId = 1903n; + + const baseData = { + pool: pool.address, + collateralToken: pool.collateralToken, + collateralDecimals: pool.collateralDecimals, + positionId, + }; + + function inputWithCollateral(amount: bigint): StepInput { + return { + networkName: NetworkName.Ethereum, + erc20Amounts: [ + { + tokenAddress: pool.collateralToken, + decimals: pool.collateralDecimals, + isBaseToken: false, + expectedBalance: amount, + minBalance: amount, + approvedSpender: undefined, + }, + ], + nfts: [ + { + nftAddress: pool.address, + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ], + }; + } + + function inputWithFxUSD(amount: bigint): StepInput { + // Simulates the post-ApproveERC20SpenderStep state for the repay path: + // the approve step splits the raw input into (approvedSpender=PoolManager + // for the amount granted to operate's transferFrom) + (any remaining + // change with approvedSpender=undefined). For the repay unit test we + // pass exactly approveAmount with approvedSpender=PoolManager, no + // orphan — the post-fix orphan-pass-through logic in + // FxMintAdjustPositionStep correctly emits no orphan output here. + return { + networkName: NetworkName.Ethereum, + erc20Amounts: [ + { + tokenAddress: FX_ADDRESSES.fxUSD, + decimals: 18n, + isBaseToken: false, + expectedBalance: amount, + minBalance: amount, + approvedSpender: FX_ADDRESSES.fxPoolManager, + }, + ], + nfts: [ + { + nftAddress: pool.address, + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ], + }; + } + + it('topup (collDelta>0, debtDelta=0): consumes collateral, returns NFT, no fxUSD output', async () => { + const collDelta = 4_000_000_000_000_000n; + const step = new FxMintAdjustPositionStep({ + ...baseData, + collDelta, + debtDelta: 0n, + }); + + const output = await step.getValidStepOutput( + inputWithCollateral(collDelta), + ); + + expect(output.crossContractCalls).to.have.length(1); + expect(output.spentERC20Amounts).to.have.length(1); + expect(output.spentERC20Amounts![0].amount).to.equal(collDelta); + expect(output.outputERC20Amounts ?? []).to.have.length(0); + expect(output.outputNFTs).to.have.length(1); + expect(output.outputNFTs![0].tokenSubID).to.equal( + '0x' + positionId.toString(16), + ); + }); + + it('borrow-more (collDelta=0, debtDelta>0): no collateral spend, fxUSD output post-borrow-fee', async () => { + const debtDelta = 5_000_000_000_000_000_000n; + const step = new FxMintAdjustPositionStep({ + ...baseData, + collDelta: 0n, + debtDelta, + borrowFeeRatio: 5_000_000n, // 0.5% + }); + + // No collateral input needed for pure borrow-more. + const stepInput: StepInput = { + networkName: NetworkName.Ethereum, + erc20Amounts: [], + nfts: [ + { + nftAddress: pool.address, + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ], + }; + + const output = await step.getValidStepOutput(stepInput); + + expect(output.spentERC20Amounts ?? []).to.have.length(0); + expect(output.outputERC20Amounts).to.have.length(1); + expect(output.outputERC20Amounts![0].tokenAddress.toLowerCase()).to.equal( + FX_ADDRESSES.fxUSD.toLowerCase(), + ); + const expectedNet = debtDelta - (debtDelta * 5_000_000n) / 1_000_000_000n; + expect(output.outputERC20Amounts![0].expectedBalance).to.equal(expectedNet); + expect(output.outputNFTs).to.have.length(1); + }); + + it('topup-and-borrow (collDelta>0, debtDelta>0): both legs', async () => { + const collDelta = 4_000_000_000_000_000n; + const debtDelta = 2_000_000_000_000_000_000n; + const step = new FxMintAdjustPositionStep({ + ...baseData, + collDelta, + debtDelta, + borrowFeeRatio: 5_000_000n, + }); + + const output = await step.getValidStepOutput( + inputWithCollateral(collDelta), + ); + + expect(output.spentERC20Amounts).to.have.length(1); + expect(output.outputERC20Amounts).to.have.length(1); + expect(output.outputNFTs).to.have.length(1); + }); + + it('repay (collDelta=0, debtDelta<0): consumes fxUSD with fee uplift, no collateral output', async () => { + const debtDelta = -3_000_000_000_000_000_000n; + const step = new FxMintAdjustPositionStep({ + ...baseData, + collDelta: 0n, + debtDelta, + repayFeeRatio: 5_000_000n, + }); + + // Input fxUSD must equal the approve amount the recipe's preceding + // ApproveERC20SpenderStep authorizes — namely |debtDelta| × (1e9 + repayFeeRatio)/1e9. + // PoolManager pulls exactly that amount; cookbook's amount-balance + // validator requires input == spent + fees + outputs. + const fxUSDPulled = 3_015_000_000_000_000_000n; // |3e18| × (1e9 + 5e6)/1e9 + const output = await step.getValidStepOutput(inputWithFxUSD(fxUSDPulled)); + + // fxUSD spend = |debtDelta| × (1e9 + 5e6) / 1e9 = 3.015e18 + expect(output.spentERC20Amounts).to.have.length(1); + expect(output.spentERC20Amounts![0].tokenAddress.toLowerCase()).to.equal( + FX_ADDRESSES.fxUSD.toLowerCase(), + ); + expect(output.spentERC20Amounts![0].amount).to.equal( + 3_015_000_000_000_000_000n, + ); + + expect(output.outputERC20Amounts ?? []).to.have.length(0); + expect(output.outputNFTs).to.have.length(1); + }); + + it('throws when both deltas are zero', () => { + expect( + () => + new FxMintAdjustPositionStep({ + ...baseData, + collDelta: 0n, + debtDelta: 0n, + }), + ).to.throw(/at least one of/); + }); + + it('throws when debtDelta>0 and borrowFeeRatio missing', () => { + expect( + () => + new FxMintAdjustPositionStep({ + ...baseData, + collDelta: 0n, + debtDelta: 1n, + }), + ).to.throw(/borrowFeeRatio/); + }); + + it('throws when debtDelta<0 and repayFeeRatio missing', () => { + expect( + () => + new FxMintAdjustPositionStep({ + ...baseData, + collDelta: 0n, + debtDelta: -1n, + }), + ).to.throw(/repayFeeRatio/); + }); + + it('throws at getStepOutput when collDelta > 0 but no matching input', async () => { + // Runtime guard inside getStepOutput: even if the construction-time + // checks pass, if the upstream RecipeInput.erc20Amounts is missing + // the collateral token (e.g., caller wired the wrong token), the + // step refuses to fabricate a spentERC20Amounts entry. Mirrors the + // FxMintTopupRecipe direct-path guard but covers the case where the + // recipe layer didn't catch the mistake. + const step = new FxMintAdjustPositionStep({ + ...baseData, + collDelta: 1n, + debtDelta: 0n, + }); + const emptyInput: StepInput = { + networkName: NetworkName.Ethereum, + erc20Amounts: [], + nfts: [ + { + nftAddress: pool.address, + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ], + }; + // Step base class wraps thrown errors with "step is invalid." and + // preserves the original message via `cause`. Match the wrapped + // message at the outer layer and assert on the cause's message + // separately so we know the right branch fired. + let caught: Error | undefined; + try { + await step.getValidStepOutput(emptyInput); + } catch (e) { + caught = e as Error; + } + expect(caught).to.exist; + expect(caught!.message).to.match(/step is invalid/); + const cause = (caught as Error & { cause?: Error }).cause; + expect(cause?.message ?? '').to.match(/no input balance/); + }); +}); diff --git a/src/steps/borrow/fx/__tests__/fx-mint-close-position-step.test.ts b/src/steps/borrow/fx/__tests__/fx-mint-close-position-step.test.ts new file mode 100644 index 0000000..b294eb7 --- /dev/null +++ b/src/steps/borrow/fx/__tests__/fx-mint-close-position-step.test.ts @@ -0,0 +1,153 @@ +import chai from 'chai'; +import { FxMintClosePositionStep } from '../fx-mint-close-position-step'; +import { FX_ADDRESSES, resolvePool } from '../fx-mint-util'; +import { NetworkName, NFTTokenType } from '@railgun-community/shared-models'; +import type { StepInput } from '../../../../models/export-models'; + +const { expect } = chai; + +// Task 5 — verifies collateralDecimals is plumbed through every output/spent +// line that references the COLLATERAL token. fxUSD lines (which are always +// 18-decimal) must continue to carry decimals=18n. +// +// The bug we're guarding against: pre-Task-5 the close step hardcoded +// `decimals: 18n` on the collateral output, which would mis-account WBTC +// (8-decimal) by a factor of 10^10. +describe('FxMintClosePositionStep', () => { + // The PoolManager-approved fxUSD input must carry approveAmount = + // repayAmount + repayFee. Cookbook's accounting validator only accepts + // the input if `expectedBalance >= minBalance` and the step then declares + // `feeAmount = approveAmount - repayAmount` so input == spent + fee + + // outputs balances exactly. We use a tiny made-up fee so the math is + // self-consistent for the test — actual close-side fee comes from + // `computeFxClose` at the recipe layer. + const repayAmount = 5_000_000_000_000_000_000n; // 5 fxUSD + // 0.025 fxUSD = 0.5% of repayAmount. The on-chain f(x) repay fee is a + // 1e9-denominated ratio (see FEE_DENOM in fx-mint-util); this test just + // needs `approveAmount > repayAmount` so the close step's accounting + // (input == spent + fee + outputs) has a non-zero fee leg to declare. + const repayFee = 25_000_000_000_000_000n; + const approveAmount = repayAmount + repayFee; + const withdrawColl = 100_000n; // 0.001 WBTC at 8 decimals + const positionId = 1903n; + const fxUSD = FX_ADDRESSES.fxUSD; + const poolManager = FX_ADDRESSES.fxPoolManager; + + function makeStepInput(nftAddress: string): StepInput { + return { + networkName: NetworkName.Ethereum, + erc20Amounts: [ + { + tokenAddress: fxUSD, + decimals: 18n, + isBaseToken: false, + expectedBalance: approveAmount, + minBalance: approveAmount, + // Must match poolManager so the close step's lookup picks this up + // as the approved input (vs an "orphan" pass-through). + approvedSpender: poolManager, + }, + ], + nfts: [ + { + nftAddress, + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ], + }; + } + + it('plumbs collateralDecimals through to outputERC20Amounts (WBTC-Long, partial close)', async () => { + const wbtcPool = resolvePool('WBTC-Long'); + + const step = new FxMintClosePositionStep({ + pool: wbtcPool.address, + collateralToken: wbtcPool.collateralToken, + collateralDecimals: wbtcPool.collateralDecimals, // 8n + positionId, + repayAmount, + withdrawColl, + partialClose: true, + }); + + const output = await step.getValidStepOutput( + makeStepInput(wbtcPool.address), + ); + + // Every output/spent line referencing the WBTC collateral must carry decimals = 8. + const collateralLines = [ + ...(output.outputERC20Amounts ?? []), + ...(output.spentERC20Amounts ?? []), + ].filter( + l => + l.tokenAddress.toLowerCase() === wbtcPool.collateralToken.toLowerCase(), + ); + + expect(collateralLines).to.have.length.greaterThan(0); + for (const line of collateralLines) { + expect(line.decimals).to.equal(8n); + } + + // Sanity: any fxUSD lines should still carry decimals = 18. + const fxUSDLines = [ + ...(output.outputERC20Amounts ?? []), + ...(output.spentERC20Amounts ?? []), + ].filter(l => l.tokenAddress.toLowerCase() === fxUSD.toLowerCase()); + + for (const line of fxUSDLines) { + expect(line.decimals).to.equal(18n); + } + + // Partial close: NFT survives operate() and shields back. Tightened + // from a `length > 0` smoke check (Task 5) to the exact contract: + // outputNFTs has the position NFT; spentNFTs is empty. + expect(output.outputNFTs).to.have.length(1); + expect(output.outputNFTs[0]?.nftAddress).to.equal(wbtcPool.address); + expect(output.outputNFTs[0]?.tokenSubID).to.equal( + '0x' + positionId.toString(16), + ); + expect(output.spentNFTs ?? []).to.have.length(0); + }); + + it('full-close (partialClose=false) still produces valid output for WBTC-Long', async () => { + const wbtcPool = resolvePool('WBTC-Long'); + + const step = new FxMintClosePositionStep({ + pool: wbtcPool.address, + collateralToken: wbtcPool.collateralToken, + collateralDecimals: wbtcPool.collateralDecimals, + positionId, + repayAmount, + withdrawColl, + partialClose: false, + }); + + const output = await step.getValidStepOutput( + makeStepInput(wbtcPool.address), + ); + + // Should produce at least one output/spent line for the WBTC collateral, all with decimals=8. + const collateralLines = [ + ...(output.outputERC20Amounts ?? []), + ...(output.spentERC20Amounts ?? []), + ].filter( + l => + l.tokenAddress.toLowerCase() === wbtcPool.collateralToken.toLowerCase(), + ); + expect(collateralLines).to.have.length.greaterThan(0); + for (const line of collateralLines) { + expect(line.decimals).to.equal(8n); + } + + // Full close: PoolManager.operate burns the NFT inside the call, so + // the step must declare the NFT as spent (consumed) and outputNFTs is + // empty. Tightened from Task 5's `length > 0` smoke check. + expect(output.outputNFTs ?? []).to.have.length(0); + const spentNFTs = output.spentNFTs ?? []; + expect(spentNFTs).to.have.length(1); + expect(spentNFTs[0]?.nftAddress).to.equal(wbtcPool.address); + expect(spentNFTs[0]?.tokenSubID).to.equal('0x' + positionId.toString(16)); + }); +}); diff --git a/src/steps/borrow/fx/__tests__/fx-mint-open-position-step.test.ts b/src/steps/borrow/fx/__tests__/fx-mint-open-position-step.test.ts new file mode 100644 index 0000000..c5cc6fa --- /dev/null +++ b/src/steps/borrow/fx/__tests__/fx-mint-open-position-step.test.ts @@ -0,0 +1,119 @@ +import chai from 'chai'; +import { FxMintOpenPositionStep } from '../fx-mint-open-position-step'; +import { FEE_DENOM, FX_ADDRESSES, resolvePool } from '../fx-mint-util'; +import { NetworkName, NFTTokenType } from '@railgun-community/shared-models'; +import type { StepInput } from '../../../../models/export-models'; + +const { expect } = chai; + +describe('FxMintOpenPositionStep', () => { + const wstETHPool = resolvePool('wstETH-Long'); + const wbtcPool = resolvePool('WBTC-Long'); + + const targetDebt = 5_000_000_000_000_000_000n; // 5 fxUSD + const predictedPositionId = 1903n; + + function makeStepInput( + collateralAmount: bigint, + decimals: bigint, + collateralToken: string, + ): StepInput { + return { + networkName: NetworkName.Ethereum, + erc20Amounts: [ + { + tokenAddress: collateralToken, + decimals, + isBaseToken: false, + expectedBalance: collateralAmount, + minBalance: collateralAmount, + approvedSpender: undefined, + }, + ], + nfts: [], + }; + } + + it('encodes operate() with the correct args (wstETH-Long)', async () => { + const step = new FxMintOpenPositionStep({ + pool: wstETHPool.address, + collateralToken: wstETHPool.collateralToken, + collateralDecimals: wstETHPool.collateralDecimals, + targetDebt, + predictedPositionId, + borrowFeeRatio: 5_000_000n, // 0.5% (current mainnet value, in 1e9 denom) + }); + + const collateralAmount = 4_000_000_000_000_000n; + const output = await step.getValidStepOutput( + makeStepInput(collateralAmount, 18n, wstETHPool.collateralToken), + ); + + // Single cross-contract call to PoolManager. + expect(output.crossContractCalls).to.have.length(1); + expect(output.crossContractCalls[0].to.toLowerCase()).to.equal( + FX_ADDRESSES.fxPoolManager.toLowerCase(), + ); + + // outputERC20Amounts: fxUSD post-borrow-fee net. + const expectedFxUSDNet = targetDebt - (targetDebt * 5_000_000n) / FEE_DENOM; + expect(output.outputERC20Amounts).to.have.length(1); + expect(output.outputERC20Amounts[0].tokenAddress.toLowerCase()).to.equal( + FX_ADDRESSES.fxUSD.toLowerCase(), + ); + expect(output.outputERC20Amounts[0].expectedBalance).to.equal( + expectedFxUSDNet, + ); + + // outputNFTs: position NFT at the pool address. + expect(output.outputNFTs).to.deep.equal([ + { + nftAddress: wstETHPool.address, + tokenSubID: '0x' + predictedPositionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ]); + + // spentERC20Amounts: collateral with the right decimals (18 here). + expect(output.spentERC20Amounts).to.deep.equal([ + { + tokenAddress: wstETHPool.collateralToken, + decimals: 18n, + amount: collateralAmount, + recipient: FX_ADDRESSES.fxPoolManager, + }, + ]); + }); + + it('encodes correctly with WBTC-Long collateralDecimals=8 and a different borrow fee', async () => { + const step = new FxMintOpenPositionStep({ + pool: wbtcPool.address, + collateralToken: wbtcPool.collateralToken, + collateralDecimals: wbtcPool.collateralDecimals, + targetDebt, + predictedPositionId, + borrowFeeRatio: 7_500_000n, // pretend governance moved it to 0.75% + }); + + const collateralAmount = 100_000n; // 0.001 WBTC at 8 decimals + const output = await step.getValidStepOutput( + makeStepInput(collateralAmount, 8n, wbtcPool.collateralToken), + ); + + // Net = targetDebt × (1e9 - 7.5e6) / 1e9 = 5e18 × 0.9925 = 4.9625e18 + expect(output.outputERC20Amounts[0].expectedBalance).to.equal( + 4_962_500_000_000_000_000n, + ); + + // Collateral-side decimals plumbed through correctly. + expect(output.spentERC20Amounts).to.deep.equal([ + { + tokenAddress: wbtcPool.collateralToken, + decimals: 8n, + amount: collateralAmount, + recipient: FX_ADDRESSES.fxPoolManager, + }, + ]); + }); +}); diff --git a/src/steps/borrow/fx/__tests__/fx-mint-util.test.ts b/src/steps/borrow/fx/__tests__/fx-mint-util.test.ts new file mode 100644 index 0000000..835ea58 --- /dev/null +++ b/src/steps/borrow/fx/__tests__/fx-mint-util.test.ts @@ -0,0 +1,141 @@ +import chai from 'chai'; +import { + resolvePool, + FX_ADDRESSES, + KNOWN_POOLS, + computeFxRepay, + computeFxClose, +} from '../fx-mint-util'; + +const { expect } = chai; + +describe('fx-mint-util — pool registry', () => { + it('resolvePool("wstETH-Long") returns collateralDecimals = 18n', () => { + const pool = resolvePool('wstETH-Long'); + expect(pool.collateralDecimals).to.equal(18n); + expect(pool.collateralToken.toLowerCase()).to.equal( + FX_ADDRESSES.wstETH.toLowerCase(), + ); + }); + + it('resolvePool("WBTC-Long") returns collateralDecimals = 8n', () => { + const pool = resolvePool('WBTC-Long'); + expect(pool.collateralDecimals).to.equal(8n); + expect(pool.collateralToken.toLowerCase()).to.equal( + '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', + ); + }); + + it('resolvePool with custom ref preserves collateralDecimals from caller', () => { + const pool = resolvePool({ + address: '0x000000000000000000000000000000000000beef', + collateralToken: '0x000000000000000000000000000000000000cafe', + collateralDecimals: 6n, + }); + expect(pool.collateralDecimals).to.equal(6n); + }); + + it('KNOWN_POOLS has collateralDecimals on every entry', () => { + for (const entry of KNOWN_POOLS) { + expect(entry.collateralDecimals).to.be.a('bigint'); + // chai 4.x's .gte typings don't accept bigint, so compare directly. + expect(entry.collateralDecimals >= 0n).to.equal(true); + } + }); + + it('resolvePool throws on unknown pool name', () => { + expect(() => resolvePool('UNKNOWN' as never)).to.throw( + /Unknown fxMINT pool/, + ); + }); + + it('FX_ADDRESSES no longer exposes the misleading fxPositionNFT constant', () => { + expect((FX_ADDRESSES as Record).fxPositionNFT).to.equal( + undefined, + ); + }); +}); + +describe('fx-mint-util — computeFxRepay', () => { + it('caps repayAmount at min(maxRepayUnderFee, rawDebts, desiredRepayAmount)', () => { + const result = computeFxRepay({ + rawDebts: 100_000_000_000_000_000_000n, // 100 fxUSD debt + shieldedFxUSD: 50_000_000_000_000_000_000n, // 50 fxUSD shielded + desiredRepayAmount: 30_000_000_000_000_000_000n, // wants to repay 30 + repayFeeRatio: 5_000_000n, // 0.5% (test value, not mainnet's 0.2%) + railgunUnshieldFeeBps: 25n, + }); + + // The 30 fxUSD desired is below both rawDebts and the post-fee max; + // expect repayAmount = 30 fxUSD exactly. + expect(result.repayAmount).to.equal(30_000_000_000_000_000_000n); + // approveAmount = 30e18 × (1e9 + 5e6) / 1e9 = 30.15e18. + expect(result.approveAmount).to.equal(30_150_000_000_000_000_000n); + }); + + it('caps repayAmount at maxRepayUnderFee when shieldedFxUSD is the binding constraint', () => { + const result = computeFxRepay({ + rawDebts: 1_000_000_000_000_000_000_000n, // 1000 fxUSD + shieldedFxUSD: 10_000_000_000_000_000_000n, // 10 fxUSD + desiredRepayAmount: 1_000_000_000_000_000_000_000n, // wants to repay all 1000 + repayFeeRatio: 5_000_000n, + railgunUnshieldFeeBps: 25n, + }); + + // 10 × (10000 - 25) / 10000 = 9.975 fxUSD post-unshield. + // 9.975 × 1e9 / (1e9 + 5e6) = 9.925373... fxUSD post-repay-fee. + expect(result.fxUSDAfterUnshield).to.equal(9_975_000_000_000_000_000n); + // chai's .lessThan/.greaterThan don't accept bigint in 4.x typings, so + // compare directly (same workaround as the .gte case above). + expect(result.repayAmount < 10_000_000_000_000_000_000n).to.equal(true); + expect(result.approveAmount <= 9_975_000_000_000_000_000n).to.equal(true); + }); + + it('caps repayAmount at rawDebts when desiredRepayAmount > rawDebts', () => { + const result = computeFxRepay({ + rawDebts: 50_000_000_000_000_000_000n, // 50 fxUSD + shieldedFxUSD: 1_000_000_000_000_000_000_000n, // way more than enough + desiredRepayAmount: 100_000_000_000_000_000_000n, // wants to over-repay + repayFeeRatio: 5_000_000n, + railgunUnshieldFeeBps: 25n, + }); + + expect(result.repayAmount).to.equal(50_000_000_000_000_000_000n); + }); +}); + +describe('fx-mint-util — computeFxClose still passes after refactor', () => { + it('produces same output as before the refactor (golden value)', () => { + // Pin every field to a hand-computed literal so a subtle math + // regression (e.g., wrong precision constant, swapped operands) + // surfaces here. Derivation, with shieldedFxUSD=4.975e18, + // repayFee=5e6/1e9 (0.5%), unshieldFee=25bps: + // fxUSDAfterUnshield = 4.975e18 × 9975/10000 = 4_962_562_500_000_000_000 + // maxRepayUnderFee = 4_962_562_500_000_000_000 × 1e9 / (1e9 + 5e6) + // = 4_937_873_134_328_358_208 + // repayAmount = min(maxRepayUnderFee, rawDebts, desired=rawDebts) + // = 4_937_873_134_328_358_208 (capped by fee ceiling) + // approveAmount = repayAmount × (1e9 + 5e6) / 1e9 + // = 4_962_562_499_999_999_999 (off-by-1 from integer div) + // positionWstETH = rawColls × collBal / totalRaw = 8e15 + // withdrawColl = positionWstETH × repayAmount / rawDebts + // = 7_900_597_014_925_373 + const result = computeFxClose({ + rawColls: 8_000_000_000_000_000n, + rawDebts: 5_000_000_000_000_000_000n, + collateralBalance: 8_000_000_000_000_000n, + totalRawColls: 8_000_000_000_000_000n, + shieldedFxUSD: 4_975_000_000_000_000_000n, // = 5 fxUSD - 0.5% borrow fee + repayFeeRatio: 5_000_000n, + railgunUnshieldFeeBps: 25n, + }); + + expect(result.partialClose).to.equal(true); + expect(result.positionWstETH).to.equal(8_000_000_000_000_000n); + expect(result.fxUSDAfterUnshield).to.equal(4_962_562_500_000_000_000n); + expect(result.maxRepayUnderFee).to.equal(4_937_873_134_328_358_208n); + expect(result.repayAmount).to.equal(4_937_873_134_328_358_208n); + expect(result.approveAmount).to.equal(4_962_562_499_999_999_999n); + expect(result.withdrawColl).to.equal(7_900_597_014_925_373n); + }); +}); diff --git a/src/steps/borrow/fx/fx-mint-adjust-position-step.ts b/src/steps/borrow/fx/fx-mint-adjust-position-step.ts new file mode 100644 index 0000000..fcda7ef --- /dev/null +++ b/src/steps/borrow/fx/fx-mint-adjust-position-step.ts @@ -0,0 +1,278 @@ +import { Interface, type ContractTransaction } from 'ethers'; +import { NFTTokenType } from '@railgun-community/shared-models'; +import { Step } from '../../step'; +import type { + StepConfig, + StepInput, + UnvalidatedStepOutput, +} from '../../../models/export-models'; +import { + FX_ADDRESSES, + FX_POOL_MANAGER_ABI, + FEE_DENOM, + type Address, +} from './fx-mint-util'; + +export type FxMintAdjustPositionStepData = { + /** f(x) Pool address (also the position-NFT contract). */ + pool: Address; + /** Collateral token address for this pool. */ + collateralToken: Address; + /** + * Native-units decimals for the collateral token. Plumbed from the + * pool registry by the recipe (resolvePool().collateralDecimals). + * Cookbook's amount accounting must match the on-chain token's + * decimals; off by 10^(18-decimals) for non-18-decimal collateral + * if mis-set. + */ + collateralDecimals: bigint; + /** Existing position to adjust. */ + positionId: bigint; + /** + * Signed collateral delta, in native collateral units: + * > 0: deposit additional collateral (relay-adapter must hold the amount) + * < 0: withdraw collateral (out of v0.1 scope; reserved for v0.2's + * FxMintWithdrawCollateralRecipe — the step accepts the value + * but no v0.1 recipe wires it through). + * = 0: no-op on the collateral axis + */ + collDelta: bigint; + /** + * Signed debt delta, in fxUSD wei: + * > 0: mint additional fxUSD (PoolManager pays the relay-adapter, + * net of borrow fee). + * < 0: burn fxUSD to repay debt (PoolManager pulls fxUSD from the + * relay-adapter, including the f(x) repay fee uplift; recipe's + * preceding ApproveERC20SpenderStep authorizes the pull). + * = 0: no-op on the debt axis + */ + debtDelta: bigint; + /** + * f(x)'s borrow fee for this (pool, operator), 1e9-denominated. + * Required iff debtDelta > 0; ignored otherwise. Caller fetches via + * PoolConfiguration.getPoolFeeRatio(pool, operator)[2] (tuple index + * confirmed in discovery-notes.md). + */ + borrowFeeRatio?: bigint; + /** + * f(x)'s repay fee for this (pool, operator), 1e9-denominated. + * Required iff debtDelta < 0; ignored otherwise. + * PoolConfiguration.getPoolFeeRatio(pool, operator)[3]. + */ + repayFeeRatio?: bigint; +}; + +/** + * Encodes `PoolManager.operate(pool, positionId, collDelta, debtDelta)` for + * an existing position. Signed deltas express any combination of (deposit, + * withdraw, mint, burn) on the collateral and debt axes in a single tx. + * + * The position NFT is provided in RecipeInput.nfts at the recipe layer and + * re-declared in this step's `outputNFTs` with the same tokenSubID, so the + * recipe-engine epilogue shields the same NFT back to the user — adjusting + * a position never changes its identifier. We do NOT use `spentNFTs` (the + * step-validator combines spent+output NFTs into a single map and rejects + * duplicates); this matches FxMintClosePositionStep's partial-close branch. + * Full close, which burns the NFT, is handled by FxMintClosePositionStep, + * not here. + * + * Step output shape is conditional: + * - collDelta > 0: declares spentERC20Amounts for the input collateral + * (looked up from input.erc20Amounts matching collateralToken; + * amount = inputColl.expectedBalance, mirroring FxMintOpenPositionStep). + * - debtDelta > 0: declares outputERC20Amounts for the post-borrow-fee + * fxUSD net (= debtDelta × (FEE_DENOM - borrowFeeRatio) / FEE_DENOM). + * - debtDelta < 0: declares spentERC20Amounts for the fxUSD consumed + * (= |debtDelta| × (FEE_DENOM + repayFeeRatio) / FEE_DENOM; PoolManager + * pulls this amount from the relay-adapter via the recipe's preceding + * approve step). Matches the close-side accounting pattern. + * + * v0.1 recipes only use non-negative collDelta. Negative-collDelta is + * accommodated structurally for v0.2's withdraw-collateral recipe, but + * no v0.1 recipe constructs the step with collDelta < 0. + */ +export class FxMintAdjustPositionStep extends Step { + readonly config: StepConfig = { + name: 'f(x) Adjust Position', + description: + 'Calls PoolManager.operate(pool, positionId, collDelta, debtDelta). Signed deltas; same NFT in/out.', + hasNonDeterministicOutput: false, + }; + + private readonly data: FxMintAdjustPositionStepData; + + constructor(data: FxMintAdjustPositionStepData) { + super(); + + if (data.collDelta === 0n && data.debtDelta === 0n) { + throw new Error( + 'FxMintAdjustPositionStep: at least one of collDelta and debtDelta must be non-zero (operate(0,0) would revert)', + ); + } + if (data.debtDelta > 0n && data.borrowFeeRatio === undefined) { + throw new Error( + 'FxMintAdjustPositionStep: borrowFeeRatio required when debtDelta > 0', + ); + } + if (data.debtDelta < 0n && data.repayFeeRatio === undefined) { + throw new Error( + 'FxMintAdjustPositionStep: repayFeeRatio required when debtDelta < 0', + ); + } + + this.data = data; + } + + protected async getStepOutput( + input: StepInput, + ): Promise { + const { + pool, + collateralToken, + collateralDecimals, + positionId, + collDelta, + debtDelta, + borrowFeeRatio, + repayFeeRatio, + } = this.data; + const poolManager = FX_ADDRESSES.fxPoolManager as Address; + const fxUSD = FX_ADDRESSES.fxUSD as Address; + + // Encoded operate() — passes signed deltas straight through. The Pool + // contract handles the rawColls/rawDebts internal conversion. Using + // ethers' Interface (matches cookbook house style; we don't ship a + // typechain binding for the f(x) PoolManager). + const iface = new Interface(FX_POOL_MANAGER_ABI); + const callData = iface.encodeFunctionData('operate', [ + pool, + positionId, + collDelta, + debtDelta, + ]); + + // ---- spentERC20Amounts ---- + const spentERC20Amounts: NonNullable< + UnvalidatedStepOutput['spentERC20Amounts'] + > = []; + + if (collDelta > 0n) { + // Caller (recipe) ensures the input collateral matches collDelta. + // Look up the input balance — the upstream may be a 0x swap with + // non-deterministic output, in which case the recipe encoded + // collDelta from the swap quote's promised buy amount but the + // step still consumes inputColl.expectedBalance for accounting. + const inputColl = input.erc20Amounts.find( + a => a.tokenAddress.toLowerCase() === collateralToken.toLowerCase(), + ); + if (!inputColl) { + throw new Error( + `FxMintAdjustPositionStep: no input balance for collateralToken ${collateralToken} (collDelta > 0 requires it)`, + ); + } + spentERC20Amounts.push({ + tokenAddress: collateralToken, + decimals: collateralDecimals, + amount: inputColl.expectedBalance, + recipient: poolManager, + }); + } + + if (debtDelta < 0n) { + // PoolManager.operate(... , -X) pulls X × (1e9 + repayFeeRatio)/1e9 + // of fxUSD from the relay-adapter via transferFrom (allowed by the + // recipe's preceding approve step). Declared here so cookbook's + // amount accounting reflects the actual outflow. + const debtBurn = -debtDelta; + const fxUSDPulled = (debtBurn * (FEE_DENOM + repayFeeRatio!)) / FEE_DENOM; + spentERC20Amounts.push({ + tokenAddress: fxUSD, + decimals: 18n, + amount: fxUSDPulled, + recipient: poolManager, + }); + } + + // ---- outputERC20Amounts ---- + const outputERC20Amounts: NonNullable< + UnvalidatedStepOutput['outputERC20Amounts'] + > = []; + + if (debtDelta > 0n) { + // Post-borrow-fee net fxUSD lands in the relay-adapter and shields + // back to the user. + const fxUSDNet = debtDelta - (debtDelta * borrowFeeRatio!) / FEE_DENOM; + outputERC20Amounts.push({ + tokenAddress: fxUSD, + decimals: 18n, + expectedBalance: fxUSDNet, + // Deterministic: debtDelta and borrowFeeRatio are both known at + // construction time. minBalance = expectedBalance keeps the + // output spendable by future fixed-amount steps without hitting + // step.ts:71-83's non-deterministic-input gate. + minBalance: fxUSDNet, + isBaseToken: false, + approvedSpender: undefined, + }); + } + + if (debtDelta < 0n) { + // Orphan fxUSD pass-through — mirrors FxMintClosePositionStep's + // pattern. The repay path receives the user's full shielded fxUSD + // (so the wallet doesn't have to know approveAmount when unshielding); + // ApproveERC20SpenderStep marks only `approveAmount` as + // approvedSpender=PoolManager, leaving the rest as orphan change. + // Cookbook's step-validator (validators/step-validator.ts) demands + // input == spent + outputs + fees per token at THIS step (recipe- + // engine epilogue shield-back happens AFTER all steps and isn't + // accounted for inside the step-level check). Pass the orphan + // through as an output so the balance closes to the wei. + const orphanFxUSD = input.erc20Amounts + .filter( + a => + a.tokenAddress.toLowerCase() === fxUSD.toLowerCase() && + a.approvedSpender?.toLowerCase() !== poolManager.toLowerCase(), + ) + .map(a => ({ + tokenAddress: fxUSD, + decimals: a.decimals, + expectedBalance: a.expectedBalance, + minBalance: a.minBalance, + isBaseToken: false, + approvedSpender: a.approvedSpender, + })); + outputERC20Amounts.push(...orphanFxUSD); + } + + // ---- outputNFTs ---- + // Position NFT survives every adjust op (only full close burns), so + // it's re-declared in `outputNFTs` with the same tokenSubID — the + // recipe-engine epilogue shields it back to the user. Matches + // FxMintClosePositionStep's PARTIAL-close branch (NFT survives → + // only outputNFTs); `spentNFTs` is reserved for the burn case. + // The step-validator (validators/step-validator.ts) accepts an + // inputNFT iff it appears in (spentNFTs ∪ outputNFTs), and rejects + // duplicates across that union — so we must not put the NFT in both. + const outputNFTs: NonNullable = [ + { + nftAddress: pool, + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ]; + + const tx: ContractTransaction = { + to: poolManager, + data: callData, + value: 0n, + }; + + return { + crossContractCalls: [tx], + spentERC20Amounts, + outputERC20Amounts, + outputNFTs, + }; + } +} diff --git a/src/steps/borrow/fx/fx-mint-close-position-step.ts b/src/steps/borrow/fx/fx-mint-close-position-step.ts new file mode 100644 index 0000000..7fe409b --- /dev/null +++ b/src/steps/borrow/fx/fx-mint-close-position-step.ts @@ -0,0 +1,195 @@ +import { Interface, type ContractTransaction } from 'ethers'; +import { NFTTokenType } from '@railgun-community/shared-models'; +import { Step } from '../../step'; +import type { + StepConfig, + StepInput, + UnvalidatedStepOutput, +} from '../../../models/export-models'; +import { + FX_ADDRESSES, + FX_POOL_MANAGER_ABI, + type Address, +} from './fx-mint-util'; + +export type FxMintClosePositionStepData = { + pool: Address; + collateralToken: Address; + /** + * Native-units decimals for the collateral token (wstETH = 18, WBTC = 8). + * Cookbook's amount accounting must match the on-chain token's decimals; + * pre-Task-5 this was hardcoded to 18n on the close-side output, which + * mis-accounted WBTC-Long by a factor of 10^10. Plumbed from the pool + * registry by the recipe (see `resolvePool().collateralDecimals` in + * fx-mint-util.ts). + * + * Note: f(x) has NO withdraw fee, so this value affects bookkeeping only — + * it does not change the on-chain math (the operate() args use raw + * collateral wei regardless). + */ + collateralDecimals: bigint; + positionId: bigint; + /** fxUSD debt to repay. PoolManager pulls `repayAmount × (1 + fee)` from msg.sender. */ + repayAmount: bigint; + /** Actual collateral wei to withdraw. */ + withdrawColl: bigint; + /** True if position survives (NFT shields back); false if full close (NFT burns). */ + partialClose: boolean; +}; + +/** + * Encodes `PoolManager.operate(pool, positionId, -withdrawColl, -repayAmount)`. + * + * Consumes fxUSD (relay-adapter must have approved PoolManager for + * `repayAmount × (1 + repayFeeRatio/1e9)`) and the position NFT; + * produces collateralToken (wstETH or WBTC) for the swap-back leg. + * + * For partial close the NFT survives and is shielded back. For full close + * (repayAmount == position's full debt) f(x) burns the NFT inside operate + * and `outputNFTs` stays empty. + */ +export class FxMintClosePositionStep extends Step { + readonly config: StepConfig = { + name: 'f(x) Close Position', + description: + 'Calls PoolManager.operate(pool, positionId, -coll, -debt). Burns fxUSD; releases collateral and (for full close) the position NFT.', + hasNonDeterministicOutput: false, + }; + + constructor(private readonly data: FxMintClosePositionStepData) { + super(); + } + + protected async getStepOutput( + input: StepInput, + ): Promise { + const { + pool, + collateralToken, + collateralDecimals, + positionId, + repayAmount, + withdrawColl, + partialClose, + } = this.data; + const poolManager = FX_ADDRESSES.fxPoolManager as Address; + const fxUSD = FX_ADDRESSES.fxUSD as Address; + + // The prior ApproveERC20SpenderStep declared `approveAmount` of fxUSD + // as approved-for-PoolManager. Cookbook's accounting requires every + // input wei to be classified as spent / fee / output. PoolManager + // pulls the full approveAmount from msg.sender; `repayAmount` of that + // is burned against the position, the rest is the f(x) repay fee + // (treasury). Read the actual approved input amount so we can + // declare the fee correctly. + const approvedFxUSDInput = input.erc20Amounts.find( + a => + a.tokenAddress.toLowerCase() === fxUSD.toLowerCase() && + a.approvedSpender?.toLowerCase() === poolManager.toLowerCase(), + ); + if (!approvedFxUSDInput) { + throw new Error( + `FxMintClosePositionStep: no PoolManager-approved fxUSD input found`, + ); + } + const approveAmount = approvedFxUSDInput.expectedBalance; + const feeAmount = approveAmount - repayAmount; + + // Cookbook's accounting validator sums ALL fxUSD inputs, including any + // non-approved orphans left by upstream steps' integer rounding (we + // observed a recurring 2-wei orphan from UnshieldDefaultStep + Approve + // step's change accounting). Pass those through as outputs so input == + // spent + fees + outputs balances to the wei. + const orphanFxUSD = input.erc20Amounts + .filter( + a => + a.tokenAddress.toLowerCase() === fxUSD.toLowerCase() && + a.approvedSpender?.toLowerCase() !== poolManager.toLowerCase(), + ) + .map(a => ({ + tokenAddress: fxUSD, + decimals: a.decimals, + expectedBalance: a.expectedBalance, + minBalance: a.minBalance, + isBaseToken: false, + approvedSpender: a.approvedSpender, + })); + + // Ethers' Interface for ABI encoding (cookbook house style: other + // steps use Contract.populateTransaction via typechain bindings; we + // use Interface directly since we don't ship a typechain binding for + // the f(x) PoolManager — just the ABI fragment in fx-mint-util). + const iface = new Interface(FX_POOL_MANAGER_ABI); + const callData = iface.encodeFunctionData('operate', [ + pool, + positionId, + -withdrawColl, + -repayAmount, + ]); + + const tx: ContractTransaction = { + to: poolManager, + data: callData, + value: 0n, + }; + + return { + crossContractCalls: [tx], + spentERC20Amounts: [ + { + tokenAddress: fxUSD, + decimals: 18n, + amount: repayAmount, // burned by PoolManager against position debt + recipient: poolManager, + }, + ], + ...(feeAmount > 0n && { + feeERC20AmountRecipients: [ + { + tokenAddress: fxUSD, + decimals: 18n, + amount: feeAmount, // f(x) repay fee → protocol treasury + recipient: poolManager, + }, + ], + }), + spentNFTs: partialClose + ? [] // partial: NFT stays with relay-adapter for shield-back + : [ + { + nftAddress: pool, + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ], + outputERC20Amounts: [ + { + tokenAddress: collateralToken, + // Plumbed from the pool registry via constructor arg. Was hardcoded + // 18n, which broke WBTC-Long (8-decimal) bookkeeping by 10^10. + decimals: collateralDecimals, + // PoolManager.operate withdraws exactly `withdrawColl` wstETH or + // reverts. Treating it as deterministic (min == expected) lets + // the downstream Approve(0x, withdrawColl) pass cookbook's + // "non-deterministic input" gate. + expectedBalance: withdrawColl, + minBalance: withdrawColl, + isBaseToken: false, + approvedSpender: undefined, + }, + ...orphanFxUSD, + ], + outputNFTs: partialClose + ? [ + { + nftAddress: pool, + tokenSubID: '0x' + positionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ] + : [], + }; + } +} diff --git a/src/steps/borrow/fx/fx-mint-open-position-step.ts b/src/steps/borrow/fx/fx-mint-open-position-step.ts new file mode 100644 index 0000000..6fca211 --- /dev/null +++ b/src/steps/borrow/fx/fx-mint-open-position-step.ts @@ -0,0 +1,165 @@ +import { Interface, type ContractTransaction } from 'ethers'; +import { NFTTokenType } from '@railgun-community/shared-models'; +import { Step } from '../../step'; +import type { + StepConfig, + StepInput, + UnvalidatedStepOutput, +} from '../../../models/export-models'; +import { + FX_ADDRESSES, + FX_POOL_MANAGER_ABI, + FEE_DENOM, + type Address, +} from './fx-mint-util'; + +export type FxMintOpenPositionStepData = { + /** f(x) Pool address. The position NFT is minted by this contract. */ + pool: Address; + /** Collateral token address (wstETH for wstETH-Long, WBTC for WBTC-Long). */ + collateralToken: Address; + /** + * Native-units decimals for the collateral token (wstETH = 18, WBTC = 8). + * Cookbook's amount accounting must match the on-chain token's decimals; + * this field is plumbed from the pool registry by the recipe (see + * `resolvePool().collateralDecimals` in fx-mint-util.ts). + */ + collateralDecimals: bigint; + /** Absolute fxUSD debt to mint. operate() mints exactly this amount. */ + targetDebt: bigint; + /** + * Predicted positionId from Pool.getNextPositionId() (caller queries + * before constructing the recipe). The Pool assigns this to the new + * position; we use it to declare the NFT in `outputNFTs` so the recipe + * engine's shield-back epilogue picks it up. + * + * If a different tx grabs the id first, the gas-estimate revert + * surfaces the race cleanly before any real ETH is spent. + */ + predictedPositionId: bigint; + /** + * f(x)'s borrow fee for this (pool, operator) pair, 1e9-denominated. + * Caller fetches via PoolConfiguration.getPoolFeeRatio(pool, operator)[2] + * (tuple confirmed in discovery-notes.md). Previously hardcoded to + * `5n / 1000n` (= 0.5% in /1000 denom — wrong denom AND wrong value if + * f(x) governance moves the rate). Now dynamic and using FEE_DENOM (1e9). + */ + borrowFeeRatio: bigint; +}; + +/** + * Encodes `PoolManager.operate(pool, 0, +collateralAmount, +targetDebt)`. + * + * Goes through PoolManager (not Pool directly) — Pool.operate reverts with + * `ErrorCallerNotPoolManager` for any caller other than PoolManager. + * + * Consumes collateralToken from the relay adapter; produces fxUSD + an + * ERC-721 position NFT (owner = msg.sender = relay adapter). + * + * The post-fee fxUSD output `expectedBalance` accounts for f(x)'s borrow + * fee (now driven by `borrowFeeRatio` from opts; was previously a + * hardcoded 0.5%). + */ +export class FxMintOpenPositionStep extends Step { + readonly config: StepConfig = { + name: 'f(x) Open Position', + description: + 'Calls PoolManager.operate(pool, 0, +collateral, +debt). Mints fxUSD and a position NFT to msg.sender.', + hasNonDeterministicOutput: false, // predictedPositionId is provided + }; + + constructor(private readonly data: FxMintOpenPositionStepData) { + super(); + } + + protected async getStepOutput( + input: StepInput, + ): Promise { + const { + pool, + collateralToken, + collateralDecimals, + targetDebt, + predictedPositionId, + borrowFeeRatio, + } = this.data; + const poolManager = FX_ADDRESSES.fxPoolManager as Address; + const fxUSD = FX_ADDRESSES.fxUSD as Address; + + // Read the actual collateral amount from the input — the upstream + // ZeroXV2SwapStep produces a non-deterministic amount (slippage), so + // we can't hardcode it at construction time. Use the swap output's + // `expectedBalance` (the quote's promised buy amount) as the value + // we encode into operate(), and consume that exact amount from the + // step input. + const inputColl = input.erc20Amounts.find( + a => a.tokenAddress.toLowerCase() === collateralToken.toLowerCase(), + ); + if (!inputColl) { + throw new Error( + `FxMintOpenPositionStep: no input balance for collateralToken ${collateralToken}`, + ); + } + const collateralAmount = inputColl.expectedBalance; + + // Encode operate(...) via ethers' Interface — matches the cookbook + // house style (other steps use ethers' Contract.populateTransaction; + // we use Interface directly because we're not maintaining a typechain + // binding for the f(x) PoolManager, just the ABI fragment in + // fx-mint-util). + const iface = new Interface(FX_POOL_MANAGER_ABI); + const callData = iface.encodeFunctionData('operate', [ + pool, + 0n, + collateralAmount, + targetDebt, + ]); + + // Borrow fee deduction: f(x) emits (targetDebt - fee) of fxUSD to + // msg.sender (the relay-adapter). Cookbook's StepOutputERC20Amount + // expects the post-fee net; the recipe layer doesn't fold this in. + // Using FEE_DENOM (1e9) — matches PoolConfiguration.getPoolFeeRatio's + // denominator and the existing computeFxClose convention. + const fxUSDNet = targetDebt - (targetDebt * borrowFeeRatio) / FEE_DENOM; + + const tx: ContractTransaction = { + to: poolManager, + data: callData, + value: 0n, + }; + + return { + crossContractCalls: [tx], + spentERC20Amounts: [ + { + tokenAddress: collateralToken, + decimals: collateralDecimals, + amount: collateralAmount, + recipient: poolManager, + }, + ], + outputERC20Amounts: [ + { + tokenAddress: fxUSD, + decimals: 18n, + expectedBalance: fxUSDNet, + // Deterministic: targetDebt and borrowFeeRatio are both known at + // construction time. Setting minBalance = expectedBalance lets + // future steps consume this fxUSD with a fixed amount without + // tripping step.ts:71-83's non-deterministic-input gate. + minBalance: fxUSDNet, + isBaseToken: false, + approvedSpender: undefined, + }, + ], + outputNFTs: [ + { + nftAddress: pool, + tokenSubID: '0x' + predictedPositionId.toString(16), + nftTokenType: NFTTokenType.ERC721, + amount: 1n, + }, + ], + }; + } +} diff --git a/src/steps/borrow/fx/fx-mint-util.ts b/src/steps/borrow/fx/fx-mint-util.ts new file mode 100644 index 0000000..e6a3d63 --- /dev/null +++ b/src/steps/borrow/fx/fx-mint-util.ts @@ -0,0 +1,585 @@ +import { getAddress as ethersGetAddress } from 'ethers'; + +/** + * Type alias for an EVM address. Structurally identical to viem's + * `Address` (a `\`0x${string}\`` template-literal brand) so cookbook + * consumers that ALSO use viem can pass these addresses straight into + * viem's strict-typed APIs without a cast. We don't pull in viem to get + * this — the brand is just a type-level convention. ethers itself + * accepts any string for addresses, so this is a pure type-system aid. + */ +export type Address = `0x${string}`; + +/** + * Ethers' `getAddress` checksums and validates, then returns plain + * `string`. We wrap it to cast the result back to the branded `Address` + * type so call sites (FX_ADDRESSES, KNOWN_POOLS, DEFAULT_FXMINT_OPERATOR) + * can stay tidy. + */ +const getAddress = (a: string): Address => ethersGetAddress(a) as Address; + +// ============================================================================= +// f(x) Protocol mainnet addresses (immutable contracts, sourced from +// @aladdindao/fx-sdk@1.0.5 dist/index.cjs and verified on Etherscan). +// ============================================================================= + +export const FX_ADDRESSES = { + WETH: getAddress('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'), + wstETH: getAddress('0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0'), + fxUSD: getAddress('0x085780639CC2cACd35E474e71f4d000e2405d8f6'), + fxPoolManager: getAddress('0x250893CA4Ba5d05626C785e8da758026928FCD24'), + // Note: there is no single "position NFT" address — each pool IS its own + // ERC-721 NFT contract (Pool inherits the ERC-721 interface). Use + // `pool.address` from KNOWN_POOLS / resolvePool for outputNFTs.nftAddress. + fxPoolConfiguration: getAddress('0x16b334f2644cc00b85DB1A1efF0C2C395e00C28d'), +} as const; + +/** + * Default operator for f(x) fee queries: the Railgun relay-adapter. + * + * Lives alongside FX_ADDRESSES so cookbook readers (getFxPool) and the + * CLI's cliConstants.relayAdapter point at the same constant — preventing + * the cookbook/CLI drift risk a separate declaration would invite. + * Third-party wallet integrators consuming the cookbook re-export this + * directly; the CLI imports it for its own scripts. + * + * If Railgun rotates the relay-adapter address on a future engine + * version, update this once here and the CLI's cliConstants in lockstep. + */ +export const DEFAULT_FXMINT_OPERATOR: Address = getAddress( + '0xAc9f360Ae85469B27aEDdEaFC579Ef2d052aD405', +); + +// ============================================================================= +// ABI fragments (verbatim from @aladdindao/fx-sdk@1.0.5 dist/index.js). +// ============================================================================= + +export const FX_POOL_ABI = [ + // --- Core position functions --- + + // operate — lines 1291–1339 of dist/index.js (AFPool_default) + { + inputs: [ + { + internalType: 'uint256', + name: 'positionId', + type: 'uint256', + }, + { + internalType: 'int256', + name: 'newRawColl', + type: 'int256', + }, + { + internalType: 'int256', + name: 'newRawDebt', + type: 'int256', + }, + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + ], + name: 'operate', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + { + internalType: 'int256', + name: '', + type: 'int256', + }, + { + internalType: 'int256', + name: '', + type: 'int256', + }, + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + + // getPosition — lines 988–1011 of dist/index.js (AFPool_default) + { + inputs: [ + { + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + ], + name: 'getPosition', + outputs: [ + { + internalType: 'uint256', + name: 'rawColls', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'rawDebts', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + + // getPositionDebtRatio — lines 1012–1030 of dist/index.js (AFPool_default) + { + inputs: [ + { + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + ], + name: 'getPositionDebtRatio', + outputs: [ + { + internalType: 'uint256', + name: 'debtRatio', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + + // --- ERC-721 functions (Pool IS the NFT contract) --- + + // ownerOf — lines 1340–1358 of dist/index.js (AFPool_default) + { + inputs: [ + { + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + ], + name: 'ownerOf', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + + // safeTransferFrom (without data) — lines 1583–1605 of dist/index.js (AFPool_default) + { + inputs: [ + { + internalType: 'address', + name: 'from', + type: 'address', + }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + ], + name: 'safeTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + + // safeTransferFrom (with data) — lines 1606–1633 of dist/index.js (AFPool_default) + { + inputs: [ + { + internalType: 'address', + name: 'from', + type: 'address', + }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + name: 'safeTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + + // setApprovalForAll — lines 1634–1651 of dist/index.js (AFPool_default) + { + inputs: [ + { + internalType: 'address', + name: 'operator', + type: 'address', + }, + { + internalType: 'bool', + name: 'approved', + type: 'bool', + }, + ], + name: 'setApprovalForAll', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + + // approve (ERC-721) — lines 746–763 of dist/index.js (AFPool_default) + { + inputs: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'tokenId', + type: 'uint256', + }, + ], + name: 'approve', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + + // getTotalRawCollaterals — pool-wide raw collateral total. Used together + // with PoolManager.getPoolInfo(pool).collateralBalance to convert a + // position's rawColls (collateral-value units) into actual wstETH wei. + { + inputs: [], + name: 'getTotalRawCollaterals', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + + // getLiquidateRatios — returns (debtRatio, bonusRatio). debtRatio is the + // 1e18-scaled liquidation threshold (matches getPositionDebtRatio's + // scale); bonusRatio is the 1e9-scaled liquidator bonus. Stored on Pool + // directly, not in PoolConfiguration. Confirmed via cast call: + // (0.95e18, 4e7) for both wstETH-Long and WBTC-Long. + { + inputs: [], + name: 'getLiquidateRatios', + outputs: [ + { internalType: 'uint256', name: '', type: 'uint256' }, + { internalType: 'uint256', name: '', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, + + // getRebalanceRatios — returns (debtRatio, bonusRatio). Same shape as + // getLiquidateRatios but for the REBALANCE threshold, which sits BELOW + // the liquidation threshold. When a position's debtRatio crosses the + // rebalance ratio, f(x)'s rebalancer service progressively unwinds + // collateral to keep the position from ever reaching the liquidation + // ratio (and getting fully seized with the 4% liquidator bonus). The + // bonusRatio here is the rebalancer's smaller share (2.5% on mainnet + // as of May 2026). Wallet integrators displaying position risk should + // surface this as a yellow zone between healthy and liquidated. + // Confirmed via cast call: (0.88e18, 2.5e7) for wstETH-Long. + { + inputs: [], + name: 'getRebalanceRatios', + outputs: [ + { internalType: 'uint256', name: '', type: 'uint256' }, + { internalType: 'uint256', name: '', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +// PoolManager — the user-facing router. The Pool's operate function +// throws ErrorCallerNotPoolManager when called by anyone other than this +// contract, so all mint/close calls must go through PoolManager.operate(pool, ...). +// ABI extracted from @aladdindao/fx-sdk@1.0.5 PoolManager_default — the +// 4-arg overload (useStable defaults to false; mint fxUSD path). +export const FX_POOL_MANAGER_ABI = [ + { + inputs: [ + { internalType: 'address', name: 'pool', type: 'address' }, + { internalType: 'uint256', name: 'positionId', type: 'uint256' }, + { internalType: 'int256', name: 'newColl', type: 'int256' }, + { internalType: 'int256', name: 'newDebt', type: 'int256' }, + ], + name: 'operate', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + // getPoolInfo — collateral/debt capacity + balances for a pool. Used to + // scale rawColls → actual wstETH (close.ts withdraw computation). + { + inputs: [{ internalType: 'address', name: 'pool', type: 'address' }], + name: 'getPoolInfo', + outputs: [ + { internalType: 'uint256', name: 'collateralCapacity', type: 'uint256' }, + { internalType: 'uint256', name: 'collateralBalance', type: 'uint256' }, + { internalType: 'uint256', name: 'rawCollateral', type: 'uint256' }, + { internalType: 'uint256', name: 'debtCapacity', type: 'uint256' }, + { internalType: 'uint256', name: 'debtBalance', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +// PoolConfiguration — per-operator fee table. Returns (supplyFeeRatio, +// withdrawFeeRatio, borrowFeeRatio, repayFeeRatio) scaled by 1e9. close.ts +// queries this to size the fxUSD approval correctly: PoolManager.operate +// pulls `repayAmount × (1 + repayFeeRatio/1e9)` from msg.sender. +export const FX_POOL_CONFIGURATION_ABI = [ + { + inputs: [ + { internalType: 'address', name: 'pool', type: 'address' }, + { internalType: 'address', name: 'operator', type: 'address' }, + ], + name: 'getPoolFeeRatio', + outputs: [ + { internalType: 'uint256', name: 'supplyFeeRatio', type: 'uint256' }, + { internalType: 'uint256', name: 'withdrawFeeRatio', type: 'uint256' }, + { internalType: 'uint256', name: 'borrowFeeRatio', type: 'uint256' }, + { internalType: 'uint256', name: 'repayFeeRatio', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +// ============================================================================= +// Pool registry (currently 2 mainnet pools: wstETH-Long, WBTC-Long). +// ============================================================================= + +export type FxMintPoolName = 'wstETH-Long' | 'WBTC-Long'; + +// FxMintPoolRef accepts either a registered pool name or a fully-specified +// custom-pool object. The custom-pool branch carries collateralDecimals +// because cookbook's amount accounting needs the right native-decimals +// value (WBTC = 8, wstETH = 18). For named pools the value is supplied +// from KNOWN_POOLS internally. +export type FxMintPoolRef = + | FxMintPoolName + | { + address: Address; + collateralToken: Address; + collateralDecimals: bigint; + }; + +/** + * Registry-entry shape for a known f(x) pool. Exported so downstream + * code that wants to type a "known pool" parameter (e.g., wallet UIs + * that bind pool metadata to a typed dropdown) can do so without + * redeclaring the shape. + */ +export type FxPoolEntry = { + name: FxMintPoolName; + address: Address; + collateralToken: Address; + // Native-units decimals for the collateral token. f(x)'s `operate()` + // takes raw amounts in this native scale; cookbook's amount metadata + // must match or its accounting will be off by 10^(18-decimals). + collateralDecimals: bigint; +}; + +export const KNOWN_POOLS: readonly FxPoolEntry[] = [ + { + name: 'wstETH-Long', + address: getAddress('0x6Ecfa38FeE8a5277B91eFdA204c235814F0122E8'), + collateralToken: FX_ADDRESSES.wstETH, + collateralDecimals: 18n, + }, + { + name: 'WBTC-Long', + address: getAddress('0xAB709e26Fa6B0A30c119D8c55B887DeD24952473'), + collateralToken: getAddress('0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599'), + collateralDecimals: 8n, + }, +] as const; + +export type ResolvedFxPool = { + address: Address; + collateralToken: Address; + collateralDecimals: bigint; +}; + +export function resolvePool(ref: FxMintPoolRef): ResolvedFxPool { + if (typeof ref === 'string') { + const found = KNOWN_POOLS.find(p => p.name === ref); + if (!found) { + throw new Error( + `Unknown fxMINT pool name: ${ref}. Known pools: ${KNOWN_POOLS.map( + p => p.name, + ).join(', ')}`, + ); + } + return { + address: found.address, + collateralToken: found.collateralToken, + collateralDecimals: found.collateralDecimals, + }; + } + return { + address: getAddress(ref.address), + collateralToken: getAddress(ref.collateralToken), + collateralDecimals: ref.collateralDecimals, + }; +} + +// ============================================================================= +// Close-side amount math. +// ============================================================================= + +export const FEE_DENOM = 1_000_000_000n; +export const BPS_DENOM = 10_000n; + +export type FxCloseInputs = { + rawColls: bigint; // Pool.getPosition()[0] + rawDebts: bigint; // Pool.getPosition()[1] + collateralBalance: bigint; // PoolManager.getPoolInfo(pool)[1] + totalRawColls: bigint; // Pool.getTotalRawCollaterals() + shieldedFxUSD: bigint; // wallet.balanceForERC20Token(fxUSD) + repayFeeRatio: bigint; // PoolConfiguration.getPoolFeeRatio(...)[3], 1e9-denominated + railgunUnshieldFeeBps: bigint; // 25 on mainnet +}; + +export type FxCloseAmounts = { + positionWstETH: bigint; + fxUSDAfterUnshield: bigint; + maxRepayUnderFee: bigint; + repayAmount: bigint; + approveAmount: bigint; + withdrawColl: bigint; + partialClose: boolean; +}; + +export function computeFxClose(input: FxCloseInputs): FxCloseAmounts { + const { rawColls, rawDebts, collateralBalance, totalRawColls } = input; + + // Repay-side math: factored out so FxMintRepayDebtRecipe shares it. + // For close, the user's "desired repay amount" is the entire debt — they + // want to close as much as they can given fxUSD availability + fees. + const repay = computeFxRepay({ + rawDebts, + shieldedFxUSD: input.shieldedFxUSD, + desiredRepayAmount: rawDebts, + repayFeeRatio: input.repayFeeRatio, + railgunUnshieldFeeBps: input.railgunUnshieldFeeBps, + }); + + // Collateral-side math: proportional withdraw at the repaid fraction. + // f(x) has no withdraw fee, so this is a clean ratio. Note the variable + // name is wstETH-Long-era — it's the native-collateral conversion + // regardless of which pool (WBTC-Long uses 8-decimal WBTC). Renaming + // would touch downstream callers, so out-of-scope here. + const positionWstETH = (rawColls * collateralBalance) / totalRawColls; + const withdrawColl = (positionWstETH * repay.repayAmount) / rawDebts; + const partialClose = repay.repayAmount < rawDebts; + + // FxRepayAmounts is a strict subset of FxCloseAmounts's repay-side + // fields (fxUSDAfterUnshield, maxRepayUnderFee, repayAmount, + // approveAmount), so spreading is type-safe and avoids the verbose + // per-field re-listing this used to do. + return { ...repay, positionWstETH, withdrawColl, partialClose }; +} + +// ============================================================================= +// Repay-side amount math, factored out so FxMintRepayDebtRecipe (which has +// no collateral side) can reuse the same fee accounting that computeFxClose +// uses for its repay leg. computeFxClose above delegates to this. +// ============================================================================= + +export type FxRepayInputs = { + // Position's current debt in fxUSD wei (Pool.getPosition(positionId)[1]). + rawDebts: bigint; + // Caller's available shielded fxUSD; the recipe input balance. + shieldedFxUSD: bigint; + // What the user wants to repay (in fxUSD wei). The function caps this + // against the rawDebts ceiling and the post-fee available ceiling. + desiredRepayAmount: bigint; + // PoolConfiguration.getPoolFeeRatio(pool, operator)[3] for repay fee + // (CONFIRMED in discovery-notes.md as index [3]; tuple is + // [supplyFeeRatio, withdrawFeeRatio, borrowFeeRatio, repayFeeRatio]). + // 1e9-denominated. + repayFeeRatio: bigint; + // Railgun's unshield fee in basis points (mainnet = 25). + railgunUnshieldFeeBps: bigint; +}; + +export type FxRepayAmounts = { + // shieldedFxUSD × (10000 - 25) / 10000 — what lands in the relay-adapter + // after Railgun's unshield haircut. + fxUSDAfterUnshield: bigint; + // The largest repayAmount that can be supported by fxUSDAfterUnshield + // given the f(x) repay fee uplift on PoolManager.transferFrom. + maxRepayUnderFee: bigint; + // The actual debt reduction f(x)'s operate() will perform. Capped at + // min(maxRepayUnderFee, rawDebts, desiredRepayAmount). + repayAmount: bigint; + // What PoolManager will pull from the relay-adapter, including the + // f(x) repay fee uplift: repayAmount × (1e9 + repayFeeRatio) / 1e9. + // The recipe's ApproveERC20SpenderStep needs this exact value. + approveAmount: bigint; +}; + +export function computeFxRepay(input: FxRepayInputs): FxRepayAmounts { + const { + rawDebts, + shieldedFxUSD, + desiredRepayAmount, + repayFeeRatio, + railgunUnshieldFeeBps, + } = input; + + if (railgunUnshieldFeeBps > BPS_DENOM) { + throw new Error( + `railgunUnshieldFeeBps must be <= ${BPS_DENOM}, got ${railgunUnshieldFeeBps}`, + ); + } + + const fxUSDAfterUnshield = + (shieldedFxUSD * (BPS_DENOM - railgunUnshieldFeeBps)) / BPS_DENOM; + const maxRepayUnderFee = + (fxUSDAfterUnshield * FEE_DENOM) / (FEE_DENOM + repayFeeRatio); + + // Three-way min: fee ceiling, debt ceiling, user's intent. + let repayAmount = maxRepayUnderFee; + if (rawDebts < repayAmount) repayAmount = rawDebts; + if (desiredRepayAmount < repayAmount) repayAmount = desiredRepayAmount; + + const approveAmount = (repayAmount * (FEE_DENOM + repayFeeRatio)) / FEE_DENOM; + + return { fxUSDAfterUnshield, maxRepayUnderFee, repayAmount, approveAmount }; +} diff --git a/src/steps/borrow/index.ts b/src/steps/borrow/index.ts new file mode 100644 index 0000000..79b5012 --- /dev/null +++ b/src/steps/borrow/index.ts @@ -0,0 +1,4 @@ +export * from './fx/fx-mint-open-position-step'; +export * from './fx/fx-mint-close-position-step'; +export * from './fx/fx-mint-adjust-position-step'; +export * from './fx/fx-mint-util'; diff --git a/src/steps/index.ts b/src/steps/index.ts index d6926ca..ad1c9e0 100644 --- a/src/steps/index.ts +++ b/src/steps/index.ts @@ -6,3 +6,4 @@ export * from './adapt'; export * from './token'; export * from './railgun'; export * from './swap'; +export * from './borrow';