From c7d0c1c4d48dd1ba9ed2337b0e3944c4c2258766 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Fri, 15 May 2026 17:10:07 +0530 Subject: [PATCH 01/42] feat: simple bridge function --- src/combined/BungeeOpenRouterV2Unchecked.sol | 76 ++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index 33674ea..a836eb2 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -69,6 +69,16 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { bool useFinalAmountAsValue; } + /// @dev Simplified bridge descriptor for the no-swap `bridge()` path. + /// The caller knows `finalAmount = inputAmount - feeAmount` before encoding, + /// so no amount-splicing or runtime value overrides are needed. + struct StaticBridgeData { + address target; + address approvalSpender; + uint256 value; + bytes data; + } + struct MonolithicExecution { InputData input; FeeData preFee; @@ -132,6 +142,72 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { _runMonolithic(exec); } + // ========================================================================= + // External: simple bridge path (no swap) + // ========================================================================= + + /** + * @notice Pull → optional pre-bridge fee → bridge, with no swap step. + * @dev `feeBytes` must be either empty (`0x`, skip fee) or exactly 64 bytes + * ABI-encoded as `(address receiver, uint256 amount)`. Any other + * length reverts with `InvalidExecution`. + * + * Because no swap is involved, `finalAmount = inputAmount - feeAmount` is + * fully knowable by the caller before signing. The caller must therefore + * bake the correct amount directly into `bridgeData.data` and set + * `bridgeData.value` to the desired `msg.value` for the bridge call. + * No runtime calldata splicing is performed. + * + * The caller MUST route through `AllowanceHolder.exec` for ERC-20 + * inputs so that `_msgSender()` resolves to `input.user`. + */ + function bridge(InputData calldata input, bytes calldata feeBytes, StaticBridgeData calldata bridgeData) + external + payable + { + if (bridgeData.target == address(0) || input.user == address(0) || input.inputToken == address(0)) { + revert InvalidExecution(); + } + + // feeBytes must be empty or exactly one ABI word-pair (address + uint256 = 64 bytes) + if (feeBytes.length != 0 && feeBytes.length != 64) { + revert InvalidExecution(); + } + + // 1. pull funds from user via AllowanceHolder + _pullFromUser(input.inputToken, input.user, input.inputAmount); + + // 2. optional pre-bridge fee decoded from feeBytes; track net amount for approval + uint256 feeAmount; + if (feeBytes.length == 64) { + address feeReceiver; + assembly ("memory-safe") { + // feeBytes is a calldata slice: feeBytes.offset points at the raw bytes + feeReceiver := calldataload(feeBytes.offset) + feeAmount := calldataload(add(feeBytes.offset, 0x20)) + } + if (feeAmount != 0) { + if (feeAmount > input.inputAmount) { + revert InsufficientFunds(); + } + CurrencyLib.transfer(input.inputToken, feeReceiver, feeAmount); + } + } + + // 3. optional approval to bridge spender for the net amount (inputAmount - feeAmount) + if (bridgeData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { + uint256 netAmount; + unchecked { + netAmount = input.inputAmount - feeAmount; + } + SafeTransferLib.safeApproveWithRetry(input.inputToken, bridgeData.approvalSpender, netAmount); + } + + // 4. bridge call — data and value are pre-encoded by the caller + bytes memory bData = bridgeData.data; + _doCall(bridgeData.target, bridgeData.value, bData, false); + } + // ========================================================================= // External: modular path // ========================================================================= From e85dba5b2edb8c820a688f342d75fb656ff286c4 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Fri, 15 May 2026 17:10:24 +0530 Subject: [PATCH 02/42] feat: simple bridge relay test --- scripts/deploy/deployBungeeOpenRouterV2.ts | 8 +- scripts/e2e/bridgeViaRelaySimple.ts | 240 +++++++++++++++++++++ scripts/e2e/config.ts | 2 +- scripts/e2e/utils/contractTypes.ts | 8 + scripts/e2e/utils/routerAbi.ts | 7 + 5 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 scripts/e2e/bridgeViaRelaySimple.ts diff --git a/scripts/deploy/deployBungeeOpenRouterV2.ts b/scripts/deploy/deployBungeeOpenRouterV2.ts index 5d51a2e..3d69756 100644 --- a/scripts/deploy/deployBungeeOpenRouterV2.ts +++ b/scripts/deploy/deployBungeeOpenRouterV2.ts @@ -13,10 +13,12 @@ * Omitting --network runs against the in-process Hardhat network. */ +import hre from 'hardhat'; import { ethers } from 'hardhat'; async function main() { const [deployer] = await ethers.getSigners(); + const networkName = hre.network.name; const owner = deployer.address; const openRouterSigner = deployer.address; @@ -28,7 +30,7 @@ async function main() { console.log('Deployer: ', deployer.address); console.log('Owner: ', owner); console.log('OpenRouterSigner: ', openRouterSigner); - console.log('Network: ', (await ethers.provider.getNetwork()).name); + console.log('Network: ', networkName); console.log(''); // ------------------------------------------------------------------------- @@ -67,10 +69,10 @@ async function main() { if (chainId !== 31337n) { console.log('\nTo verify on a block explorer:'); // console.log( - // ` npx hardhat verify --network ${v2Address} "${owner}" "${openRouterSigner}"` + // ` npx hardhat verify --network ${networkName} ${v2Address} "${owner}" "${openRouterSigner}"` // ); console.log( - ` npx hardhat verify --network ${v2uAddress} "${owner}"`, + ` npx hardhat verify --network ${networkName} ${v2uAddress} "${owner}"`, ); } } diff --git a/scripts/e2e/bridgeViaRelaySimple.ts b/scripts/e2e/bridgeViaRelaySimple.ts new file mode 100644 index 0000000..d88ce24 --- /dev/null +++ b/scripts/e2e/bridgeViaRelaySimple.ts @@ -0,0 +1,240 @@ +/** + * Script — Bridge AAVE (Polygon PoS) → AAVE (Base) via Relay.link + * using the `bridge(InputData, bytes feeBytes, BridgeData)` entrypoint. + * + * Flow: + * 1. Read signer's Polygon AAVE (or USDC) balance. + * 2. Compute fee via FEE_BPS; encode as 64-byte ABI word-pair (receiver, amount). + * If FEE_AMOUNT_BPS=0, feeBytes is `0x` and the contract skips the fee entirely. + * 3. Fetch Relay.link /quote/v2 for the net bridge amount (inputAmount − fee). + * 4. AllowanceHolder.exec → router.bridge(input, feeBytes, bridgeData). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/bridgeViaRelaySimple.ts + * + * USDC path (Polygon Circle USDC → Base USDC): + * PRIVATE_KEY=0x... ts-node scripts/e2e/bridgeViaRelaySimple.ts usdc + * + * No fee: + * FEE_AMOUNT_BPS=0 PRIVATE_KEY=0x... ts-node scripts/e2e/bridgeViaRelaySimple.ts + * + * Router addresses: {@link ROUTER_BY_CHAIN_ID} / `routerAddressForChain` in config.ts. + * Override with `ROUTER_CHAIN_137` or legacy `ROUTER_ADDRESS` if needed. + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, +} from './config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; +import { getWalletErc20Balance } from './utils/erc20'; +import { ROUTER_ABI } from './utils/routerAbi'; +import type { InputData, StaticBridgeData } from './utils/contractTypes'; +import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; +import { logTxnSummary } from './utils/txnLogSummary'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +// ─── feeBytes encoding ──────────────────────────────────────────────────────── + +/** + * Encode fee as the 64-byte ABI word-pair expected by the contract: + * abi.encode(address receiver, uint256 amount) + * Returns `'0x'` when feeAmount is zero so the contract skips the transfer. + */ +function encodeFeeBytes(receiver: string, feeAmount: bigint): string { + if (feeAmount === 0n) { + return '0x'; + } + return ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256'], + [receiver, feeAmount], + ); +} + +// ─── Execution builder ──────────────────────────────────────────────────────── + +interface BridgeParams { + signerAddress: string; + inputToken: string; + inputAmount: bigint; + feeBytes: string; + relaySpender: string; + depositTarget: string; + /** Bridge calldata with finalAmount (= inputAmount - feeAmount) already encoded inside. */ + depositData: string; +} + +function buildBridgeCalldata(routerIface: ethers.Interface, p: BridgeParams): string { + const input: InputData = { + user: p.signerAddress, + inputToken: p.inputToken, + inputAmount: p.inputAmount, + }; + + // No amountPositions or useFinalAmountAsValue — the caller bakes the net amount + // directly into depositData before calling. + const bridgeData: StaticBridgeData = { + target: p.depositTarget, + approvalSpender: p.relaySpender, + value: 0n, + data: p.depositData, + }; + + return routerIface.encodeFunctionData('bridge', [input, p.feeBytes, bridgeData]); +} + +// ─── Execution leg ──────────────────────────────────────────────────────────── + +interface LegConfig { + label: string; + inputToken: string; + decimals: number; + symbol: string; + originChainId: number; + destinationChainId: number; + destinationCurrency: string; +} + +async function executeLeg(args: { + config: LegConfig; + signer: ethers.Wallet; + signerAddress: string; + inputAmount: bigint; + routerIface: ethers.Interface; +}): Promise { + const { config, signer, signerAddress, inputAmount, routerIface } = args; + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + const fmt = (n: bigint) => ethers.formatUnits(n, config.decimals); + + console.log(`\n── ${config.label} ──`); + console.log(`Input: ${fmt(inputAmount)} ${config.symbol}`); + console.log(`Fee (${FEE_BPS} bps): ${fmt(feeAmount)} ${config.symbol}`); + console.log(`Bridge amount: ${fmt(bridgeAmount)} ${config.symbol}`); + + // feeBytes: `0x` if no fee, else abi-encoded (receiver=signer, amount) + const feeBytes = encodeFeeBytes(signerAddress, feeAmount); + console.log(`feeBytes: ${feeBytes === '0x' ? '0x (no fee)' : `${feeBytes.slice(0, 18)}… (${feeBytes.length / 2 - 1} bytes)`}`); + + console.log('Fetching Relay.link quote...'); + const quote = await fetchRelayQuoteV2({ + routerAddress: ROUTER_POLYGON, + recipient: signerAddress, + originChainId: config.originChainId, + destinationChainId: config.destinationChainId, + originCurrency: config.inputToken, + destinationCurrency: config.destinationCurrency, + amount: bridgeAmount, + }); + const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); + console.log(`Relay spender: ${relaySpender}`); + console.log(`Deposit target: ${depositTarget}`); + + const execCalldata = buildBridgeCalldata(routerIface, { + signerAddress, + inputToken: config.inputToken, + inputAmount, + feeBytes, + relaySpender, + depositTarget, + depositData, + }); + + await ensureAllowanceForAllowanceHolder(signer, config.inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.bridge...'); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + config.inputToken, + inputAmount, + ROUTER_POLYGON, + execCalldata, + ); + + logTxnSummary(config.label, config.originChainId, receipt); +} + +// ─── Entry points ───────────────────────────────────────────────────────────── + +async function run(useUsdc: boolean): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const legConfig: LegConfig = useUsdc + ? { + label: 'Polygon USDC → Base USDC — Relay — Simple Bridge', + inputToken: TOKENS.USDC_POLYGON_CIRCLE, + decimals: 6, + symbol: 'USDC', + originChainId: CHAIN_IDS.POLYGON, + destinationChainId: CHAIN_IDS.BASE, + destinationCurrency: TOKENS.USDC_BASE, + } + : { + label: 'Polygon AAVE → Base AAVE — Relay — Simple Bridge', + inputToken: TOKENS.AAVE_POLYGON, + decimals: 18, + symbol: 'AAVE', + originChainId: CHAIN_IDS.POLYGON, + destinationChainId: CHAIN_IDS.BASE, + destinationCurrency: TOKENS.AAVE_BASE, + }; + + const { balance: walletBalance } = await getWalletErc20Balance( + legConfig.inputToken, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error( + `Signer ${signerAddress} has zero balance of ${legConfig.inputToken}. Fund the wallet first.`, + ); + } + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Input token: ${legConfig.inputToken}`); + console.log( + `Balance: ${ethers.formatUnits(walletBalance, legConfig.decimals)} ${legConfig.symbol}`, + ); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + await executeLeg({ + config: legConfig, + signer, + signerAddress, + inputAmount: walletBalance, + routerIface, + }); + + console.log('\nDone.'); +} + +async function main(): Promise { + const arg = process.argv[2]?.toLowerCase(); + const useUsdc = arg === 'usdc' || arg === 'usdc-polygon-base'; + await run(useUsdc); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/config.ts b/scripts/e2e/config.ts index 0c39f70..c14576d 100644 --- a/scripts/e2e/config.ts +++ b/scripts/e2e/config.ts @@ -34,7 +34,7 @@ export const ALLOWANCE_HOLDER = '0x0000000000001fF3684f28c67538d4D072C22734'; * Chains without an entry fall back to legacy `ROUTER_ADDRESS` when set. */ export const ROUTER_BY_CHAIN_ID: Record = { - [CHAIN_IDS.POLYGON]: '0x23D5aFEF7cE44257366D9ef6de80Ea334FAa9d25', + [CHAIN_IDS.POLYGON]: '0x5bfbF2d49658e48D209449B3E263DC6F774B6E6f', [CHAIN_IDS.ARBITRUM]: '0xe1788A0374EF5D4C35e62478FdB35F37CeE5B951', [CHAIN_IDS.BASE]: '0x96E8c261fCCDFca2CCffe8b4A33dC8a65f153785', [CHAIN_IDS.ETHEREUM]: '0xeB5ae85Fe7e3E272Ac77fd316079589C0Ed91648', diff --git a/scripts/e2e/utils/contractTypes.ts b/scripts/e2e/utils/contractTypes.ts index aeaf23e..ff9b5b6 100644 --- a/scripts/e2e/utils/contractTypes.ts +++ b/scripts/e2e/utils/contractTypes.ts @@ -36,6 +36,14 @@ export interface BridgeData { useFinalAmountAsValue: boolean; } +/** Simplified bridge descriptor for the no-swap `bridge()` entrypoint. */ +export interface StaticBridgeData { + target: string; + approvalSpender: string; + value: bigint; + data: string; +} + export interface MonolithicExecution { input: InputData; preFee: FeeData; diff --git a/scripts/e2e/utils/routerAbi.ts b/scripts/e2e/utils/routerAbi.ts index a64499f..f2dae81 100644 --- a/scripts/e2e/utils/routerAbi.ts +++ b/scripts/e2e/utils/routerAbi.ts @@ -18,4 +18,11 @@ export const ROUTER_ABI = [ `function performModularExecution( (uint256 actionInfo, bytes data, uint256[] splices)[] actions ) external payable`, + + // Simple bridge path (no swap, no splicing — caller pre-encodes finalAmount into data) + `function bridge( + (address user, address inputToken, uint256 inputAmount) input, + bytes feeBytes, + (address target, address approvalSpender, uint256 value, bytes data) bridgeData + ) external payable`, ] as const; From e8e1074601c0941044f3592407054e68129082e5 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Fri, 15 May 2026 19:11:48 +0530 Subject: [PATCH 03/42] feat: approval, balance dust during gas tests --- scripts/e2e/approveViaModular.ts | 134 ++++++++++++++ scripts/e2e/bridgeViaRelay.ts | 14 +- scripts/e2e/bridgeViaRelaySimple.ts | 9 +- scripts/e2e/swapBridgeViaArbitrumNative.ts | 11 +- scripts/e2e/swapBridgeViaCctp.ts | 16 +- scripts/e2e/swapBridgeViaCctpSimple.ts | 199 +++++++++++++++++++++ scripts/e2e/swapBridgeViaOft.ts | 16 +- scripts/e2e/swapBridgeViaStargateNative.ts | 35 +++- scripts/e2e/utils/erc20.ts | 1 + scripts/e2e/utils/reproducibility.ts | 106 +++++++++++ 10 files changed, 531 insertions(+), 10 deletions(-) create mode 100644 scripts/e2e/approveViaModular.ts create mode 100644 scripts/e2e/swapBridgeViaCctpSimple.ts create mode 100644 scripts/e2e/utils/reproducibility.ts diff --git a/scripts/e2e/approveViaModular.ts b/scripts/e2e/approveViaModular.ts new file mode 100644 index 0000000..3d9f34e --- /dev/null +++ b/scripts/e2e/approveViaModular.ts @@ -0,0 +1,134 @@ +/** + * Script — Call ERC-20 approve(spender, amount) through the router using + * `performModularExecution(Action[])`. + * + * This routes a single CALL action targeting the token contract so the router + * itself issues the approval — useful when the router holds tokens and needs + * to authorise a downstream spender (e.g. a bridge contract) before calling it. + * + * Usage: + * TOKEN=0x... SPENDER=0x... AMOUNT=1000000 PRIVATE_KEY=0x... \ + * ts-node scripts/e2e/approveViaModular.ts + * + * Optional: + * CHAIN_ID=137 (default: 137, Polygon) + * AMOUNT=max (uses MaxUint256) + * + * actionInfo packing (from the contract): + * bits 0-7 : callType (0 = CALL) + * bits 8-15 : storeResult flag (0 = don't store) + * bits 16+ : target address (uint160) + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { CHAIN_IDS, routerAddressForChain, RPC, TOKENS } from './config'; +import { encodeApprove } from './utils/erc20'; +import { ROUTER_ABI } from './utils/routerAbi'; + +// ─── actionInfo helpers ─────────────────────────────────────────────────────── + +const CallType = { CALL: 0n, STATICCALL: 1n, CALL_WITH_NATIVE: 2n } as const; + +function packActionInfo( + target: string, + callType = CallType.CALL, + storeResult = false, +): bigint { + return (BigInt(target) << 16n) | (storeResult ? 0x100n : 0n) | callType; +} + +// ─── build + send ───────────────────────────────────────────────────────────── + +async function run(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const token = TOKENS.USDC_POLYGON_CIRCLE; + if (!token || !/^0x[a-fA-F0-9]{40}$/.test(token)) { + throw new Error( + 'TOKEN env var required (checksummed or lowercase ERC-20 address)', + ); + } + + const spender = '0x28b5a0e9c621a5badaa536219b3a228c8168cf5d'; // cctp tokenmessengerv2 + if (!spender || !/^0x[a-fA-F0-9]{40}$/.test(spender)) { + throw new Error( + 'SPENDER env var required (checksummed or lowercase address)', + ); + } + + const amount = ethers.MaxUint256; + + const chainId = CHAIN_IDS.POLYGON; + const rpcUrl: string = + process.env.RPC_URL ?? + (chainId === CHAIN_IDS.POLYGON + ? RPC.POLYGON + : chainId === CHAIN_IDS.ARBITRUM + ? RPC.ARBITRUM + : chainId === CHAIN_IDS.BASE + ? RPC.BASE + : chainId === CHAIN_IDS.ETHEREUM + ? RPC.ETHEREUM + : (() => { + throw new Error(`No default RPC for chain ${chainId}; set RPC_URL`); + })()); + + const routerAddress = routerAddressForChain(chainId); + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + const approveCalldata = encodeApprove(spender, amount); + + // Single action: CALL token.approve(spender, amount) from the router. + const actions = [ + { + actionInfo: packActionInfo(token), + data: approveCalldata, + splices: [], + }, + ]; + + const calldata = routerIface.encodeFunctionData('performModularExecution', [ + actions, + ]); + + console.log(`Signer: ${signerAddress}`); + console.log(`Chain: ${chainId}`); + console.log(`Router: ${routerAddress}`); + console.log(`Token: ${token}`); + console.log(`Spender: ${spender}`); + console.log( + `Amount: ${ + amount === ethers.MaxUint256 ? 'MaxUint256' : amount.toString() + }`, + ); + console.log('Sending performModularExecution → token.approve...'); + + const tx = await signer.sendTransaction({ + to: routerAddress, + data: calldata, + }); + console.log(`Tx hash: ${tx.hash}`); + const receipt = await tx.wait(); + console.log( + `Status: ${receipt?.status === 1 ? 'success' : 'reverted'} (block ${ + receipt?.blockNumber + })`, + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); + +async function main(): Promise { + await run(); +} diff --git a/scripts/e2e/bridgeViaRelay.ts b/scripts/e2e/bridgeViaRelay.ts index 1eef43e..2a73641 100644 --- a/scripts/e2e/bridgeViaRelay.ts +++ b/scripts/e2e/bridgeViaRelay.ts @@ -44,6 +44,10 @@ import { MonolithicExecution, NO_FEE, NO_SWAP } from './utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from './utils/reproducibility'; /** Router on Polygon — quotes + modular recipient target must match executing chain deployment. */ const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -142,6 +146,9 @@ async function executeLeg(args: { console.log(`Relay spender: ${relaySpender}`); console.log(`Deposit target: ${depositTarget}`); + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, relaySpender); + let execCalldata: string; if (useModular) { const actions = buildModularActions( @@ -285,6 +292,9 @@ async function executeLegUsdcPolygonToBase(args: { console.log(`Relay spender: ${relaySpender}`); console.log(`Deposit target: ${depositTarget}`); + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, relaySpender); + let execCalldata: string; if (useModular) { const actions = buildModularActionsUsdcPolygonToBase( @@ -360,7 +370,7 @@ async function mainUsdcPolygonToBaseRelay() { ); } - const legAmount = walletBalance / 2n; + const legAmount = (walletBalance - 20n) / 2n; if (legAmount === 0n) { throw new Error( `Wallet balance (${walletBalance}) is too small to split into two nonzero halves.`, @@ -431,7 +441,7 @@ async function main() { ); } - const legAmount = walletBalance / 2n; + const legAmount = (walletBalance - 20n) / 2n; if (legAmount === 0n) { throw new Error( `Wallet balance (${walletBalance}) is too small to split into two nonzero halves.`, diff --git a/scripts/e2e/bridgeViaRelaySimple.ts b/scripts/e2e/bridgeViaRelaySimple.ts index d88ce24..1077fbf 100644 --- a/scripts/e2e/bridgeViaRelaySimple.ts +++ b/scripts/e2e/bridgeViaRelaySimple.ts @@ -39,6 +39,10 @@ import { ROUTER_ABI } from './utils/routerAbi'; import type { InputData, StaticBridgeData } from './utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; import { logTxnSummary } from './utils/txnLogSummary'; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from './utils/reproducibility'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -140,6 +144,9 @@ async function executeLeg(args: { console.log(`Relay spender: ${relaySpender}`); console.log(`Deposit target: ${depositTarget}`); + await ensureRouterErc20Balance(signer, config.inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, config.inputToken, relaySpender); + const execCalldata = buildBridgeCalldata(routerIface, { signerAddress, inputToken: config.inputToken, @@ -221,7 +228,7 @@ async function run(useUsdc: boolean): Promise { config: legConfig, signer, signerAddress, - inputAmount: walletBalance, + inputAmount: (walletBalance - 20n) / 2n, routerIface, }); diff --git a/scripts/e2e/swapBridgeViaArbitrumNative.ts b/scripts/e2e/swapBridgeViaArbitrumNative.ts index 767d0c0..30ac4a1 100644 --- a/scripts/e2e/swapBridgeViaArbitrumNative.ts +++ b/scripts/e2e/swapBridgeViaArbitrumNative.ts @@ -65,6 +65,11 @@ import type { ModularAction } from './utils/modularActionsBuilder/index'; import { MonolithicExecution, NO_FEE, ZERO_ADDRESS } from './utils/contractTypes'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, + ensureRouterApproval, +} from './utils/reproducibility'; // ─── Exec-mode selection ────────────────────────────────────────────────────── @@ -302,6 +307,10 @@ async function executeLeg( console.log(` Fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); console.log(` Min ETH out: ${ethers.formatEther(minAmountOut)} ETH`); + await ensureRouterErc20Balance(signer, TOKENS.AAVE_ETH, routerAddress); + await ensureRouterNativeBalance(signer, routerAddress); + await ensureRouterApproval(signer, routerAddress, TOKENS.AAVE_ETH, ooRouter); + const arbFee = await estimateArbitrumBridgeFee(provider); const minEthRequired = feeAmount + arbFee; if (estimatedOut < minEthRequired) { @@ -388,7 +397,7 @@ async function main(): Promise { ); } - const legAmount = fullBalance / 2n; + const legAmount = (fullBalance - 20n) / 2n; if (legAmount === 0n) { throw new Error('AAVE balance too small to split into two legs.'); } diff --git a/scripts/e2e/swapBridgeViaCctp.ts b/scripts/e2e/swapBridgeViaCctp.ts index 3ff723e..e1afc98 100644 --- a/scripts/e2e/swapBridgeViaCctp.ts +++ b/scripts/e2e/swapBridgeViaCctp.ts @@ -41,6 +41,10 @@ import { } from './utils/contractTypes'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from './utils/reproducibility'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -271,6 +275,9 @@ async function executeLegUsdcPolygonToBaseCctp(args: { true, ); + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); + let execCalldata: string; if (useModular) { execCalldata = routerIface.encodeFunctionData('performModularExecution', [ @@ -353,6 +360,11 @@ async function executeLeg(args: { true, ); + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouterAddress); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); + let execCalldata: string; if (useModular) { execCalldata = routerIface.encodeFunctionData('performModularExecution', [ @@ -422,7 +434,7 @@ async function mainUsdcPolygonToBaseCctp() { ); } - const legAmount = walletBalance / 2n; + const legAmount = (walletBalance - 20n) / 2n; if (legAmount === 0n) { throw new Error( `Balance ${walletBalance} too small for two nonzero 50% legs.`, @@ -490,7 +502,7 @@ async function main() { ); } - const legAmount = walletBalance / 2n; + const legAmount = (walletBalance - 20n) / 2n; if (legAmount === 0n) { throw new Error( `Balance ${walletBalance} too small for two nonzero 50% legs.`, diff --git a/scripts/e2e/swapBridgeViaCctpSimple.ts b/scripts/e2e/swapBridgeViaCctpSimple.ts new file mode 100644 index 0000000..46ae4f6 --- /dev/null +++ b/scripts/e2e/swapBridgeViaCctpSimple.ts @@ -0,0 +1,199 @@ +/** + * Polygon native USDC → Base USDC via CCTP v2 using `router.bridge(...)`. + * + * Same burn token / TokenMessenger constraints as {@link swapBridgeViaCctp}: + * use Circle’s native Polygon USDC (`USDC_POLYGON_CIRCLE`); bridged USDC.e is unsupported. + * + * Unlike the monolithic/modular paths in `swapBridgeViaCctp.ts`, this script: + * – only supports USDC-in (no OpenOcean AAVE→USDC swap); + * – encodes the net `depositForBurn` amount in calldata up front (no splice); + * – uses a single `bridge` entrypoint per run (full wallet balance by default). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaCctpSimple.ts + * + * No pre-bridge fee: + * FEE_AMOUNT_BPS=0 PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaCctpSimple.ts + * + * Router: {@link ROUTER_BY_CHAIN_ID} / `routerAddressForChain(137)` in config.ts. + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, +} from './config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; +import { getWalletErc20Balance } from './utils/erc20'; +import { ROUTER_ABI } from './utils/routerAbi'; +import type { InputData, StaticBridgeData } from './utils/contractTypes'; +import { logTxnSummary } from './utils/txnLogSummary'; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from './utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +function encodeFeeBytes(receiver: string, feeAmount: bigint): string { + if (feeAmount === 0n) { + return '0x'; + } + return ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256'], + [receiver, feeAmount], + ); +} + +/** + * CCTP `depositForBurn` with explicit burn amount (net after optional fee). + */ +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number, + amount: bigint, + fastPath: boolean = true, +): string { + const iface = new ethers.Interface([ + 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external', + ]); + + const mintRecipient = ethers.zeroPadValue(recipientAddress, 32); + const maxFee = fastPath ? 1_000_000n : 0n; + const minFinalityThreshold = fastPath ? 1000 : 2000; + + return iface.encodeFunctionData('depositForBurn', [ + amount, + destinationCctpDomain, + mintRecipient, + burnToken, + ethers.ZeroHash, + maxFee, + minFinalityThreshold, + ]); +} + +function buildBridgeCalldata( + routerIface: ethers.Interface, + args: { + signerAddress: string; + inputToken: string; + inputAmount: bigint; + feeBytes: string; + tokenMessenger: string; + depositData: string; + }, +): string { + const input: InputData = { + user: args.signerAddress, + inputToken: args.inputToken, + inputAmount: args.inputAmount, + }; + + const bridgeData: StaticBridgeData = { + target: args.tokenMessenger, + approvalSpender: args.tokenMessenger, + value: 0n, + data: args.depositData, + }; + + return routerIface.encodeFunctionData('bridge', [ + input, + args.feeBytes, + bridgeData, + ]); +} + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.USDC_POLYGON_CIRCLE; + const { balance: walletBalance } = await getWalletErc20Balance( + inputToken, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error( + `Signer ${signerAddress} has zero Circle native USDC on Polygon. Fund ${inputToken} on Polygon PoS.`, + ); + } + + const inputAmount = walletBalance - 20n; + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Input USDC: ${ethers.formatUnits(inputAmount, 6)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); + console.log(`TokenMessenger: ${polyCctp.tokenMessenger}`); + console.log(`Burn token: ${polyCctp.usdcAddress}`); + + const depositData = buildDepositForBurnCalldata( + signerAddress, + polyCctp.usdcAddress, + baseCctp.cctpDomain, + bridgeAmount, + true, + ); + + const feeBytes = encodeFeeBytes(signerAddress, feeAmount); + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = buildBridgeCalldata(routerIface, { + signerAddress, + inputToken, + inputAmount, + feeBytes, + tokenMessenger: polyCctp.tokenMessenger, + depositData, + }); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, polyCctp.tokenMessenger); + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.bridge...'); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + inputToken, + inputAmount, + ROUTER_POLYGON, + execCalldata, + ); + + logTxnSummary( + 'Polygon USDC → Base USDC — CCTP — Simple bridge', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log( + `\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`, + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swapBridgeViaOft.ts b/scripts/e2e/swapBridgeViaOft.ts index f9fe823..0bc8725 100644 --- a/scripts/e2e/swapBridgeViaOft.ts +++ b/scripts/e2e/swapBridgeViaOft.ts @@ -71,6 +71,10 @@ import type { ModularAction } from './utils/modularActionsBuilder/index'; import { MonolithicExecution, NO_FEE, NO_SWAP } from './utils/contractTypes'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from './utils/reproducibility'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -372,6 +376,11 @@ async function executeCase1Leg(args: { ` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`, ); + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); let execCalldata: string; @@ -558,6 +567,9 @@ async function executeCase2Leg(args: { ` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`, ); + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); let execCalldata: string; @@ -640,7 +652,7 @@ async function runCase1( ); } - const legAmount = walletBalance / 2n; + const legAmount = (walletBalance - 20n) / 2n; if (legAmount === 0n) { throw new Error('Case 1: AAVE balance too small to split into two halves.'); } @@ -702,7 +714,7 @@ async function runCase2( ); } - const legAmount = walletBalance / 2n; + const legAmount = (walletBalance - 20n) / 2n; if (legAmount === 0n) { throw new Error( 'Case 2: USDT0 balance too small to split into two halves.', diff --git a/scripts/e2e/swapBridgeViaStargateNative.ts b/scripts/e2e/swapBridgeViaStargateNative.ts index ad9cc13..19384bf 100644 --- a/scripts/e2e/swapBridgeViaStargateNative.ts +++ b/scripts/e2e/swapBridgeViaStargateNative.ts @@ -91,6 +91,11 @@ import type { ModularAction } from './utils/modularActionsBuilder/index'; import { MonolithicExecution, NO_FEE, NO_SWAP, ZERO_ADDRESS } from './utils/contractTypes'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, + ensureRouterApproval, +} from './utils/reproducibility'; /** * LZ extra options for Polygon USDT0 OFT Adapter `send()` (executor gas). @@ -900,6 +905,32 @@ async function executeLeg( inputAmountWei = maxAffordableSwapIn; } + // ── State prep for reproducible gas ───────────────────────────────────────── + // Determine the token the router will approve to the bridge contract (null for native pools). + const bridgeToken: string | null = + cfg.isNativePool + ? null + : cfg.ooSwap !== null + ? cfg.ooSwap.outToken + : cfg.inputToken; + + if (!cfg.isNativeInput) { + await ensureRouterErc20Balance(signer, cfg.inputToken, routerAddress); + } + if (cfg.isNativeInput || cfg.isNativePool || (cfg.ooSwap && cfg.ooSwap.outToken.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase())) { + await ensureRouterNativeBalance(signer, routerAddress); + } + if (cfg.ooSwap && cfg.ooSwap.outToken.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()) { + await ensureRouterErc20Balance(signer, cfg.ooSwap.outToken, routerAddress); + } + if (cfg.ooSwap && !cfg.isNativeInput) { + await ensureRouterApproval(signer, routerAddress, cfg.inputToken, ooRouter); + } + if (bridgeToken && bridgeToken.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()) { + await ensureRouterApproval(signer, routerAddress, bridgeToken, cfg.bridgeContract); + } + // ──────────────────────────────────────────────────────────────────────────── + let amountLD: bigint; if (cfg.isNativePool) { amountLD = minAmountOut - feeAmount - nativeFeeWithBuffer; @@ -997,7 +1028,7 @@ async function runCase( ); } // Reserve wei for signer gas; lz fee itself is deducted inside executeLeg (`txValue = swap + fee`). - walletBalance = raw - NATIVE_INPUT_GAS_RESERVE; + walletBalance = raw - NATIVE_INPUT_GAS_RESERVE - 20n; decimals = 18; } else { ({ balance: walletBalance, decimals } = await getWalletErc20Balance( @@ -1012,7 +1043,7 @@ async function runCase( ); } - const legAmount = walletBalance / 2n; + const legAmount = cfg.isNativeInput ? walletBalance / 2n : (walletBalance - 20n) / 2n; if (legAmount === 0n) { throw new Error(`${cfg.name}: balance too small to split into two halves.`); } diff --git a/scripts/e2e/utils/erc20.ts b/scripts/e2e/utils/erc20.ts index 7716911..87484af 100644 --- a/scripts/e2e/utils/erc20.ts +++ b/scripts/e2e/utils/erc20.ts @@ -44,6 +44,7 @@ export function getErc20Contract(tokenAddress: string, providerOrSigner: ethers. tokenAddress, [ 'function approve(address spender, uint256 amount) external returns (bool)', + 'function transfer(address recipient, uint256 amount) external returns (bool)', 'function allowance(address owner, address spender) external view returns (uint256)', 'function balanceOf(address account) external view returns (uint256)', 'function decimals() external view returns (uint8)', diff --git a/scripts/e2e/utils/reproducibility.ts b/scripts/e2e/utils/reproducibility.ts new file mode 100644 index 0000000..6252190 --- /dev/null +++ b/scripts/e2e/utils/reproducibility.ts @@ -0,0 +1,106 @@ +/** + * State-prep helpers for reproducible on-chain gas-cost tests. + * + * Callers must pass the deployed open-router address from config (`routerAddressForChain`, etc.), + * never Relay `depositTarget`, CCTP `tokenMessenger`, or other external calldata targets. + * + * Before each test leg these ensure: + * 1. The router holds ≥ 20 wei of every token whose balance slot will be written. + * 2. The router has a non-zero ERC-20 allowance for every external spender it will call. + * + * Seeding slots to non-zero means subsequent SSTORE writes cost ~2 900 gas + * (non-zero → non-zero) rather than ~20 000 gas (zero → non-zero), giving + * consistent gas readings across repeated runs. + */ +import { ethers } from 'ethers'; +import { getErc20Contract, encodeApprove } from './erc20'; +import { ROUTER_ABI } from './routerAbi'; + +const SEED_WEI = 20n; + +function packCallAction(target: string): bigint { + return BigInt(target) << 16n; // CallType.CALL=0, storeResult=false +} + +/** + * Transfers {@link SEED_WEI} of `token` from `signer` to the deployed open router only + * when that router already holds zero — never to Relay/deposit/spender contracts. + */ +export async function ensureRouterErc20Balance( + signer: ethers.Wallet, + token: string, + openRouterAddress: string, +): Promise { + const openRouter = ethers.getAddress(openRouterAddress); + const tokenResolved = ethers.getAddress(token); + const tokenRo = getErc20Contract(tokenResolved, signer.provider!); + const bal = BigInt(await tokenRo.balanceOf(openRouter)); + if (bal > 0n) { + return; + } + + console.log( + ` [state-prep] open router ${openRouter} token ${tokenResolved} balance=0 — signer transfer ${SEED_WEI} wei to open router only`, + ); + const tx = await getErc20Contract(tokenResolved, signer).transfer(openRouter, SEED_WEI); + await tx.wait(); +} + +/** + * Sends {@link SEED_WEI} of native currency from `signer` to the open router when its + * balance is zero; skipped when already non-zero. + */ +export async function ensureRouterNativeBalance( + signer: ethers.Wallet, + openRouterAddress: string, +): Promise { + const openRouter = ethers.getAddress(openRouterAddress); + const bal = await signer.provider!.getBalance(openRouter); + if (bal > 0n) { + return; + } + + console.log( + ` [state-prep] open router ${openRouter} native balance=0 — signer sending ${SEED_WEI} wei to open router only`, + ); + const tx = await signer.sendTransaction({ to: openRouter, value: SEED_WEI }); + await tx.wait(); +} + +/** + * Issues `token.approve(spender, MaxUint256)` FROM the router (via + * `performModularExecution`) when the current router→spender allowance is zero. + * + * Guarantees the allowance slot is non-zero before the test txn so that the + * approval write inside the test costs ~2 900 gas (non-zero → non-zero). + */ +export async function ensureRouterApproval( + signer: ethers.Wallet, + openRouterAddress: string, + token: string, + spender: string, +): Promise { + const openRouter = ethers.getAddress(openRouterAddress); + const tokenResolved = ethers.getAddress(token); + const spenderResolved = ethers.getAddress(spender); + const tokenRo = getErc20Contract(tokenResolved, signer.provider!); + const allowance = BigInt(await tokenRo.allowance(openRouter, spenderResolved)); + if (allowance > 0n) { + return; + } + + console.log( + ` [state-prep] open router ${openRouter} token ${tokenResolved} allowance for ${spenderResolved}=0 — pre-approving MaxUint256 via open router`, + ); + const routerIface = new ethers.Interface(ROUTER_ABI); + const actions = [ + { + actionInfo: packCallAction(tokenResolved), + data: encodeApprove(spenderResolved, ethers.MaxUint256), + splices: [], + }, + ]; + const calldata = routerIface.encodeFunctionData('performModularExecution', [actions]); + const tx = await signer.sendTransaction({ to: openRouter, data: calldata }); + await tx.wait(); +} From 17dc785d7c350f1bd7db02ae08ce3a913d680292 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Fri, 15 May 2026 20:46:26 +0530 Subject: [PATCH 04/42] feat: introduce flags for fee and balance handling in swap functions - Added a `flags` uint8 to manage fee timing and output measurement in `swap` and `swapAndBridge` functions. - Implemented `FEE_FLAG_BIT_MASK` and `BALANCE_FLAG_BIT_MASK` for enhanced control over fee collection and output calculation. - Updated documentation to clarify the usage of flags and their impact on swap behavior. --- src/combined/BungeeOpenRouterV2Unchecked.sol | 235 +++++++++++++++++-- 1 file changed, 212 insertions(+), 23 deletions(-) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index a836eb2..d4bcaf5 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -85,6 +85,9 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { SwapData swap; FeeData postFee; BridgeData bridge; + /// Packed byte; monolithic pipeline only tests `BALANCE_FLAG_BIT_MASK` (bit 1) in `_execSwap`. + /// Fee timing uses `preFee` / `postFee` structs — `FEE_FLAG_BIT_MASK` (bit 0) is ignored here. + uint8 flags; } // ========================================================================= @@ -103,6 +106,43 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { uint256[] splices; } + // ========================================================================= + // Flags (swap / swapAndBridge / monolithic swap step) + // ========================================================================= + // + // Instead of two bool parameters, one uint8 packs independent switches; future flags can use + // bit 2 (0x04), bit 3 (0x08), etc. without changing the ABI shape. + // + // Bit layout (least significant bits); test with `(flags & MASK) != 0`: + // bits 7..2 : reserved (0) + // bit 1 : BALANCE_FLAG_BIT_MASK (0x02) — swap output: returndata vs balance delta + // bit 0 : FEE_FLAG_BIT_MASK (0x01) — swap fee: pre- vs post-swap (standalone paths only) + // + // Combined values for swap()/swapAndBridge(): + // + // flags binary (low byte) postFee? balance-of output? + // ───── ────────────────── ──────── ────────────────── + // 0x00 00000000 no returndata word + // 0x01 00000001 yes returndata word + // 0x02 00000010 no balance delta on outputToken + // 0x03 00000011 yes balance delta on outputToken + // + // FEE_FLAG_BIT_MASK selects bit 0 — fee timing (see `_collectFee` + swap flow). + // Cleared — pull → deduct fee from input token → swap remainder → standalone swap skips minOutput. + // Set — pull → swap full input → deduct fee from output token → standalone swap checks minOutput. + // + // BALANCE_FLAG_BIT_MASK selects bit 1 — swap output sizing. + // Cleared — decode returned amount from call returndata at `swapData.returnDataWordOffset`. + // Set — snapshot outputToken balance before call, measure (after − before) as output. + // + // Monolithic `performExecution` applies only `BALANCE_FLAG_BIT_MASK` in `_execSwap`; fee timing is `preFee`/`postFee` structs. + + /// @dev Bit mask 0x01: post-swap fee path when `(flags & mask) != 0`; clear = pre-swap fee from input token. + uint8 internal constant FEE_FLAG_BIT_MASK = 0x01; + + /// @dev Bit mask 0x02: measure swap output by balance delta when `(flags & mask) != 0`; clear = returndata word. + uint8 internal constant BALANCE_FLAG_BIT_MASK = 0x02; + // ========================================================================= // Errors // ========================================================================= @@ -137,11 +177,128 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { * @dev The caller MUST route through `AllowanceHolder.exec` so that * `_msgSender()` resolves to `exec.input.user`. There is no nonce or * deadline; replay protection is the caller's responsibility. + * Bit 0 (`FEE_FLAG_BIT_MASK`) is unused in monolithic runs; fee placement is `preFee` / `postFee` structs. + * `exec.flags` only contributes `BALANCE_FLAG_BIT_MASK` to the optional `_execSwap` step. */ function performExecution(MonolithicExecution calldata exec) external payable { _runMonolithic(exec); } + // ========================================================================= + // External: standalone swap + // ========================================================================= + + /** + * @notice Pull → optional pre/post fee → swap. + * @param flags Packed `uint8`; OR masks then test with `flags & MASK != 0`. Masks: `FEE_FLAG_BIT_MASK` (0x01), `BALANCE_FLAG_BIT_MASK` (0x02). + * Common values: `0` (pre-fee, returndata), `1` (post-fee, returndata), + * `2` (pre-fee, balance delta), `3` (post-fee, balance delta). + * @param feeBytes 0x = no fee; 64 bytes = abi.encode(address receiver, uint256 amount). + * @dev minOutput is only enforced in post-fee mode. Pre-fee skips minOutput check. + * Post-fee: fee collected from output token after swap, then minOutput validated. + * Pre-fee: fee collected from input token before swap, minOutput skipped. + * Bits are read with bitwise AND against each mask; omitting both flags ⇒ pre-fee + returndata. + */ + function swap( + InputData calldata input, + uint8 flags, + bytes calldata feeBytes, + SwapData calldata swapData + ) external payable returns (uint256 finalAmount) { + if (input.user == address(0) || input.inputToken == address(0) || swapData.target == address(0)) { + revert InvalidExecution(); + } + + _pullFromUser(input.inputToken, input.user, input.inputAmount); + + // Check feeBytes first: flag bit is only read when a fee is actually present. + // FEE_FLAG_BIT_MASK: unset ⇒ collect fee from input before swap; set ⇒ swap first, fee from output + bool hasFee = feeBytes.length != 0; + /// @dev if hasFee is false, we short-circuit and flag check wont execute at runtime saving gas + bool postFee = hasFee && flags & FEE_FLAG_BIT_MASK != 0; + uint256 swapInput = input.inputAmount; + + if (hasFee && !postFee) { + uint256 fee = _collectFee(input.inputToken, feeBytes); + if (fee > swapInput) revert InsufficientFunds(); + unchecked { swapInput -= fee; } + } + + if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { + SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); + } + + // BALANCE_FLAG_BIT_MASK: unset ⇒ decode output word from returndata; set ⇒ output = delta on outputToken + finalAmount = _execSwap(swapData, flags & BALANCE_FLAG_BIT_MASK != 0); + + if (postFee) { + uint256 fee = _collectFee(swapData.outputToken, feeBytes); + if (fee > finalAmount) revert InsufficientFunds(); + unchecked { finalAmount -= fee; } + if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); + } + } + + // ========================================================================= + // External: swap + bridge + // ========================================================================= + + /** + * @notice Pull → optional pre/post swap fee → swap → bridge with runtime amount splicing. + * @param flags Same packing as `swap`: 0x00–0x03 as documented on the flag constants block. + * @param feeBytes 0x = no fee; 64 bytes = abi.encode(address receiver, uint256 amount). + * @dev minOutput is always enforced (after fee deduction in post-fee mode). + * Post-fee: fee collected from output token after swap, then minOutput validated. + * Pre-fee: fee collected from input token before swap, minOutput still validated. + */ + function swapAndBridge( + InputData calldata input, + uint8 flags, + bytes calldata feeBytes, + SwapData calldata swapData, + BridgeData calldata bridgeData + ) external payable { + if ( + bridgeData.target == address(0) || input.user == address(0) || + input.inputToken == address(0) || swapData.target == address(0) + ) { + revert InvalidExecution(); + } + + _pullFromUser(input.inputToken, input.user, input.inputAmount); + + // Check feeBytes first: flag bit is only read when a fee is actually present. + // FEE_FLAG_BIT_MASK: same semantics as standalone `swap` (fee from input vs output token) + bool hasFee = feeBytes.length != 0; + bool postFee = hasFee && flags & FEE_FLAG_BIT_MASK != 0; + uint256 swapInput = input.inputAmount; + + if (hasFee && !postFee) { + uint256 fee = _collectFee(input.inputToken, feeBytes); + if (fee > swapInput) revert InsufficientFunds(); + unchecked { swapInput -= fee; } + } + + if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { + SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); + } + + address finalToken = swapData.outputToken; + // BALANCE_FLAG_BIT_MASK: same returndata vs balance-delta measurement as `swap` + uint256 finalAmount = _execSwap(swapData, flags & BALANCE_FLAG_BIT_MASK != 0); + + if (postFee) { + uint256 fee = _collectFee(finalToken, feeBytes); + if (fee > finalAmount) revert InsufficientFunds(); + unchecked { finalAmount -= fee; } + } + + // Always check minOutput (unlike standalone swap where pre-fee skips this) + if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); + + _doBridge(finalToken, finalAmount, bridgeData); + } + // ========================================================================= // External: simple bridge path (no swap) // ========================================================================= @@ -263,43 +420,75 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } } - // 5. splice finalAmount into bridge calldata at every signed offset - bytes memory bridgeData = exec.bridge.data; - BytesSpliceLib.spliceWords({data: bridgeData, positions: exec.bridge.amountPositions, word: finalAmount}); - - // 6. optional approval to bridge spender - if (exec.bridge.approvalSpender != address(0) && finalToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(finalToken, exec.bridge.approvalSpender, finalAmount); - } - - // 7. bridge call, bubbling any revert - // when useFinalAmountAsValue is set, forward finalAmount as msg.value so - // native-token bridges (e.g. Arbitrum inbox) receive the exact bridged amount. - uint256 bridgeValue = exec.bridge.useFinalAmountAsValue ? finalAmount : exec.bridge.value; - _doCall(exec.bridge.target, bridgeValue, bridgeData, false); + // 5. bridge: splice, approve, call + _doBridge(finalToken, finalAmount, exec.bridge); } - /// @dev Swap helper; decodes final amount from a returndata word. function _performSwap(MonolithicExecution calldata exec) internal returns (address finalToken, uint256 finalAmount) { if (exec.swap.approvalSpender != address(0) && exec.input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { uint256 swapInput; - unchecked { - swapInput = exec.input.inputAmount - exec.preFee.amount; - } + unchecked { swapInput = exec.input.inputAmount - exec.preFee.amount; } SafeTransferLib.safeApproveWithRetry(exec.input.inputToken, exec.swap.approvalSpender, swapInput); } - bytes memory ret = _doCall(exec.swap.target, exec.swap.value, exec.swap.data, true); - finalAmount = _decodeReturnWord(ret, exec.swap.returnDataWordOffset); + // Monolithic path: only `BALANCE_FLAG_BIT_MASK` is read for `_execSwap`; fee uses `preFee` / `postFee`, not bit 0. + finalAmount = _execSwap(exec.swap, exec.flags & BALANCE_FLAG_BIT_MASK != 0); + if (finalAmount < exec.swap.minOutput) revert SwapOutputInsufficient(); + finalToken = exec.swap.outputToken; + } + + // ========================================================================= + // Internal: swap / fee / bridge helpers + // ========================================================================= - if (finalAmount < exec.swap.minOutput) { - revert SwapOutputInsufficient(); + /// @dev Execute swap; output measured via returndata word or output-token balance delta. + /// useBalanceOf=true: measure output as (balance after - balance before). + /// useBalanceOf=false: decode output from returndata at swapData.returnDataWordOffset. + function _execSwap(SwapData calldata swapData, bool useBalanceOf) internal returns (uint256 finalAmount) { + if (useBalanceOf) { + // Balance delta mode: snapshot before, call, measure delta + uint256 before = CurrencyLib.balanceOf(swapData.outputToken, address(this)); + _doCall(swapData.target, swapData.value, swapData.data, false); + finalAmount = CurrencyLib.balanceOf(swapData.outputToken, address(this)) - before; + } else { + // Returndata mode: decode output from a specific word in returndata + bytes memory ret = _doCall(swapData.target, swapData.value, swapData.data, true); + finalAmount = _decodeReturnWord(ret, swapData.returnDataWordOffset); } + } - finalToken = exec.swap.outputToken; + /// @dev Decode, validate, and collect fee from feeBytes. Returns fee amount (0 if feeBytes empty). + /// feeBytes encoding: + /// - 0x (zero length): no fee, return 0 immediately. + /// - 64 bytes: abi.encode(address receiver, uint256 amount). Transfer amount to receiver. + /// Caller must pass the correct `token` address: + /// - Pre-fee: pass inputToken (fee deducted before swap). + /// - Post-fee: pass outputToken (fee deducted after swap). + function _collectFee(address token, bytes calldata feeBytes) internal returns (uint256 feeAmount) { + if (feeBytes.length != 64) revert InvalidExecution(); + address receiver; + assembly ("memory-safe") { + receiver := calldataload(feeBytes.offset) + feeAmount := calldataload(add(feeBytes.offset, 0x20)) + } + if (feeAmount != 0) CurrencyLib.transfer(token, receiver, feeAmount); + } + + /// @dev Splice finalAmount into bridge calldata, approve, and call bridge target. + function _doBridge(address token, uint256 amount, BridgeData calldata bd) internal { + bytes memory bData = bd.data; + BytesSpliceLib.spliceWords({data: bData, positions: bd.amountPositions, word: amount}); + + if (bd.approvalSpender != address(0) && token != CurrencyLib.NATIVE_TOKEN_ADDRESS) { + SafeTransferLib.safeApproveWithRetry(token, bd.approvalSpender, amount); + } + + // when useFinalAmountAsValue, forward amount as msg.value for native-token bridges + uint256 bridgeValue = bd.useFinalAmountAsValue ? amount : bd.value; + _doCall(bd.target, bridgeValue, bData, false); } // ========================================================================= From 06853084fee1a7a21e1ac178c57432a99ad71f3e Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Fri, 15 May 2026 21:26:33 +0530 Subject: [PATCH 05/42] fix: swap output check after swap --- src/combined/BungeeOpenRouterV2Unchecked.sol | 70 ++++++++++++-------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index d4bcaf5..2e4327f 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -128,8 +128,8 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // 0x03 00000011 yes balance delta on outputToken // // FEE_FLAG_BIT_MASK selects bit 0 — fee timing (see `_collectFee` + swap flow). - // Cleared — pull → deduct fee from input token → swap remainder → standalone swap skips minOutput. - // Set — pull → swap full input → deduct fee from output token → standalone swap checks minOutput. + // Cleared — pull → deduct fee from input token → swap remainder. + // Set — pull → swap full input → deduct fee from output token (after minOutput check on swap result). // // BALANCE_FLAG_BIT_MASK selects bit 1 — swap output sizing. // Cleared — decode returned amount from call returndata at `swapData.returnDataWordOffset`. @@ -138,7 +138,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // Monolithic `performExecution` applies only `BALANCE_FLAG_BIT_MASK` in `_execSwap`; fee timing is `preFee`/`postFee` structs. /// @dev Bit mask 0x01: post-swap fee path when `(flags & mask) != 0`; clear = pre-swap fee from input token. - uint8 internal constant FEE_FLAG_BIT_MASK = 0x01; + uint8 internal constant FEE_FLAG_BIT_MASK = 0x01; /// @dev Bit mask 0x02: measure swap output by balance delta when `(flags & mask) != 0`; clear = returndata word. uint8 internal constant BALANCE_FLAG_BIT_MASK = 0x02; @@ -194,17 +194,16 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { * Common values: `0` (pre-fee, returndata), `1` (post-fee, returndata), * `2` (pre-fee, balance delta), `3` (post-fee, balance delta). * @param feeBytes 0x = no fee; 64 bytes = abi.encode(address receiver, uint256 amount). - * @dev minOutput is only enforced in post-fee mode. Pre-fee skips minOutput check. - * Post-fee: fee collected from output token after swap, then minOutput validated. - * Pre-fee: fee collected from input token before swap, minOutput skipped. - * Bits are read with bitwise AND against each mask; omitting both flags ⇒ pre-fee + returndata. + * @dev `minOutput` is the minimum gross amount coming out of the swap (before any output-token fee). + * It is enforced immediately after `_execSwap`, then post-swap fee (if any) is collected. + * Pre-fee paths take the input-side fee before the swap; `minOutput` still guards the swap outcome. + * Bits are read with bitwise AND against each mask; omitting both masks ⇒ pre-fee + returndata. */ - function swap( - InputData calldata input, - uint8 flags, - bytes calldata feeBytes, - SwapData calldata swapData - ) external payable returns (uint256 finalAmount) { + function swap(InputData calldata input, uint8 flags, bytes calldata feeBytes, SwapData calldata swapData) + external + payable + returns (uint256 finalAmount) + { if (input.user == address(0) || input.inputToken == address(0) || swapData.target == address(0)) { revert InvalidExecution(); } @@ -218,24 +217,33 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { bool postFee = hasFee && flags & FEE_FLAG_BIT_MASK != 0; uint256 swapInput = input.inputAmount; + // collect pre-swap fee if (hasFee && !postFee) { uint256 fee = _collectFee(input.inputToken, feeBytes); if (fee > swapInput) revert InsufficientFunds(); - unchecked { swapInput -= fee; } + unchecked { + swapInput -= fee; + } } + // approve swap router if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); } // BALANCE_FLAG_BIT_MASK: unset ⇒ decode output word from returndata; set ⇒ output = delta on outputToken + // perform swap finalAmount = _execSwap(swapData, flags & BALANCE_FLAG_BIT_MASK != 0); + // check swap output + if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); + // collect post-swap fee if (postFee) { uint256 fee = _collectFee(swapData.outputToken, feeBytes); if (fee > finalAmount) revert InsufficientFunds(); - unchecked { finalAmount -= fee; } - if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); + unchecked { + finalAmount -= fee; + } } } @@ -247,9 +255,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { * @notice Pull → optional pre/post swap fee → swap → bridge with runtime amount splicing. * @param flags Same packing as `swap`: 0x00–0x03 as documented on the flag constants block. * @param feeBytes 0x = no fee; 64 bytes = abi.encode(address receiver, uint256 amount). - * @dev minOutput is always enforced (after fee deduction in post-fee mode). - * Post-fee: fee collected from output token after swap, then minOutput validated. - * Pre-fee: fee collected from input token before swap, minOutput still validated. + * @dev Same `minOutput` rule as `swap`: validated on gross `_execSwap` output, then optional output fee applies. */ function swapAndBridge( InputData calldata input, @@ -259,8 +265,8 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { BridgeData calldata bridgeData ) external payable { if ( - bridgeData.target == address(0) || input.user == address(0) || - input.inputToken == address(0) || swapData.target == address(0) + bridgeData.target == address(0) || input.user == address(0) || input.inputToken == address(0) + || swapData.target == address(0) ) { revert InvalidExecution(); } @@ -273,29 +279,37 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { bool postFee = hasFee && flags & FEE_FLAG_BIT_MASK != 0; uint256 swapInput = input.inputAmount; + // collect pre-swap fee if (hasFee && !postFee) { uint256 fee = _collectFee(input.inputToken, feeBytes); if (fee > swapInput) revert InsufficientFunds(); - unchecked { swapInput -= fee; } + unchecked { + swapInput -= fee; + } } + // approve swap router if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); } address finalToken = swapData.outputToken; + // perform swap // BALANCE_FLAG_BIT_MASK: same returndata vs balance-delta measurement as `swap` uint256 finalAmount = _execSwap(swapData, flags & BALANCE_FLAG_BIT_MASK != 0); + // check swap output + if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); + + // collect post-swap fee if (postFee) { uint256 fee = _collectFee(finalToken, feeBytes); if (fee > finalAmount) revert InsufficientFunds(); - unchecked { finalAmount -= fee; } + unchecked { + finalAmount -= fee; + } } - // Always check minOutput (unlike standalone swap where pre-fee skips this) - if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); - _doBridge(finalToken, finalAmount, bridgeData); } @@ -430,7 +444,9 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { { if (exec.swap.approvalSpender != address(0) && exec.input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { uint256 swapInput; - unchecked { swapInput = exec.input.inputAmount - exec.preFee.amount; } + unchecked { + swapInput = exec.input.inputAmount - exec.preFee.amount; + } SafeTransferLib.safeApproveWithRetry(exec.input.inputToken, exec.swap.approvalSpender, swapInput); } From c45a025e60df18c0d7b9f7000e5d4dee5d4b9c4a Mon Sep 17 00:00:00 2001 From: arthcp Date: Fri, 15 May 2026 20:22:58 +0400 Subject: [PATCH 06/42] feat: calldata optimisations --- scripts/e2e/bridgeViaRelay.ts | 90 ++--- scripts/e2e/bridgeViaRelaySimple.ts | 42 +-- scripts/e2e/swapBridgeViaArbitrumNative.ts | 55 +-- scripts/e2e/swapBridgeViaCctp.ts | 107 +++--- scripts/e2e/swapBridgeViaCctpSimple.ts | 24 +- scripts/e2e/swapBridgeViaOft.ts | 115 +++--- scripts/e2e/swapBridgeViaStargateNative.ts | 136 +++---- scripts/e2e/utils/contractTypes.ts | 41 ++- scripts/e2e/utils/routerAbi.ts | 14 +- src/combined/BungeeOpenRouterV2Unchecked.sol | 354 +++++++++++-------- test/poc/OneInchCctpOpenRouterPoC.t.sol | 21 +- 11 files changed, 540 insertions(+), 459 deletions(-) diff --git a/scripts/e2e/bridgeViaRelay.ts b/scripts/e2e/bridgeViaRelay.ts index 2a73641..e464516 100644 --- a/scripts/e2e/bridgeViaRelay.ts +++ b/scripts/e2e/bridgeViaRelay.ts @@ -40,7 +40,7 @@ import { import { ROUTER_ABI } from './utils/routerAbi'; import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { MonolithicExecution, NO_FEE, NO_SWAP } from './utils/contractTypes'; +import { MonolithicExecutionCall, NO_FEE, NO_SWAP, monolithicArgs } from './utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; @@ -59,27 +59,29 @@ function buildMonolithicExecution( relaySpender: string, depositTarget: string, depositData: string, -): MonolithicExecution { +): MonolithicExecutionCall { return { - input: { - user: signerAddress, - inputToken: TOKENS.AAVE_POLYGON, - inputAmount, - }, - preFee: { - receiver: signerAddress, - amount: feeAmount, - }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { - target: depositTarget, - approvalSpender: relaySpender, - value: 0n, - data: depositData, - amountPositions: [], - useFinalAmountAsValue: false, + exec: { + input: { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount, + }, + preFee: { + receiver: signerAddress, + amount: feeAmount, + }, + swap: NO_SWAP, + postFee: NO_FEE, + bridge: { + target: depositTarget, + approvalSpender: relaySpender, + value: 0n, + }, + flags: 0n, }, + swapCallData: '0x', + bridgeCallData: depositData, }; } @@ -173,9 +175,7 @@ async function executeLeg(args: { depositTarget, depositData, ); - execCalldata = routerIface.encodeFunctionData('performExecution', [ - execPayload, - ]); + execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(execPayload)); } await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); @@ -205,27 +205,29 @@ function buildMonolithicExecutionUsdcPolygonToBase( relaySpender: string, depositTarget: string, depositData: string, -): MonolithicExecution { +): MonolithicExecutionCall { return { - input: { - user: signerAddress, - inputToken: TOKENS.USDC_POLYGON_CIRCLE, - inputAmount, - }, - preFee: { - receiver: signerAddress, - amount: feeAmount, - }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { - target: depositTarget, - approvalSpender: relaySpender, - value: 0n, - data: depositData, - amountPositions: [], - useFinalAmountAsValue: false, + exec: { + input: { + user: signerAddress, + inputToken: TOKENS.USDC_POLYGON_CIRCLE, + inputAmount, + }, + preFee: { + receiver: signerAddress, + amount: feeAmount, + }, + swap: NO_SWAP, + postFee: NO_FEE, + bridge: { + target: depositTarget, + approvalSpender: relaySpender, + value: 0n, + }, + flags: 0n, }, + swapCallData: '0x', + bridgeCallData: depositData, }; } @@ -319,9 +321,7 @@ async function executeLegUsdcPolygonToBase(args: { depositTarget, depositData, ); - execCalldata = routerIface.encodeFunctionData('performExecution', [ - execPayload, - ]); + execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(execPayload)); } await ensureAllowanceForAllowanceHolder( diff --git a/scripts/e2e/bridgeViaRelaySimple.ts b/scripts/e2e/bridgeViaRelaySimple.ts index 1077fbf..3577866 100644 --- a/scripts/e2e/bridgeViaRelaySimple.ts +++ b/scripts/e2e/bridgeViaRelaySimple.ts @@ -1,13 +1,12 @@ /** * Script — Bridge AAVE (Polygon PoS) → AAVE (Base) via Relay.link - * using the `bridge(InputData, bytes feeBytes, BridgeData)` entrypoint. + * using the `bridge(InputData, FeeData, BridgeData, bytes)` entrypoint. * * Flow: * 1. Read signer's Polygon AAVE (or USDC) balance. - * 2. Compute fee via FEE_BPS; encode as 64-byte ABI word-pair (receiver, amount). - * If FEE_AMOUNT_BPS=0, feeBytes is `0x` and the contract skips the fee entirely. + * 2. Compute fee via FEE_BPS; set fee.amount=0 to skip the fee entirely. * 3. Fetch Relay.link /quote/v2 for the net bridge amount (inputAmount − fee). - * 4. AllowanceHolder.exec → router.bridge(input, feeBytes, bridgeData). + * 4. AllowanceHolder.exec → router.bridge(input, fee, bridgeData, bridgeCallData). * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/bridgeViaRelaySimple.ts @@ -36,7 +35,7 @@ import { import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; import { getWalletErc20Balance } from './utils/erc20'; import { ROUTER_ABI } from './utils/routerAbi'; -import type { InputData, StaticBridgeData } from './utils/contractTypes'; +import type { BridgeData, FeeData, InputData } from './utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; import { logTxnSummary } from './utils/txnLogSummary'; import { @@ -46,30 +45,13 @@ import { const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); -// ─── feeBytes encoding ──────────────────────────────────────────────────────── - -/** - * Encode fee as the 64-byte ABI word-pair expected by the contract: - * abi.encode(address receiver, uint256 amount) - * Returns `'0x'` when feeAmount is zero so the contract skips the transfer. - */ -function encodeFeeBytes(receiver: string, feeAmount: bigint): string { - if (feeAmount === 0n) { - return '0x'; - } - return ethers.AbiCoder.defaultAbiCoder().encode( - ['address', 'uint256'], - [receiver, feeAmount], - ); -} - // ─── Execution builder ──────────────────────────────────────────────────────── interface BridgeParams { signerAddress: string; inputToken: string; inputAmount: bigint; - feeBytes: string; + fee: FeeData; relaySpender: string; depositTarget: string; /** Bridge calldata with finalAmount (= inputAmount - feeAmount) already encoded inside. */ @@ -83,16 +65,15 @@ function buildBridgeCalldata(routerIface: ethers.Interface, p: BridgeParams): st inputAmount: p.inputAmount, }; - // No amountPositions or useFinalAmountAsValue — the caller bakes the net amount + // No bridge amount-position flag or bridge-value flag — the caller bakes the net amount // directly into depositData before calling. - const bridgeData: StaticBridgeData = { + const bridgeData: BridgeData = { target: p.depositTarget, approvalSpender: p.relaySpender, value: 0n, - data: p.depositData, }; - return routerIface.encodeFunctionData('bridge', [input, p.feeBytes, bridgeData]); + return routerIface.encodeFunctionData('bridge', [input, p.fee, bridgeData, p.depositData]); } // ─── Execution leg ──────────────────────────────────────────────────────────── @@ -126,9 +107,8 @@ async function executeLeg(args: { console.log(`Fee (${FEE_BPS} bps): ${fmt(feeAmount)} ${config.symbol}`); console.log(`Bridge amount: ${fmt(bridgeAmount)} ${config.symbol}`); - // feeBytes: `0x` if no fee, else abi-encoded (receiver=signer, amount) - const feeBytes = encodeFeeBytes(signerAddress, feeAmount); - console.log(`feeBytes: ${feeBytes === '0x' ? '0x (no fee)' : `${feeBytes.slice(0, 18)}… (${feeBytes.length / 2 - 1} bytes)`}`); + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + console.log(`Fee tuple: ${fee.amount === 0n ? 'amount=0 (no fee)' : `receiver=${fee.receiver}, amount=${fee.amount}`}`); console.log('Fetching Relay.link quote...'); const quote = await fetchRelayQuoteV2({ @@ -151,7 +131,7 @@ async function executeLeg(args: { signerAddress, inputToken: config.inputToken, inputAmount, - feeBytes, + fee, relaySpender, depositTarget, depositData, diff --git a/scripts/e2e/swapBridgeViaArbitrumNative.ts b/scripts/e2e/swapBridgeViaArbitrumNative.ts index 30ac4a1..71c531b 100644 --- a/scripts/e2e/swapBridgeViaArbitrumNative.ts +++ b/scripts/e2e/swapBridgeViaArbitrumNative.ts @@ -12,7 +12,7 @@ * Monolithic mechanics: * - Pull inputAmount AAVE via AH.exec grant, approve OO router, swap AAVE → ETH. * - Post-swap fee (FEE_BPS) in ETH sent to signer. - * - useFinalAmountAsValue=true: router forwards actualFinalETH as msg.value to inbox. + * - BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to inbox. * - No ETH splice needed (depositEth takes no calldata amount param). * * Modular mechanics: @@ -62,7 +62,13 @@ import { encodeApprove, getWalletErc20Balance } from './utils/erc20'; import { ROUTER_ABI } from './utils/routerAbi'; import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { MonolithicExecution, NO_FEE, ZERO_ADDRESS } from './utils/contractTypes'; +import { + BRIDGE_VALUE_FLAG, + MonolithicExecutionCall, + NO_FEE, + ZERO_ADDRESS, + monolithicArgs, +} from './utils/contractTypes'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; import { @@ -205,7 +211,7 @@ function buildDepositEthCalldata(): string { /** * AAVE → OO → ETH → Arbitrum inbox (monolithic): * - input: AAVE pulled via AH - * - swap: AAVE → native ETH, useFinalAmountAsValue=true forwards actualFinalETH + * - swap: AAVE → native ETH, BRIDGE_VALUE_FLAG forwards actualFinalETH * - bridge: depositEth() — no amount in calldata, all ETH passed as msg.value */ function buildMonolithic( @@ -215,28 +221,29 @@ function buildMonolithic( minAmountOut: bigint, ooRouter: string, swapData: string, -): MonolithicExecution { +): MonolithicExecutionCall { return { - input: { user: signerAddress, inputToken: TOKENS.AAVE_ETH, inputAmount }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ooRouter, - outputToken: NATIVE_TOKEN_ADDRESS, - value: 0n, - minOutput: minAmountOut, - data: swapData, - returnDataWordOffset: 0n, - }, - postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { - target: ARBITRUM_INBOX, - approvalSpender: ZERO_ADDRESS, - value: 0n, // ignored — useFinalAmountAsValue=true - data: buildDepositEthCalldata(), - amountPositions: [], // no amount in calldata - useFinalAmountAsValue: true, + exec: { + input: { user: signerAddress, inputToken: TOKENS.AAVE_ETH, inputAmount }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { receiver: signerAddress, amount: feeAmount }, + bridge: { + target: ARBITRUM_INBOX, + approvalSpender: ZERO_ADDRESS, + value: 0n, // ignored when BRIDGE_VALUE_FLAG is set + }, + flags: BRIDGE_VALUE_FLAG, }, + swapCallData: swapData, + bridgeCallData: buildDepositEthCalldata(), }; } @@ -339,7 +346,7 @@ async function executeLeg( execCalldata = routerIface.encodeFunctionData('performModularExecution', [actions]); } else { const mono = buildMonolithic(signerAddress, inputAmount, feeAmount, minAmountOut, ooRouter, swapData); - execCalldata = routerIface.encodeFunctionData('performExecution', [mono]); + execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono)); } // Input is AAVE (ERC-20) — msg.value is always 0; ETH comes from the swap output. diff --git a/scripts/e2e/swapBridgeViaCctp.ts b/scripts/e2e/swapBridgeViaCctp.ts index e1afc98..22a734a 100644 --- a/scripts/e2e/swapBridgeViaCctp.ts +++ b/scripts/e2e/swapBridgeViaCctp.ts @@ -35,9 +35,11 @@ import { ROUTER_ABI } from './utils/routerAbi'; import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; import type { ModularAction } from './utils/modularActionsBuilder/index'; import { - MonolithicExecution, + MonolithicExecutionCall, NO_FEE, NO_SWAP, + bridgeAmountPositionFlag, + monolithicArgs, } from './utils/contractTypes'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; @@ -128,35 +130,36 @@ function buildMonolithicExecution( swapData: string, depositForBurnData: string, tokenMessenger: string, -): MonolithicExecution { +): MonolithicExecutionCall { return { - input: { - user: signerAddress, - inputToken: TOKENS.AAVE_POLYGON, - inputAmount, - }, - preFee: NO_FEE, - swap: { - target: ooRouterAddress, - approvalSpender: ooRouterAddress, - outputToken: TOKENS.USDC_POLYGON_CIRCLE, - value: 0n, - minOutput: minAmountOut, - data: swapData, - returnDataWordOffset: 0n, - }, - postFee: { - receiver: signerAddress, - amount: feeAmount, - }, - bridge: { - target: tokenMessenger, - approvalSpender: tokenMessenger, - value: 0n, - data: depositForBurnData, - amountPositions: [4n], - useFinalAmountAsValue: false, + exec: { + input: { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount, + }, + preFee: NO_FEE, + swap: { + target: ooRouterAddress, + approvalSpender: ooRouterAddress, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { + receiver: signerAddress, + amount: feeAmount, + }, + bridge: { + target: tokenMessenger, + approvalSpender: tokenMessenger, + value: 0n, + }, + flags: bridgeAmountPositionFlag(4n), }, + swapCallData: swapData, + bridgeCallData: depositForBurnData, }; } @@ -197,27 +200,29 @@ function buildMonolithicExecutionUsdcPolygonToBaseCctp( feeAmount: bigint, depositForBurnData: string, tokenMessenger: string, -): MonolithicExecution { +): MonolithicExecutionCall { return { - input: { - user: signerAddress, - inputToken: TOKENS.USDC_POLYGON_CIRCLE, - inputAmount, - }, - preFee: { - receiver: signerAddress, - amount: feeAmount, - }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { - target: tokenMessenger, - approvalSpender: tokenMessenger, - value: 0n, - data: depositForBurnData, - amountPositions: [4n], - useFinalAmountAsValue: false, + exec: { + input: { + user: signerAddress, + inputToken: TOKENS.USDC_POLYGON_CIRCLE, + inputAmount, + }, + preFee: { + receiver: signerAddress, + amount: feeAmount, + }, + swap: NO_SWAP, + postFee: NO_FEE, + bridge: { + target: tokenMessenger, + approvalSpender: tokenMessenger, + value: 0n, + }, + flags: bridgeAmountPositionFlag(4n), }, + swapCallData: '0x', + bridgeCallData: depositForBurnData, }; } @@ -291,7 +296,7 @@ async function executeLegUsdcPolygonToBaseCctp(args: { ), ]); } else { - execCalldata = routerIface.encodeFunctionData('performExecution', [ + execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs( buildMonolithicExecutionUsdcPolygonToBaseCctp( signerAddress, inputAmount, @@ -299,7 +304,7 @@ async function executeLegUsdcPolygonToBaseCctp(args: { depositForBurnData, polyCctp.tokenMessenger, ), - ]); + )); } await ensureAllowanceForAllowanceHolder( @@ -380,7 +385,7 @@ async function executeLeg(args: { ), ]); } else { - execCalldata = routerIface.encodeFunctionData('performExecution', [ + execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs( buildMonolithicExecution( signerAddress, inputAmount, @@ -391,7 +396,7 @@ async function executeLeg(args: { depositForBurnData, polyCctp.tokenMessenger, ), - ]); + )); } await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); diff --git a/scripts/e2e/swapBridgeViaCctpSimple.ts b/scripts/e2e/swapBridgeViaCctpSimple.ts index 46ae4f6..d5831e5 100644 --- a/scripts/e2e/swapBridgeViaCctpSimple.ts +++ b/scripts/e2e/swapBridgeViaCctpSimple.ts @@ -33,7 +33,7 @@ import { import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; import { getWalletErc20Balance } from './utils/erc20'; import { ROUTER_ABI } from './utils/routerAbi'; -import type { InputData, StaticBridgeData } from './utils/contractTypes'; +import type { BridgeData, FeeData, InputData } from './utils/contractTypes'; import { logTxnSummary } from './utils/txnLogSummary'; import { ensureRouterErc20Balance, @@ -42,16 +42,6 @@ import { const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); -function encodeFeeBytes(receiver: string, feeAmount: bigint): string { - if (feeAmount === 0n) { - return '0x'; - } - return ethers.AbiCoder.defaultAbiCoder().encode( - ['address', 'uint256'], - [receiver, feeAmount], - ); -} - /** * CCTP `depositForBurn` with explicit burn amount (net after optional fee). */ @@ -87,7 +77,7 @@ function buildBridgeCalldata( signerAddress: string; inputToken: string; inputAmount: bigint; - feeBytes: string; + fee: FeeData; tokenMessenger: string; depositData: string; }, @@ -98,17 +88,17 @@ function buildBridgeCalldata( inputAmount: args.inputAmount, }; - const bridgeData: StaticBridgeData = { + const bridgeData: BridgeData = { target: args.tokenMessenger, approvalSpender: args.tokenMessenger, value: 0n, - data: args.depositData, }; return routerIface.encodeFunctionData('bridge', [ input, - args.feeBytes, + args.fee, bridgeData, + args.depositData, ]); } @@ -157,13 +147,13 @@ async function main(): Promise { true, ); - const feeBytes = encodeFeeBytes(signerAddress, feeAmount); + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; const routerIface = new ethers.Interface(ROUTER_ABI); const execCalldata = buildBridgeCalldata(routerIface, { signerAddress, inputToken, inputAmount, - feeBytes, + fee, tokenMessenger: polyCctp.tokenMessenger, depositData, }); diff --git a/scripts/e2e/swapBridgeViaOft.ts b/scripts/e2e/swapBridgeViaOft.ts index 0bc8725..5259910 100644 --- a/scripts/e2e/swapBridgeViaOft.ts +++ b/scripts/e2e/swapBridgeViaOft.ts @@ -68,7 +68,13 @@ import { import { ROUTER_ABI } from './utils/routerAbi'; import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { MonolithicExecution, NO_FEE, NO_SWAP } from './utils/contractTypes'; +import { + MonolithicExecutionCall, + NO_FEE, + NO_SWAP, + bridgeAmountPositionFlag, + monolithicArgs, +} from './utils/contractTypes'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; import { @@ -234,7 +240,7 @@ function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { * - Swap AAVE → USDT0 via OpenOcean (swap step) * - Post-swap fee: FEE_BPS of estimated USDT0 output transferred to signer * - Bridge remaining USDT0 via OFT Adapter (approval required) - * - useFinalAmountAsValue=false; amountPositions=[196n] splices actual balance into amountLD + * - bridge amount position flag splices actual balance into amountLD at byte 196 * - bridge.value = nativeFeeWithBuffer (forwarded as LZ msg.value) */ function buildCase1Monolithic( @@ -246,35 +252,36 @@ function buildCase1Monolithic( swapData: string, oftSendData: string, nativeFeeWithBuffer: bigint, -): MonolithicExecution { +): MonolithicExecutionCall { return { - input: { - user: signer, - inputToken: TOKENS.AAVE_POLYGON, - inputAmount, - }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ooRouter, - outputToken: TOKENS.USDT0_POLYGON, - value: 0n, - minOutput: minAmountOut, - data: swapData, - returnDataWordOffset: 0n, - }, - postFee: { - receiver: signer, - amount: feeAmount, - }, - bridge: { - target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, // adapter needs ERC-20 approval - value: nativeFeeWithBuffer, // forwarded as LZ native fee - data: oftSendData, - amountPositions: [BigInt(OFT_AMOUNT_LD_OFFSET)], // splice actual USDT0 balance at byte 196 - useFinalAmountAsValue: false, + exec: { + input: { + user: signer, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount, + }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { + receiver: signer, + amount: feeAmount, + }, + bridge: { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: USDT0_OFT_ADAPTER_POLYGON, // adapter needs ERC-20 approval + value: nativeFeeWithBuffer, // forwarded as LZ native fee + }, + flags: bridgeAmountPositionFlag(OFT_AMOUNT_LD_OFFSET), }, + swapCallData: swapData, + bridgeCallData: oftSendData, }; } @@ -397,7 +404,7 @@ async function executeCase1Leg(args: { ), ]); } else { - execCalldata = routerIface.encodeFunctionData('performExecution', [ + execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs( buildCase1Monolithic( signerAddress, inputAmount, @@ -408,7 +415,7 @@ async function executeCase1Leg(args: { oftSendData, nativeFeeWithBuffer, ), - ]); + )); } await ensureAllowanceForAllowanceHolder( @@ -448,7 +455,7 @@ async function executeCase1Leg(args: { * - No swap (NO_SWAP) * - Pre-bridge fee: FEE_BPS of input USDT0 transferred to signer * - Bridge remaining USDT0 via OFT Adapter (approval required) - * - useFinalAmountAsValue=false; amountPositions=[196n] splices actual balance + * - bridge amount position flag splices actual balance at byte 196 * - bridge.value = nativeFeeWithBuffer (forwarded as LZ msg.value) */ function buildCase2Monolithic( @@ -457,27 +464,29 @@ function buildCase2Monolithic( feeAmount: bigint, oftSendData: string, nativeFeeWithBuffer: bigint, -): MonolithicExecution { +): MonolithicExecutionCall { return { - input: { - user: signer, - inputToken: TOKENS.USDT0_POLYGON, - inputAmount, - }, - preFee: { - receiver: signer, - amount: feeAmount, - }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { - target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, - value: nativeFeeWithBuffer, - data: oftSendData, - amountPositions: [BigInt(OFT_AMOUNT_LD_OFFSET)], - useFinalAmountAsValue: false, + exec: { + input: { + user: signer, + inputToken: TOKENS.USDT0_POLYGON, + inputAmount, + }, + preFee: { + receiver: signer, + amount: feeAmount, + }, + swap: NO_SWAP, + postFee: NO_FEE, + bridge: { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + value: nativeFeeWithBuffer, + }, + flags: bridgeAmountPositionFlag(OFT_AMOUNT_LD_OFFSET), }, + swapCallData: '0x', + bridgeCallData: oftSendData, }; } @@ -584,7 +593,7 @@ async function executeCase2Leg(args: { ), ]); } else { - execCalldata = routerIface.encodeFunctionData('performExecution', [ + execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs( buildCase2Monolithic( signerAddress, inputAmount, @@ -592,7 +601,7 @@ async function executeCase2Leg(args: { oftSendData, nativeFeeWithBuffer, ), - ]); + )); } await ensureAllowanceForAllowanceHolder( diff --git a/scripts/e2e/swapBridgeViaStargateNative.ts b/scripts/e2e/swapBridgeViaStargateNative.ts index 19384bf..18cedfe 100644 --- a/scripts/e2e/swapBridgeViaStargateNative.ts +++ b/scripts/e2e/swapBridgeViaStargateNative.ts @@ -9,15 +9,15 @@ * * Native-pool mechanics (cases 1 & 3): * send() requires msg.value >= amountLD + nativeFee (StargatePoolNative._assertMessagingFee). - * Monolithic: useFinalAmountAsValue=true (router forwards actualFinalAmount as msg.value). - * amountLD = minAmountOut - fee - nativeFeeWithBuffer; positions=[]. + * Monolithic: BRIDGE_VALUE_FLAG set (router forwards actualFinalAmount as msg.value). + * amountLD = minAmountOut - fee - nativeFeeWithBuffer; no splice flag. * Since actual >= min (OO slippage), msg.value >= amountLD + nativeFeeWithBuffer ✓ * Modular: amountLD = minAmountOut - fee - nativeFeeWithBuffer (same). * nativeCall Stargate with value = amountLD + nativeFeeWithBuffer = minAmountOut - fee. * * ERC20-pool mechanics (case 2): * send() uses ERC20 transferFrom for USDC; msg.value = nativeFee only. - * Monolithic: useFinalAmountAsValue=false, amountPositions=[196n], bridge.value=nativeFeeWithBuffer. + * Monolithic: bridge amount position flag set to 196, bridge.value=nativeFeeWithBuffer. * Modular: staticCall USDC.balanceOf(router) → spliceWord(196n) into Stargate calldata. * nativeCall Stargate with value = nativeFeeWithBuffer. * @@ -88,7 +88,15 @@ import { import { ROUTER_ABI } from './utils/routerAbi'; import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { MonolithicExecution, NO_FEE, NO_SWAP, ZERO_ADDRESS } from './utils/contractTypes'; +import { + BRIDGE_VALUE_FLAG, + MonolithicExecutionCall, + NO_FEE, + NO_SWAP, + ZERO_ADDRESS, + bridgeAmountPositionFlag, + monolithicArgs, +} from './utils/contractTypes'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; import { @@ -475,8 +483,8 @@ function buildStargateCalldata( /** * Monolithic for native-pool cases (cases 1 & 3): * - OO swap input token → native ETH - * - useFinalAmountAsValue=true: router forwards actualFinalETH as msg.value to Stargate - * - amountLD = minAmountOut - fee - nativeFeeWithBuffer; pre-encoded; no splice needed (positions=[]) + * - BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to Stargate + * - amountLD = minAmountOut - fee - nativeFeeWithBuffer; pre-encoded; no splice flag needed * - StargatePoolNative checks msg.value >= amountLD + nativeFee; satisfied since actual >= min */ function buildNativePoolMonolithic( @@ -488,36 +496,37 @@ function buildNativePoolMonolithic( ooRouter: string, swapData: string, stargateData: string, -): MonolithicExecution { +): MonolithicExecutionCall { return { - input: { user: signer, inputToken: cfg.inputToken, inputAmount }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ooRouter, - outputToken: NATIVE_TOKEN_ADDRESS, - value: 0n, - minOutput: minAmountOut, - data: swapData, - returnDataWordOffset: 0n, - }, - postFee: { receiver: signer, amount: feeAmount }, - bridge: { - target: cfg.bridgeContract, - approvalSpender: ZERO_ADDRESS, // no ERC20 approval for native ETH - value: 0n, // ignored when useFinalAmountAsValue=true - data: stargateData, - amountPositions: [], // amountLD is pre-encoded - useFinalAmountAsValue: true, // forward actualFinalETH as msg.value + exec: { + input: { user: signer, inputToken: cfg.inputToken, inputAmount }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { receiver: signer, amount: feeAmount }, + bridge: { + target: cfg.bridgeContract, + approvalSpender: ZERO_ADDRESS, // no ERC20 approval for native ETH + value: 0n, // ignored when BRIDGE_VALUE_FLAG is set + }, + flags: BRIDGE_VALUE_FLAG, }, + swapCallData: swapData, + bridgeCallData: stargateData, }; } /** * Monolithic for ERC20-pool case (case 2): * - No OO swap (NO_SWAP) — input USDC goes directly to bridge - * - useFinalAmountAsValue=false: USDC transferred via ERC20 approval - * - amountPositions=[196n]: router splices finalAmount into amountLD at runtime + * - USDC transferred via ERC20 approval + * - bridge amount position flag set to 196: router splices finalAmount into amountLD at runtime * - bridge.value=nativeFeeWithBuffer: forwarded as msg.value for the LZ fee */ function buildErc20PoolMonolithic( @@ -527,20 +536,22 @@ function buildErc20PoolMonolithic( feeAmount: bigint, stargateData: string, nativeFeeWithBuffer: bigint, -): MonolithicExecution { +): MonolithicExecutionCall { return { - input: { user: signer, inputToken: cfg.inputToken, inputAmount }, - preFee: NO_FEE, - swap: NO_SWAP, // skip swap — finalToken = inputToken, finalAmount = inputAmount - preFee - postFee: { receiver: signer, amount: feeAmount }, - bridge: { - target: cfg.bridgeContract, - approvalSpender: cfg.bridgeContract, // router must approve USDC to pool - value: nativeFeeWithBuffer, // POL/native forwarded as LZ fee msg.value - data: stargateData, - amountPositions: [BigInt(STARGATE_AMOUNT_LD_OFFSET)], // splice at byte 196 - useFinalAmountAsValue: false, + exec: { + input: { user: signer, inputToken: cfg.inputToken, inputAmount }, + preFee: NO_FEE, + swap: NO_SWAP, // skip swap — finalToken = inputToken, finalAmount = inputAmount - preFee + postFee: { receiver: signer, amount: feeAmount }, + bridge: { + target: cfg.bridgeContract, + approvalSpender: cfg.bridgeContract, // router must approve USDC to pool + value: nativeFeeWithBuffer, // POL/native forwarded as LZ fee msg.value + }, + flags: bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), }, + swapCallData: '0x', + bridgeCallData: stargateData, }; } @@ -665,30 +676,31 @@ function buildNativeInErc20BridgeMonolithic( stargateData: string, nativeFeeWithBuffer: bigint, ooSwapNativeWei: bigint, -): MonolithicExecution { +): MonolithicExecutionCall { const rawOoWei = ooSwapNativeWei > 0n ? ooSwapNativeWei : inputAmount; const polOrEthToOo = rawOoWei <= inputAmount ? rawOoWei : inputAmount; return { - input: { user: signer, inputToken: NATIVE_TOKEN_ADDRESS, inputAmount }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ZERO_ADDRESS, // no ERC20 approve for native ETH input - outputToken: cfg.ooSwap!.outToken, - value: polOrEthToOo, - minOutput: minAmountOut, - data: swapData, - returnDataWordOffset: 0n, - }, - postFee: { receiver: signer, amount: feeAmount }, // fee in OO output token (USDC/USDT0) - bridge: { - target: cfg.bridgeContract, - approvalSpender: cfg.bridgeContract, // router approves bridge contract to pull ERC20 - value: nativeFeeWithBuffer, // LZ fee in native gas token only - data: stargateData, - amountPositions: [BigInt(STARGATE_AMOUNT_LD_OFFSET)], // splice amountLD at runtime - useFinalAmountAsValue: false, + exec: { + input: { user: signer, inputToken: NATIVE_TOKEN_ADDRESS, inputAmount }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ZERO_ADDRESS, // no ERC20 approve for native ETH input + outputToken: cfg.ooSwap!.outToken, + value: polOrEthToOo, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { receiver: signer, amount: feeAmount }, // fee in OO output token (USDC/USDT0) + bridge: { + target: cfg.bridgeContract, + approvalSpender: cfg.bridgeContract, // router approves bridge contract to pull ERC20 + value: nativeFeeWithBuffer, // LZ fee in native gas token only + }, + flags: bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), }, + swapCallData: swapData, + bridgeCallData: stargateData, }; } @@ -963,7 +975,7 @@ async function executeLeg( } execCalldata = routerIface.encodeFunctionData('performModularExecution', [actions]); } else { - let mono: MonolithicExecution; + let mono: MonolithicExecutionCall; if (cfg.isNativePool) { mono = buildNativePoolMonolithic( signerAddress, cfg, inputAmountWei, feeAmount, minAmountOut, @@ -979,7 +991,7 @@ async function executeLeg( signerAddress, cfg, inputAmountWei, feeAmount, stargateData, nativeFeeWithBuffer, ); } - execCalldata = routerIface.encodeFunctionData('performExecution', [mono]); + execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono)); } const txValue = cfg.isNativeInput ? inputAmountWei + nativeFeeWithBuffer : nativeFeeWithBuffer; diff --git a/scripts/e2e/utils/contractTypes.ts b/scripts/e2e/utils/contractTypes.ts index ff9b5b6..2721773 100644 --- a/scripts/e2e/utils/contractTypes.ts +++ b/scripts/e2e/utils/contractTypes.ts @@ -23,7 +23,6 @@ export interface SwapData { outputToken: string; value: bigint; minOutput: bigint; - data: string; returnDataWordOffset: bigint; } @@ -31,17 +30,6 @@ export interface BridgeData { target: string; approvalSpender: string; value: bigint; - data: string; - amountPositions: bigint[]; - useFinalAmountAsValue: boolean; -} - -/** Simplified bridge descriptor for the no-swap `bridge()` entrypoint. */ -export interface StaticBridgeData { - target: string; - approvalSpender: string; - value: bigint; - data: string; } export interface MonolithicExecution { @@ -50,6 +38,34 @@ export interface MonolithicExecution { swap: SwapData; postFee: FeeData; bridge: BridgeData; + flags: bigint; +} + +export interface MonolithicExecutionCall { + exec: MonolithicExecution; + swapCallData: string; + bridgeCallData: string; +} + +export const BRIDGE_VALUE_FLAG = 4n; +export const BRIDGE_AMOUNT_POSITION_FLAG = 8n; +export const BRIDGE_AMOUNT_POSITION_SHIFT = 16n; +export const MAX_BRIDGE_AMOUNT_POSITION = 0xffffn; + +export function bridgeAmountPositionFlag(position: bigint | number): bigint { + const positionBigInt = BigInt(position); + if (positionBigInt < 0n || positionBigInt > MAX_BRIDGE_AMOUNT_POSITION) { + throw new Error(`bridge amount position exceeds uint16: ${positionBigInt}`); + } + return BRIDGE_AMOUNT_POSITION_FLAG | (positionBigInt << BRIDGE_AMOUNT_POSITION_SHIFT); +} + +export function monolithicArgs(call: MonolithicExecutionCall) { + return [ + call.exec, + call.swapCallData, + call.bridgeCallData, + ] as const; } // ─── Sentinel / zero helpers ────────────────────────────────────────────────── @@ -66,6 +82,5 @@ export const NO_SWAP: SwapData = { outputToken: ZERO_ADDRESS, value: 0n, minOutput: 0n, - data: '0x', returnDataWordOffset: 0n, }; diff --git a/scripts/e2e/utils/routerAbi.ts b/scripts/e2e/utils/routerAbi.ts index f2dae81..9494de6 100644 --- a/scripts/e2e/utils/routerAbi.ts +++ b/scripts/e2e/utils/routerAbi.ts @@ -8,10 +8,13 @@ export const ROUTER_ABI = [ ( (address user, address inputToken, uint256 inputAmount) input, (address receiver, uint256 amount) preFee, - (address target, address approvalSpender, address outputToken, uint256 value, uint256 minOutput, bytes data, uint256 returnDataWordOffset) swap, + (address target, address approvalSpender, address outputToken, uint256 value, uint256 minOutput, uint256 returnDataWordOffset) swap, (address receiver, uint256 amount) postFee, - (address target, address approvalSpender, uint256 value, bytes data, uint256[] amountPositions, bool useFinalAmountAsValue) bridge - ) exec + (address target, address approvalSpender, uint256 value) bridge, + uint256 flags + ) exec, + bytes swapCallData, + bytes bridgeCallData ) external payable`, // Modular path @@ -22,7 +25,8 @@ export const ROUTER_ABI = [ // Simple bridge path (no swap, no splicing — caller pre-encodes finalAmount into data) `function bridge( (address user, address inputToken, uint256 inputAmount) input, - bytes feeBytes, - (address target, address approvalSpender, uint256 value, bytes data) bridgeData + (address receiver, uint256 amount) fee, + (address target, address approvalSpender, uint256 value) bridgeData, + bytes bridgeCallData ) external payable`, ] as const; diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index d4bcaf5..b5685f5 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -53,7 +53,6 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { address outputToken; uint256 value; uint256 minOutput; - bytes data; uint256 returnDataWordOffset; } @@ -61,22 +60,6 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { address target; address approvalSpender; uint256 value; - bytes data; - uint256[] amountPositions; - // when true, bridge.value is ignored and finalAmount is forwarded as - // msg.value instead — needed for native-token bridges (e.g. Arbitrum inbox) - // where the bridged amount is only known at runtime. - bool useFinalAmountAsValue; - } - - /// @dev Simplified bridge descriptor for the no-swap `bridge()` path. - /// The caller knows `finalAmount = inputAmount - feeAmount` before encoding, - /// so no amount-splicing or runtime value overrides are needed. - struct StaticBridgeData { - address target; - address approvalSpender; - uint256 value; - bytes data; } struct MonolithicExecution { @@ -85,9 +68,10 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { SwapData swap; FeeData postFee; BridgeData bridge; - /// Packed byte; monolithic pipeline only tests `BALANCE_FLAG_BIT_MASK` (bit 1) in `_execSwap`. + /// Packed flags; monolithic pipeline tests `BALANCE_FLAG_BIT_MASK` in `_execSwap` + /// and `BRIDGE_VALUE_FLAG_BIT_MASK` in `_doBridge`. /// Fee timing uses `preFee` / `postFee` structs — `FEE_FLAG_BIT_MASK` (bit 0) is ignored here. - uint8 flags; + uint256 flags; } // ========================================================================= @@ -110,24 +94,29 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // Flags (swap / swapAndBridge / monolithic swap step) // ========================================================================= // - // Instead of two bool parameters, one uint8 packs independent switches; future flags can use - // bit 2 (0x04), bit 3 (0x08), etc. without changing the ABI shape. + // Instead of bool parameters, one uint256 packs independent switches without adding + // ABI range checks or extra words for standalone bools. // // Bit layout (least significant bits); test with `(flags & MASK) != 0`: - // bits 7..2 : reserved (0) + // bits 255..32 : reserved (0) + // bits 31..16 : bridge amount word byte offset, uint16, used only when bit 3 is set + // bits 15..4 : reserved (0) + // bit 3 : BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK (0x08) — splice finalAmount into bridge calldata + // bit 2 : BRIDGE_VALUE_FLAG_BIT_MASK (0x04) — bridge msg.value: static value vs final amount // bit 1 : BALANCE_FLAG_BIT_MASK (0x02) — swap output: returndata vs balance delta // bit 0 : FEE_FLAG_BIT_MASK (0x01) — swap fee: pre- vs post-swap (standalone paths only) // // Combined values for swap()/swapAndBridge(): // - // flags binary (low byte) postFee? balance-of output? - // ───── ────────────────── ──────── ────────────────── - // 0x00 00000000 no returndata word - // 0x01 00000001 yes returndata word - // 0x02 00000010 no balance delta on outputToken - // 0x03 00000011 yes balance delta on outputToken + // flags binary (low byte) postFee? balance-of output? bridge value? + // ───── ────────────────── ──────── ────────────────── ───────────── + // 0x00 00000000 no returndata word bridge.value + // 0x01 00000001 yes returndata word bridge.value + // 0x02 00000010 no balance delta on outputToken bridge.value + // 0x03 00000011 yes balance delta on outputToken bridge.value + // 0x04 00000100 no returndata word finalAmount // - // FEE_FLAG_BIT_MASK selects bit 0 — fee timing (see `_collectFee` + swap flow). + // FEE_FLAG_BIT_MASK selects bit 0 — fee timing. // Cleared — pull → deduct fee from input token → swap remainder → standalone swap skips minOutput. // Set — pull → swap full input → deduct fee from output token → standalone swap checks minOutput. // @@ -135,13 +124,33 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // Cleared — decode returned amount from call returndata at `swapData.returnDataWordOffset`. // Set — snapshot outputToken balance before call, measure (after − before) as output. // - // Monolithic `performExecution` applies only `BALANCE_FLAG_BIT_MASK` in `_execSwap`; fee timing is `preFee`/`postFee` structs. + // BRIDGE_VALUE_FLAG_BIT_MASK selects bit 2 — bridge native value source. + // Cleared — forward `bridge.value`. + // Set — forward finalAmount as msg.value. + // + // BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK selects bit 3 — bridge calldata amount splicing. + // Cleared — no runtime amount splice. + // Set — splice finalAmount at uint16(flags >> BRIDGE_AMOUNT_POSITION_SHIFT). + // + // Monolithic `performExecution` ignores `FEE_FLAG_BIT_MASK`; fee timing is `preFee`/`postFee` structs. /// @dev Bit mask 0x01: post-swap fee path when `(flags & mask) != 0`; clear = pre-swap fee from input token. - uint8 internal constant FEE_FLAG_BIT_MASK = 0x01; + uint256 internal constant FEE_FLAG_BIT_MASK = 0x01; /// @dev Bit mask 0x02: measure swap output by balance delta when `(flags & mask) != 0`; clear = returndata word. - uint8 internal constant BALANCE_FLAG_BIT_MASK = 0x02; + uint256 internal constant BALANCE_FLAG_BIT_MASK = 0x02; + + /// @dev Bit mask 0x04: bridge.value is ignored and finalAmount is forwarded as msg.value. + uint256 internal constant BRIDGE_VALUE_FLAG_BIT_MASK = 0x04; + + /// @dev Bit mask 0x08: splice finalAmount into bridge calldata at the uint16 position packed in flags. + uint256 internal constant BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK = 0x08; + + /// @dev Shift for the packed uint16 bridge amount position. + uint256 internal constant BRIDGE_AMOUNT_POSITION_SHIFT = 16; + + /// @dev Mask for the packed uint16 bridge amount position after shifting. + uint256 internal constant BRIDGE_AMOUNT_POSITION_MASK = 0xffff; // ========================================================================= // Errors @@ -173,15 +182,20 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /** * @notice Executes the monolithic pipeline without signature verification: * pull via AH, optional pre-swap fee, optional swap, optional - * post-swap fee, bridge call with multi-position amount splicing. + * post-swap fee, bridge call with optional single-position amount splicing. * @dev The caller MUST route through `AllowanceHolder.exec` so that * `_msgSender()` resolves to `exec.input.user`. There is no nonce or * deadline; replay protection is the caller's responsibility. * Bit 0 (`FEE_FLAG_BIT_MASK`) is unused in monolithic runs; fee placement is `preFee` / `postFee` structs. - * `exec.flags` only contributes `BALANCE_FLAG_BIT_MASK` to the optional `_execSwap` step. + * `exec.flags` contributes `BALANCE_FLAG_BIT_MASK` to `_execSwap` and + * `BRIDGE_VALUE_FLAG_BIT_MASK` to bridge msg.value selection. */ - function performExecution(MonolithicExecution calldata exec) external payable { - _runMonolithic(exec); + function performExecution( + MonolithicExecution calldata exec, + bytes calldata swapCallData, + bytes calldata bridgeCallData + ) external payable { + _runMonolithic(exec, swapCallData, bridgeCallData); } // ========================================================================= @@ -190,10 +204,10 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /** * @notice Pull → optional pre/post fee → swap. - * @param flags Packed `uint8`; OR masks then test with `flags & MASK != 0`. Masks: `FEE_FLAG_BIT_MASK` (0x01), `BALANCE_FLAG_BIT_MASK` (0x02). + * @param flags Packed flags; OR masks then test with `flags & MASK != 0`. Masks: `FEE_FLAG_BIT_MASK` (0x01), `BALANCE_FLAG_BIT_MASK` (0x02). * Common values: `0` (pre-fee, returndata), `1` (post-fee, returndata), * `2` (pre-fee, balance delta), `3` (post-fee, balance delta). - * @param feeBytes 0x = no fee; 64 bytes = abi.encode(address receiver, uint256 amount). + * @param fee Set `amount` to 0 to skip fee collection. * @dev minOutput is only enforced in post-fee mode. Pre-fee skips minOutput check. * Post-fee: fee collected from output token after swap, then minOutput validated. * Pre-fee: fee collected from input token before swap, minOutput skipped. @@ -201,9 +215,10 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { */ function swap( InputData calldata input, - uint8 flags, - bytes calldata feeBytes, - SwapData calldata swapData + uint256 flags, + FeeData calldata fee, + SwapData calldata swapData, + bytes calldata swapCallData ) external payable returns (uint256 finalAmount) { if (input.user == address(0) || input.inputToken == address(0) || swapData.target == address(0)) { revert InvalidExecution(); @@ -211,17 +226,20 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { _pullFromUser(input.inputToken, input.user, input.inputAmount); - // Check feeBytes first: flag bit is only read when a fee is actually present. + // Check fee amount first: flag bit is only read when a fee is actually present. // FEE_FLAG_BIT_MASK: unset ⇒ collect fee from input before swap; set ⇒ swap first, fee from output - bool hasFee = feeBytes.length != 0; + bool hasFee = fee.amount != 0; /// @dev if hasFee is false, we short-circuit and flag check wont execute at runtime saving gas bool postFee = hasFee && flags & FEE_FLAG_BIT_MASK != 0; uint256 swapInput = input.inputAmount; if (hasFee && !postFee) { - uint256 fee = _collectFee(input.inputToken, feeBytes); - if (fee > swapInput) revert InsufficientFunds(); - unchecked { swapInput -= fee; } + uint256 feeAmount = fee.amount; + if (feeAmount > swapInput) revert InsufficientFunds(); + CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); + unchecked { + swapInput -= feeAmount; + } } if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { @@ -229,12 +247,15 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } // BALANCE_FLAG_BIT_MASK: unset ⇒ decode output word from returndata; set ⇒ output = delta on outputToken - finalAmount = _execSwap(swapData, flags & BALANCE_FLAG_BIT_MASK != 0); + finalAmount = _execSwap(swapData, swapCallData, flags & BALANCE_FLAG_BIT_MASK != 0); if (postFee) { - uint256 fee = _collectFee(swapData.outputToken, feeBytes); - if (fee > finalAmount) revert InsufficientFunds(); - unchecked { finalAmount -= fee; } + uint256 feeAmount = fee.amount; + if (feeAmount > finalAmount) revert InsufficientFunds(); + CurrencyLib.transfer(swapData.outputToken, fee.receiver, feeAmount); + unchecked { + finalAmount -= feeAmount; + } if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); } } @@ -245,58 +266,87 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /** * @notice Pull → optional pre/post swap fee → swap → bridge with runtime amount splicing. - * @param flags Same packing as `swap`: 0x00–0x03 as documented on the flag constants block. - * @param feeBytes 0x = no fee; 64 bytes = abi.encode(address receiver, uint256 amount). + * @param flags Same packing as `swap`; additionally bit 2 forwards final amount as bridge msg.value. + * @param fee Set `amount` to 0 to skip fee collection. * @dev minOutput is always enforced (after fee deduction in post-fee mode). * Post-fee: fee collected from output token after swap, then minOutput validated. * Pre-fee: fee collected from input token before swap, minOutput still validated. */ function swapAndBridge( InputData calldata input, - uint8 flags, - bytes calldata feeBytes, + uint256 flags, + FeeData calldata fee, SwapData calldata swapData, - BridgeData calldata bridgeData + bytes calldata swapCallData, + BridgeData calldata bridgeData, + bytes calldata bridgeCallData ) external payable { if ( - bridgeData.target == address(0) || input.user == address(0) || - input.inputToken == address(0) || swapData.target == address(0) + bridgeData.target == address(0) || input.user == address(0) || input.inputToken == address(0) + || swapData.target == address(0) ) { revert InvalidExecution(); } - _pullFromUser(input.inputToken, input.user, input.inputAmount); + uint256 finalAmount = _swapAndBridgeSwap(input, flags, fee, swapData, swapCallData); - // Check feeBytes first: flag bit is only read when a fee is actually present. - // FEE_FLAG_BIT_MASK: same semantics as standalone `swap` (fee from input vs output token) - bool hasFee = feeBytes.length != 0; - bool postFee = hasFee && flags & FEE_FLAG_BIT_MASK != 0; - uint256 swapInput = input.inputAmount; + // Always check minOutput (unlike standalone swap where pre-fee skips this) + if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); - if (hasFee && !postFee) { - uint256 fee = _collectFee(input.inputToken, feeBytes); - if (fee > swapInput) revert InsufficientFunds(); - unchecked { swapInput -= fee; } - } + _finishSwapAndBridge(swapData.outputToken, finalAmount, bridgeData, bridgeCallData, flags); + } - if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); + function _swapAndBridgeSwap( + InputData calldata input, + uint256 flags, + FeeData calldata fee, + SwapData calldata swapData, + bytes calldata swapCallData + ) internal returns (uint256 finalAmount) { + _pullFromUser(input.inputToken, input.user, input.inputAmount); + bool postFee; + { + // Check fee amount first: flag bit is only read when a fee is actually present. + // FEE_FLAG_BIT_MASK: same semantics as standalone `swap` (fee from input vs output token) + uint256 feeAmount = fee.amount; + postFee = feeAmount != 0 && flags & FEE_FLAG_BIT_MASK != 0; + uint256 swapInput = input.inputAmount; + + if (feeAmount != 0 && !postFee) { + if (feeAmount > swapInput) revert InsufficientFunds(); + CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); + unchecked { + swapInput -= feeAmount; + } + } + + if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { + SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); + } } - address finalToken = swapData.outputToken; // BALANCE_FLAG_BIT_MASK: same returndata vs balance-delta measurement as `swap` - uint256 finalAmount = _execSwap(swapData, flags & BALANCE_FLAG_BIT_MASK != 0); + bool useBalanceOf = flags & BALANCE_FLAG_BIT_MASK != 0; + finalAmount = _execSwap(swapData, swapCallData, useBalanceOf); if (postFee) { - uint256 fee = _collectFee(finalToken, feeBytes); - if (fee > finalAmount) revert InsufficientFunds(); - unchecked { finalAmount -= fee; } + uint256 feeAmount = fee.amount; + if (feeAmount > finalAmount) revert InsufficientFunds(); + CurrencyLib.transfer(swapData.outputToken, fee.receiver, feeAmount); + unchecked { + finalAmount -= feeAmount; + } } + } - // Always check minOutput (unlike standalone swap where pre-fee skips this) - if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); - - _doBridge(finalToken, finalAmount, bridgeData); + function _finishSwapAndBridge( + address finalToken, + uint256 finalAmount, + BridgeData calldata bridgeData, + bytes calldata bridgeCallData, + uint256 flags + ) internal { + _doBridge(finalToken, finalAmount, bridgeData, bridgeCallData, flags); } // ========================================================================= @@ -305,50 +355,35 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /** * @notice Pull → optional pre-bridge fee → bridge, with no swap step. - * @dev `feeBytes` must be either empty (`0x`, skip fee) or exactly 64 bytes - * ABI-encoded as `(address receiver, uint256 amount)`. Any other - * length reverts with `InvalidExecution`. - * - * Because no swap is involved, `finalAmount = inputAmount - feeAmount` is + * @dev Because no swap is involved, `finalAmount = inputAmount - feeAmount` is * fully knowable by the caller before signing. The caller must therefore - * bake the correct amount directly into `bridgeData.data` and set + * bake the correct amount directly into `bridgeCallData` and set * `bridgeData.value` to the desired `msg.value` for the bridge call. * No runtime calldata splicing is performed. * * The caller MUST route through `AllowanceHolder.exec` for ERC-20 * inputs so that `_msgSender()` resolves to `input.user`. */ - function bridge(InputData calldata input, bytes calldata feeBytes, StaticBridgeData calldata bridgeData) - external - payable - { + function bridge( + InputData calldata input, + FeeData calldata fee, + BridgeData calldata bridgeData, + bytes calldata bridgeCallData + ) external payable { if (bridgeData.target == address(0) || input.user == address(0) || input.inputToken == address(0)) { revert InvalidExecution(); } - // feeBytes must be empty or exactly one ABI word-pair (address + uint256 = 64 bytes) - if (feeBytes.length != 0 && feeBytes.length != 64) { - revert InvalidExecution(); - } - // 1. pull funds from user via AllowanceHolder _pullFromUser(input.inputToken, input.user, input.inputAmount); - // 2. optional pre-bridge fee decoded from feeBytes; track net amount for approval - uint256 feeAmount; - if (feeBytes.length == 64) { - address feeReceiver; - assembly ("memory-safe") { - // feeBytes is a calldata slice: feeBytes.offset points at the raw bytes - feeReceiver := calldataload(feeBytes.offset) - feeAmount := calldataload(add(feeBytes.offset, 0x20)) - } - if (feeAmount != 0) { - if (feeAmount > input.inputAmount) { - revert InsufficientFunds(); - } - CurrencyLib.transfer(input.inputToken, feeReceiver, feeAmount); + // 2. optional pre-bridge fee; track net amount for approval + uint256 feeAmount = fee.amount; + if (feeAmount != 0) { + if (feeAmount > input.inputAmount) { + revert InsufficientFunds(); } + CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); } // 3. optional approval to bridge spender for the net amount (inputAmount - feeAmount) @@ -361,8 +396,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } // 4. bridge call — data and value are pre-encoded by the caller - bytes memory bData = bridgeData.data; - _doCall(bridgeData.target, bridgeData.value, bData, false); + _doCallCalldata(bridgeData.target, bridgeData.value, bridgeCallData, false); } // ========================================================================= @@ -381,7 +415,11 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // Internal: monolithic pipeline // ========================================================================= - function _runMonolithic(MonolithicExecution calldata exec) internal { + function _runMonolithic( + MonolithicExecution calldata exec, + bytes calldata swapCallData, + bytes calldata bridgeCallData + ) internal { if (exec.bridge.target == address(0) || exec.input.user == address(0) || exec.input.inputToken == address(0)) { revert InvalidExecution(); } @@ -398,7 +436,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { address finalToken; uint256 finalAmount; if (exec.swap.target != address(0)) { - (finalToken, finalAmount) = _performSwap(exec); + (finalToken, finalAmount) = _performSwap(exec, swapCallData); } else { if (exec.preFee.amount > exec.input.inputAmount) { revert InsufficientFunds(); @@ -421,21 +459,32 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } // 5. bridge: splice, approve, call - _doBridge(finalToken, finalAmount, exec.bridge); + _finishMonolithicBridge(exec, finalToken, finalAmount, bridgeCallData); } - function _performSwap(MonolithicExecution calldata exec) + function _finishMonolithicBridge( + MonolithicExecution calldata exec, + address finalToken, + uint256 finalAmount, + bytes calldata bridgeCallData + ) internal { + _doBridge(finalToken, finalAmount, exec.bridge, bridgeCallData, exec.flags); + } + + function _performSwap(MonolithicExecution calldata exec, bytes calldata swapCallData) internal returns (address finalToken, uint256 finalAmount) { if (exec.swap.approvalSpender != address(0) && exec.input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { uint256 swapInput; - unchecked { swapInput = exec.input.inputAmount - exec.preFee.amount; } + unchecked { + swapInput = exec.input.inputAmount - exec.preFee.amount; + } SafeTransferLib.safeApproveWithRetry(exec.input.inputToken, exec.swap.approvalSpender, swapInput); } // Monolithic path: only `BALANCE_FLAG_BIT_MASK` is read for `_execSwap`; fee uses `preFee` / `postFee`, not bit 0. - finalAmount = _execSwap(exec.swap, exec.flags & BALANCE_FLAG_BIT_MASK != 0); + finalAmount = _execSwap(exec.swap, swapCallData, exec.flags & BALANCE_FLAG_BIT_MASK != 0); if (finalAmount < exec.swap.minOutput) revert SwapOutputInsufficient(); finalToken = exec.swap.outputToken; } @@ -447,48 +496,43 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /// @dev Execute swap; output measured via returndata word or output-token balance delta. /// useBalanceOf=true: measure output as (balance after - balance before). /// useBalanceOf=false: decode output from returndata at swapData.returnDataWordOffset. - function _execSwap(SwapData calldata swapData, bool useBalanceOf) internal returns (uint256 finalAmount) { + function _execSwap(SwapData calldata swapData, bytes calldata swapCallData, bool useBalanceOf) + internal + returns (uint256 finalAmount) + { if (useBalanceOf) { // Balance delta mode: snapshot before, call, measure delta uint256 before = CurrencyLib.balanceOf(swapData.outputToken, address(this)); - _doCall(swapData.target, swapData.value, swapData.data, false); + _doCallCalldata(swapData.target, swapData.value, swapCallData, false); finalAmount = CurrencyLib.balanceOf(swapData.outputToken, address(this)) - before; } else { // Returndata mode: decode output from a specific word in returndata - bytes memory ret = _doCall(swapData.target, swapData.value, swapData.data, true); + bytes memory ret = _doCallCalldata(swapData.target, swapData.value, swapCallData, true); finalAmount = _decodeReturnWord(ret, swapData.returnDataWordOffset); } } - /// @dev Decode, validate, and collect fee from feeBytes. Returns fee amount (0 if feeBytes empty). - /// feeBytes encoding: - /// - 0x (zero length): no fee, return 0 immediately. - /// - 64 bytes: abi.encode(address receiver, uint256 amount). Transfer amount to receiver. - /// Caller must pass the correct `token` address: - /// - Pre-fee: pass inputToken (fee deducted before swap). - /// - Post-fee: pass outputToken (fee deducted after swap). - function _collectFee(address token, bytes calldata feeBytes) internal returns (uint256 feeAmount) { - if (feeBytes.length != 64) revert InvalidExecution(); - address receiver; - assembly ("memory-safe") { - receiver := calldataload(feeBytes.offset) - feeAmount := calldataload(add(feeBytes.offset, 0x20)) - } - if (feeAmount != 0) CurrencyLib.transfer(token, receiver, feeAmount); - } - /// @dev Splice finalAmount into bridge calldata, approve, and call bridge target. - function _doBridge(address token, uint256 amount, BridgeData calldata bd) internal { - bytes memory bData = bd.data; - BytesSpliceLib.spliceWords({data: bData, positions: bd.amountPositions, word: amount}); + function _doBridge( + address token, + uint256 amount, + BridgeData calldata bd, + bytes calldata bridgeCallData, + uint256 flags + ) internal { + bytes memory bData = bridgeCallData; + if (flags & BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK != 0) { + uint256 position = flags >> BRIDGE_AMOUNT_POSITION_SHIFT & BRIDGE_AMOUNT_POSITION_MASK; + BytesSpliceLib.spliceWord({data: bData, position: position, word: amount}); + } if (bd.approvalSpender != address(0) && token != CurrencyLib.NATIVE_TOKEN_ADDRESS) { SafeTransferLib.safeApproveWithRetry(token, bd.approvalSpender, amount); } - // when useFinalAmountAsValue, forward amount as msg.value for native-token bridges - uint256 bridgeValue = bd.useFinalAmountAsValue ? amount : bd.value; - _doCall(bd.target, bridgeValue, bData, false); + // when set, forward amount as msg.value for native-token bridges + uint256 bridgeValue = flags & BRIDGE_VALUE_FLAG_BIT_MASK != 0 ? amount : bd.value; + _doCall(bd.target, bridgeValue, bData); } // ========================================================================= @@ -617,13 +661,35 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // Internal: simple call dispatcher (used by monolithic path) // ========================================================================= - function _doCall(address target, uint256 value, bytes memory data, bool storeResult) + function _doCall(address target, uint256 value, bytes memory data) internal { + bool success; + assembly ("memory-safe") { + success := call(gas(), target, value, add(data, 0x20), mload(data), 0, 0) + } + + if (!success) { + bytes memory ret; + assembly ("memory-safe") { + let returnDataSize := returndatasize() + ret := mload(0x40) + mstore(ret, returnDataSize) + returndatacopy(add(ret, 0x20), 0, returnDataSize) + mstore(0x40, and(add(add(add(ret, 0x20), returnDataSize), 0x1f), not(0x1f))) + revert(add(ret, 0x20), mload(ret)) + } + } + } + + function _doCallCalldata(address target, uint256 value, bytes calldata data, bool storeResult) internal returns (bytes memory ret) { bool success; assembly ("memory-safe") { - success := call(gas(), target, value, add(data, 0x20), mload(data), 0, 0) + let ptr := mload(0x40) + calldatacopy(ptr, data.offset, data.length) + mstore(0x40, and(add(add(ptr, data.length), 0x1f), not(0x1f))) + success := call(gas(), target, value, ptr, data.length, 0, 0) } if (!success || storeResult) { diff --git a/test/poc/OneInchCctpOpenRouterPoC.t.sol b/test/poc/OneInchCctpOpenRouterPoC.t.sol index 0c24dff..6d5ee09 100644 --- a/test/poc/OneInchCctpOpenRouterPoC.t.sol +++ b/test/poc/OneInchCctpOpenRouterPoC.t.sol @@ -119,7 +119,7 @@ contract OneInchCctpOpenRouterPoCTest is Test { uint256 feeRecipientUsdcBefore = ERC20(POLYGON_USDC).balanceOf(FEE_RECIPIENT); uint256 usdcSupplyBefore = ERC20(POLYGON_USDC).totalSupply(); - Router.MonolithicExecution memory exec = + (Router.MonolithicExecution memory exec, bytes memory swapCallData, bytes memory bridgeCallData) = _buildMonolithicExecution(inputAmount, vm.parseBytes(ONEINCH_SWAP_CALLDATA)); vm.prank(FIXTURE_RECIPIENT); @@ -129,7 +129,7 @@ contract OneInchCctpOpenRouterPoCTest is Test { POLYGON_AAVE, inputAmount, payable(address(router)), - abi.encodeCall(router.performExecution, (exec)) + abi.encodeCall(router.performExecution, (exec, swapCallData, bridgeCallData)) ); uint256 executeGasUsed = gasBeforeExecute - gasleft(); emit log_named_uint("AllowanceHolder.exec -> router.performExecution gas used", executeGasUsed); @@ -239,10 +239,10 @@ contract OneInchCctpOpenRouterPoCTest is Test { function _buildMonolithicExecution(uint256 inputAmount, bytes memory swapCalldata) internal pure - returns (Router.MonolithicExecution memory exec) + returns (Router.MonolithicExecution memory exec, bytes memory swapCallData, bytes memory bridgeCallData) { - uint256[] memory amountPositions = new uint256[](1); - amountPositions[0] = 4; + swapCallData = swapCalldata; + bridgeCallData = _emptyDepositForBurnCalldata(); exec = Router.MonolithicExecution({ input: Router.InputData({user: FIXTURE_RECIPIENT, inputToken: POLYGON_AAVE, inputAmount: inputAmount}), @@ -253,18 +253,11 @@ contract OneInchCctpOpenRouterPoCTest is Test { outputToken: POLYGON_USDC, value: 0, minOutput: EXPECTED_SWAP_OUTPUT_USDC, - data: swapCalldata, returnDataWordOffset: 0 }), postFee: Router.FeeData({receiver: FEE_RECIPIENT, amount: ROUTE_FEE_USDC}), - bridge: Router.BridgeData({ - target: CCTP_TOKEN_MESSENGER_V2, - approvalSpender: CCTP_TOKEN_MESSENGER_V2, - value: 0, - data: _emptyDepositForBurnCalldata(), - amountPositions: amountPositions, - useFinalAmountAsValue: false - }) + bridge: Router.BridgeData({target: CCTP_TOKEN_MESSENGER_V2, approvalSpender: CCTP_TOKEN_MESSENGER_V2, value: 0}), + flags: 0x08 | (uint256(4) << 16) }); } From 82faec86732050ab3dc302ad6be970c7ecad36f5 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Fri, 15 May 2026 22:31:21 +0530 Subject: [PATCH 07/42] feat: requestHash event --- src/combined/BungeeOpenRouterV2Unchecked.sol | 27 +++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index 1a984d0..729699c 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -167,6 +167,12 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { error MissingNativeValue(uint256 actionIndex); error ReturnDataOutOfBounds(); + // ========================================================================= + // Events + // ========================================================================= + + event RequestExecuted(bytes32 indexed requestHash); + // ========================================================================= // Constructor // ========================================================================= @@ -189,13 +195,16 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { * Bit 0 (`FEE_FLAG_BIT_MASK`) is unused in monolithic runs; fee placement is `preFee` / `postFee` structs. * `exec.flags` contributes `BALANCE_FLAG_BIT_MASK` to `_execSwap` and * `BRIDGE_VALUE_FLAG_BIT_MASK` to bridge msg.value selection. + * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. */ function performExecution( + bytes32 requestHash, MonolithicExecution calldata exec, bytes calldata swapCallData, bytes calldata bridgeCallData ) external payable { _runMonolithic(exec, swapCallData, bridgeCallData); + emit RequestExecuted(requestHash); } // ========================================================================= @@ -204,6 +213,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /** * @notice Pull → optional pre/post fee → swap. + * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. * @param flags Packed flags; OR masks then test with `flags & MASK != 0`. Masks: `FEE_FLAG_BIT_MASK` (0x01), `BALANCE_FLAG_BIT_MASK` (0x02). * Common values: `0` (pre-fee, returndata), `1` (post-fee, returndata), * `2` (pre-fee, balance delta), `3` (post-fee, balance delta). @@ -214,6 +224,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { * Bits are read with bitwise AND against each mask; omitting both masks ⇒ pre-fee + returndata. */ function swap( + bytes32 requestHash, InputData calldata input, uint256 flags, FeeData calldata fee, @@ -261,6 +272,8 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { finalAmount -= feeAmount; } } + + emit RequestExecuted(requestHash); } // ========================================================================= @@ -269,11 +282,13 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /** * @notice Pull → optional pre/post swap fee → swap → bridge with runtime amount splicing. + * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. * @param flags Same packing as `swap`; additionally bit 2 forwards final amount as bridge msg.value. * @param fee Set `amount` to 0 to skip fee collection. * @dev Same `minOutput` rule as `swap`: validated on gross `_execSwap` output, then optional output fee applies. */ function swapAndBridge( + bytes32 requestHash, InputData calldata input, uint256 flags, FeeData calldata fee, @@ -292,6 +307,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { uint256 finalAmount = _swapAndBridgeSwap(input, flags, fee, swapData, swapCallData); _finishSwapAndBridge(swapData.outputToken, finalAmount, bridgeData, bridgeCallData, flags); + emit RequestExecuted(requestHash); } function _swapAndBridgeSwap( @@ -354,6 +370,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /** * @notice Pull → optional pre-bridge fee → bridge, with no swap step. + * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. * @dev Because no swap is involved, `finalAmount = inputAmount - feeAmount` is * fully knowable by the caller before signing. The caller must therefore * bake the correct amount directly into `bridgeCallData` and set @@ -364,6 +381,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { * inputs so that `_msgSender()` resolves to `input.user`. */ function bridge( + bytes32 requestHash, InputData calldata input, FeeData calldata fee, BridgeData calldata bridgeData, @@ -396,6 +414,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // 4. bridge call — data and value are pre-encoded by the caller _doCallCalldata(bridgeData.target, bridgeData.value, bridgeCallData, false); + emit RequestExecuted(requestHash); } // ========================================================================= @@ -405,9 +424,15 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /** * @notice Runs a sequence of generic actions with optional returndata * splicing between steps. No signature verification. + * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. */ - function performModularExecution(Action[] calldata actions) external payable returns (bytes[] memory results) { + function performModularExecution(bytes32 requestHash, Action[] calldata actions) + external + payable + returns (bytes[] memory results) + { results = _performActions(actions); + emit RequestExecuted(requestHash); } // ========================================================================= From 00e3801f3fc84f9fe18fe37cc04081fefe52b948 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Sat, 16 May 2026 00:18:15 +0530 Subject: [PATCH 08/42] refactor: tests --- scripts/e2e/approveViaModular.ts | 2 + .../e2e/arbitrum/performExecution.postFee.ts | 171 +++++++++++ .../performModularExecution.postFee.ts | 165 +++++++++++ scripts/e2e/bridgeViaRelay.ts | 4 +- scripts/e2e/bridgeViaRelaySimple.ts | 9 +- scripts/e2e/cctp/performExecution.postFee.ts | 161 +++++++++++ scripts/e2e/cctp/performExecution.preFee.ts | 115 ++++++++ .../cctp/performModularExecution.postFee.ts | 158 +++++++++++ .../cctp/performModularExecution.preFee.ts | 114 ++++++++ .../cctp/swapAndBridge.postFee.balanceOf.ts | 219 +++++++++++++++ .../cctp/swapAndBridge.postFee.returndata.ts | 219 +++++++++++++++ .../cctp/swapAndBridge.preFee.balanceOf.ts | 221 +++++++++++++++ .../cctp/swapAndBridge.preFee.returndata.ts | 221 +++++++++++++++ scripts/e2e/config.ts | 13 +- scripts/e2e/oft/performExecution.postFee.ts | 183 ++++++++++++ scripts/e2e/oft/performExecution.preFee.ts | 137 +++++++++ .../oft/performModularExecution.postFee.ts | 178 ++++++++++++ .../e2e/oft/performModularExecution.preFee.ts | 134 +++++++++ .../oft/swapAndBridge.postFee.balanceOf.ts | 262 +++++++++++++++++ .../oft/swapAndBridge.postFee.returndata.ts | 262 +++++++++++++++++ .../e2e/oft/swapAndBridge.preFee.balanceOf.ts | 261 +++++++++++++++++ .../oft/swapAndBridge.preFee.returndata.ts | 261 +++++++++++++++++ ...arbUsdcBaseEth.performExecution.postFee.ts | 191 +++++++++++++ ...BaseEth.performModularExecution.postFee.ts | 178 ++++++++++++ ...baseUsdcArbEth.performExecution.postFee.ts | 184 ++++++++++++ ...cArbEth.performModularExecution.postFee.ts | 174 ++++++++++++ ...gonPolUsdt0Arb.performExecution.postFee.ts | 229 +++++++++++++++ ...sdt0Arb.performModularExecution.postFee.ts | 210 ++++++++++++++ ...olygonUsdcBase.performExecution.postFee.ts | 135 +++++++++ ...sdcBase.performModularExecution.postFee.ts | 133 +++++++++ .../swapAndBridge.postFee.balanceOf.ts | 263 +++++++++++++++++ .../swapAndBridge.postFee.returndata.ts | 265 ++++++++++++++++++ .../swapAndBridge.preFee.balanceOf.ts | 261 +++++++++++++++++ .../swapAndBridge.preFee.returndata.ts | 262 +++++++++++++++++ scripts/e2e/swap/swap.postFee.balanceOf.ts | 174 ++++++++++++ scripts/e2e/swap/swap.postFee.returndata.ts | 169 +++++++++++ scripts/e2e/swap/swap.preFee.balanceOf.ts | 174 ++++++++++++ scripts/e2e/swap/swap.preFee.returndata.ts | 169 +++++++++++ scripts/e2e/swapBridgeViaArbitrumNative.ts | 7 +- scripts/e2e/swapBridgeViaCctp.ts | 7 +- scripts/e2e/swapBridgeViaCctpSimple.ts | 3 +- scripts/e2e/swapBridgeViaOft.ts | 7 +- scripts/e2e/swapBridgeViaStargateNative.ts | 7 +- scripts/e2e/utils/contractTypes.ts | 15 +- scripts/e2e/utils/reproducibility.ts | 12 +- scripts/e2e/utils/routerAbi.ts | 27 +- 46 files changed, 6702 insertions(+), 24 deletions(-) create mode 100644 scripts/e2e/arbitrum/performExecution.postFee.ts create mode 100644 scripts/e2e/arbitrum/performModularExecution.postFee.ts create mode 100644 scripts/e2e/cctp/performExecution.postFee.ts create mode 100644 scripts/e2e/cctp/performExecution.preFee.ts create mode 100644 scripts/e2e/cctp/performModularExecution.postFee.ts create mode 100644 scripts/e2e/cctp/performModularExecution.preFee.ts create mode 100644 scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts create mode 100644 scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts create mode 100644 scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts create mode 100644 scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts create mode 100644 scripts/e2e/oft/performExecution.postFee.ts create mode 100644 scripts/e2e/oft/performExecution.preFee.ts create mode 100644 scripts/e2e/oft/performModularExecution.postFee.ts create mode 100644 scripts/e2e/oft/performModularExecution.preFee.ts create mode 100644 scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts create mode 100644 scripts/e2e/oft/swapAndBridge.postFee.returndata.ts create mode 100644 scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts create mode 100644 scripts/e2e/oft/swapAndBridge.preFee.returndata.ts create mode 100644 scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts create mode 100644 scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts create mode 100644 scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts create mode 100644 scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts create mode 100644 scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts create mode 100644 scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts create mode 100644 scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts create mode 100644 scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts create mode 100644 scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts create mode 100644 scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts create mode 100644 scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts create mode 100644 scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts create mode 100644 scripts/e2e/swap/swap.postFee.balanceOf.ts create mode 100644 scripts/e2e/swap/swap.postFee.returndata.ts create mode 100644 scripts/e2e/swap/swap.preFee.balanceOf.ts create mode 100644 scripts/e2e/swap/swap.preFee.returndata.ts diff --git a/scripts/e2e/approveViaModular.ts b/scripts/e2e/approveViaModular.ts index 3d9f34e..b110e7f 100644 --- a/scripts/e2e/approveViaModular.ts +++ b/scripts/e2e/approveViaModular.ts @@ -26,6 +26,7 @@ dotenv.config(); import { CHAIN_IDS, routerAddressForChain, RPC, TOKENS } from './config'; import { encodeApprove } from './utils/erc20'; import { ROUTER_ABI } from './utils/routerAbi'; +import { ZERO_BYTES32 } from './utils/contractTypes'; // ─── actionInfo helpers ─────────────────────────────────────────────────────── @@ -96,6 +97,7 @@ async function run(): Promise { ]; const calldata = routerIface.encodeFunctionData('performModularExecution', [ + ZERO_BYTES32, actions, ]); diff --git a/scripts/e2e/arbitrum/performExecution.postFee.ts b/scripts/e2e/arbitrum/performExecution.postFee.ts new file mode 100644 index 0000000..c05764a --- /dev/null +++ b/scripts/e2e/arbitrum/performExecution.postFee.ts @@ -0,0 +1,171 @@ +/** + * Route: Ethereum AAVE → ETH (OpenOcean) → Arbitrum ETH (inbox depositEth) + * Function: performExecution (monolithic) + * Fee: postFee — FEE_BPS of estimatedOut ETH deducted after swap + * + * BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to inbox.depositEth(). + * Input is AAVE (ERC-20) so AllowanceHolder.exec is required. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + ARBITRUM_INBOX, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + NATIVE_TOKEN_ADDRESS, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + BRIDGE_VALUE_FLAG, + NO_FEE, + ZERO_ADDRESS, + ZERO_BYTES32, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_ETH = routerAddressForChain(CHAIN_IDS.ETHEREUM); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_ETH, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_ETH, + account: ROUTER_ETH, + gasPrice: '20', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ETHEREUM}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +function buildDepositEthCalldata(): string { + return new ethers.Interface([ + 'function depositEth() external payable returns (uint256)', + ]).encodeFunctionData('depositEth', []); +} + +async function estimateArbitrumBridgeFee(provider: ethers.Provider): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ParentToChildMessageGasEstimator } = require('@arbitrum/sdk'); + const estimator = new ParentToChildMessageGasEstimator(provider); + const l2GasPrice = (await new ethers.JsonRpcProvider(RPC.ARBITRUM).getFeeData()).gasPrice ?? 0n; + const submissionFee = await estimator.estimateSubmissionFee(provider, 0n, 0n); + const executionCost = 250000n * (l2GasPrice + (l2GasPrice * 20n) / 100n); + const totalFee = BigInt(submissionFee.toString()) + executionCost; + console.log(` Estimated Arbitrum bridge fee: ${ethers.formatEther(totalFee)} ETH`); + return totalFee; + } catch (err) { + const fallback = ethers.parseEther('0.001'); + console.warn(` Arb fee estimation failed (${(err as Error).message}), using fallback: ${ethers.formatEther(fallback)} ETH`); + return fallback; + } +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.ETHEREUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance, decimals } = await getWalletErc20Balance(TOKENS.AAVE_ETH, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero AAVE on Ethereum`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ETH}`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, decimals)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OpenOcean quote (AAVE → ETH)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log(` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + const arbFee = await estimateArbitrumBridgeFee(provider); + if (estimatedOut < feeAmount + arbFee) { + console.warn(` Warning: estimated ETH may be insufficient to cover fee + bridge cost`); + } + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_ETH, ROUTER_ETH); + await ensureRouterNativeBalance(signer, ROUTER_ETH); + await ensureRouterApproval(signer, ROUTER_ETH, TOKENS.AAVE_ETH, ooRouter); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: TOKENS.AAVE_ETH, inputAmount }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { receiver: signerAddress, amount: feeAmount }, + bridge: { target: ARBITRUM_INBOX, approvalSpender: ZERO_ADDRESS, value: 0n }, + flags: BRIDGE_VALUE_FLAG, + }, + swapCallData: swapData, + bridgeCallData: buildDepositEthCalldata(), + }; + + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_ETH, inputAmount); + const receipt = await execViaAH(signer, ROUTER_ETH, TOKENS.AAVE_ETH, inputAmount, ROUTER_ETH, callData, 0n); + + logTxnSummary( + 'Ethereum AAVE → Arbitrum ETH (depositEth) — performExecution postFee', + CHAIN_IDS.ETHEREUM, + receipt, + ); + + console.log('\nETH arrives on Arbitrum once the retryable ticket is processed.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/arbitrum/performModularExecution.postFee.ts b/scripts/e2e/arbitrum/performModularExecution.postFee.ts new file mode 100644 index 0000000..f3c01c6 --- /dev/null +++ b/scripts/e2e/arbitrum/performModularExecution.postFee.ts @@ -0,0 +1,165 @@ +/** + * Route: Ethereum AAVE → ETH (OpenOcean) → Arbitrum ETH (inbox depositEth) + * Function: performModularExecution (modular) + * Fee: postFee — FEE_BPS of estimatedOut ETH sent to signer after swap + * + * Modular action sequence: + * [0] AH.transferFrom(AAVE, signer, router, inputAmount) + * [1] AAVE.approve(ooRouter, inputAmount) + * [2] call(ooRouter, swapData) — AAVE → ETH lands in router + * [3] nativeCall(signer, '0x', feeAmount) — ETH fee to signer + * [4] nativeCall(inbox, depositEth(), bridgeValue) + * + * Input is AAVE (ERC-20) so AllowanceHolder.exec is required. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performModularExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + ARBITRUM_INBOX, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ALLOWANCE_HOLDER, + NATIVE_TOKEN_ADDRESS, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_ETH = routerAddressForChain(CHAIN_IDS.ETHEREUM); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_ETH, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_ETH, + account: ROUTER_ETH, + gasPrice: '20', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ETHEREUM}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +function buildDepositEthCalldata(): string { + return new ethers.Interface([ + 'function depositEth() external payable returns (uint256)', + ]).encodeFunctionData('depositEth', []); +} + +async function estimateArbitrumBridgeFee(provider: ethers.Provider): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ParentToChildMessageGasEstimator } = require('@arbitrum/sdk'); + const estimator = new ParentToChildMessageGasEstimator(provider); + const l2GasPrice = (await new ethers.JsonRpcProvider(RPC.ARBITRUM).getFeeData()).gasPrice ?? 0n; + const submissionFee = await estimator.estimateSubmissionFee(provider, 0n, 0n); + const executionCost = 250000n * (l2GasPrice + (l2GasPrice * 20n) / 100n); + const totalFee = BigInt(submissionFee.toString()) + executionCost; + console.log(` Estimated Arbitrum bridge fee: ${ethers.formatEther(totalFee)} ETH`); + return totalFee; + } catch (err) { + const fallback = ethers.parseEther('0.001'); + console.warn(` Arb fee estimation failed (${(err as Error).message}), using fallback: ${ethers.formatEther(fallback)} ETH`); + return fallback; + } +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.ETHEREUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance, decimals } = await getWalletErc20Balance(TOKENS.AAVE_ETH, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero AAVE on Ethereum`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ETH}`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, decimals)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OpenOcean quote (AAVE → ETH)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log(` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + const arbFee = await estimateArbitrumBridgeFee(provider); + // bridgeValue uses minAmountOut-based floor so the nativeCall carries at least the bridge cost + const bridgeValue = minAmountOut > feeAmount ? minAmountOut - feeAmount : 0n; + console.log(` Bridge value: ${ethers.formatEther(bridgeValue)} ETH (floor for nativeCall)`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_ETH, ROUTER_ETH); + await ensureRouterNativeBalance(signer, ROUTER_ETH); + await ensureRouterApproval(signer, ROUTER_ETH, TOKENS.AAVE_ETH, ooRouter); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.AAVE_ETH, signerAddress, ROUTER_ETH, inputAmount])); + exec.call(TOKENS.AAVE_ETH, encodeApprove(ooRouter, inputAmount)); + exec.call(ooRouter, swapData); + exec.nativeCall(signerAddress, '0x', feeAmount); + exec.nativeCall(ARBITRUM_INBOX, buildDepositEthCalldata(), bridgeValue); + + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_ETH, inputAmount); + const receipt = await execViaAH(signer, ROUTER_ETH, TOKENS.AAVE_ETH, inputAmount, ROUTER_ETH, callData, 0n); + + logTxnSummary( + 'Ethereum AAVE → Arbitrum ETH (depositEth) — performModularExecution postFee', + CHAIN_IDS.ETHEREUM, + receipt, + ); + + console.log('\nETH arrives on Arbitrum once the retryable ticket is processed.'); + + // Suppress unused-variable warning for arbFee (kept for informational logging above) + void arbFee; +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/bridgeViaRelay.ts b/scripts/e2e/bridgeViaRelay.ts index e464516..60b7c08 100644 --- a/scripts/e2e/bridgeViaRelay.ts +++ b/scripts/e2e/bridgeViaRelay.ts @@ -40,7 +40,7 @@ import { import { ROUTER_ABI } from './utils/routerAbi'; import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { MonolithicExecutionCall, NO_FEE, NO_SWAP, monolithicArgs } from './utils/contractTypes'; +import { MonolithicExecutionCall, NO_FEE, NO_SWAP, ZERO_BYTES32, monolithicArgs } from './utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; import { sleep } from './utils/sleep'; import { logTxnSummary } from './utils/txnLogSummary'; @@ -164,6 +164,7 @@ async function executeLeg(args: { depositData, ); execCalldata = routerIface.encodeFunctionData('performModularExecution', [ + ZERO_BYTES32, actions, ]); } else { @@ -310,6 +311,7 @@ async function executeLegUsdcPolygonToBase(args: { depositData, ); execCalldata = routerIface.encodeFunctionData('performModularExecution', [ + ZERO_BYTES32, actions, ]); } else { diff --git a/scripts/e2e/bridgeViaRelaySimple.ts b/scripts/e2e/bridgeViaRelaySimple.ts index 3577866..4a7a78f 100644 --- a/scripts/e2e/bridgeViaRelaySimple.ts +++ b/scripts/e2e/bridgeViaRelaySimple.ts @@ -35,6 +35,7 @@ import { import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; import { getWalletErc20Balance } from './utils/erc20'; import { ROUTER_ABI } from './utils/routerAbi'; +import { ZERO_BYTES32 } from './utils/contractTypes'; import type { BridgeData, FeeData, InputData } from './utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; import { logTxnSummary } from './utils/txnLogSummary'; @@ -73,7 +74,13 @@ function buildBridgeCalldata(routerIface: ethers.Interface, p: BridgeParams): st value: 0n, }; - return routerIface.encodeFunctionData('bridge', [input, p.fee, bridgeData, p.depositData]); + return routerIface.encodeFunctionData('bridge', [ + ZERO_BYTES32, + input, + p.fee, + bridgeData, + p.depositData, + ]); } // ─── Execution leg ──────────────────────────────────────────────────────────── diff --git a/scripts/e2e/cctp/performExecution.postFee.ts b/scripts/e2e/cctp/performExecution.postFee.ts new file mode 100644 index 0000000..45d8544 --- /dev/null +++ b/scripts/e2e/cctp/performExecution.postFee.ts @@ -0,0 +1,161 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) + * Function: performExecution (monolithic) + * Fee: postFee — FEE_BPS of estimatedOut USDC deducted after swap + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/performExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + NO_FEE, + ZERO_BYTES32, + bridgeAmountPositionFlag, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number, +): string { + const iface = new ethers.Interface([ + 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external', + ]); + return iface.encodeFunctionData('depositForBurn', [ + 0n, + destinationCctpDomain, + ethers.zeroPadValue(recipientAddress, 32), + burnToken, + ethers.ZeroHash, + 1_000_000n, + 1000, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.AAVE_POLYGON, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + console.log('Fetching OpenOcean quote (AAVE → USDC)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log(` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + const depositForBurnData = buildDepositForBurnCalldata(signerAddress, polyCctp.usdcAddress, baseCctp.cctpDomain); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { receiver: signerAddress, amount: feeAmount }, + bridge: { target: polyCctp.tokenMessenger, approvalSpender: polyCctp.tokenMessenger, value: 0n }, + flags: bridgeAmountPositionFlag(4), + }, + swapCallData: swapData, + bridgeCallData: depositForBurnData, + }; + + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, inputAmount, ROUTER_POLYGON, callData); + + logTxnSummary( + 'Polygon AAVE → Base USDC (CCTP) — performExecution postFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log(`\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/cctp/performExecution.preFee.ts b/scripts/e2e/cctp/performExecution.preFee.ts new file mode 100644 index 0000000..07808f6 --- /dev/null +++ b/scripts/e2e/cctp/performExecution.preFee.ts @@ -0,0 +1,115 @@ +/** + * Route: Polygon USDC → Base USDC (CCTP depositForBurn, no swap) + * Function: performExecution (monolithic) + * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/performExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + NO_FEE, + NO_SWAP, + ZERO_BYTES32, + bridgeAmountPositionFlag, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number, +): string { + const iface = new ethers.Interface([ + 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external', + ]); + return iface.encodeFunctionData('depositForBurn', [ + 0n, + destinationCctpDomain, + ethers.zeroPadValue(recipientAddress, 32), + burnToken, + ethers.ZeroHash, + 1_000_000n, + 1000, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_POLYGON_CIRCLE, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero Circle native USDC on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(inputAmount - feeAmount, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + const depositForBurnData = buildDepositForBurnCalldata(signerAddress, polyCctp.usdcAddress, baseCctp.cctpDomain); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: TOKENS.USDC_POLYGON_CIRCLE, inputAmount }, + preFee: { receiver: signerAddress, amount: feeAmount }, + swap: NO_SWAP, + postFee: NO_FEE, + bridge: { target: polyCctp.tokenMessenger, approvalSpender: polyCctp.tokenMessenger, value: 0n }, + flags: bridgeAmountPositionFlag(4), + }, + swapCallData: '0x', + bridgeCallData: depositForBurnData, + }; + + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData); + + logTxnSummary( + 'Polygon USDC → Base USDC (CCTP) — performExecution preFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log(`\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/cctp/performModularExecution.postFee.ts b/scripts/e2e/cctp/performModularExecution.postFee.ts new file mode 100644 index 0000000..719e0b9 --- /dev/null +++ b/scripts/e2e/cctp/performModularExecution.postFee.ts @@ -0,0 +1,158 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) + * Function: performModularExecution (modular) + * Fee: postFee — FEE_BPS of estimatedOut USDC transferred to signer after swap + * + * Modular action sequence: + * [0] AH.transferFrom(AAVE, signer, router, inputAmount) + * [1] AAVE.approve(ooRouter, inputAmount) + * [2] ooRouter swap calldata — AAVE → USDC lands in router + * [3] USDC.transfer(signer, feeAmount) — post-swap fee + * [4] USDC.approve(tokenMessenger, MaxUint256) + * [5] STATICCALL USDC.balanceOf(router) + * [6] tokenMessenger.depositForBurn(...) — spliceArg(0) patches amount from [5] + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/performModularExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ALLOWANCE_HOLDER, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number, +): string { + const iface = new ethers.Interface([ + 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external', + ]); + return iface.encodeFunctionData('depositForBurn', [ + 0n, + destinationCctpDomain, + ethers.zeroPadValue(recipientAddress, 32), + burnToken, + ethers.ZeroHash, + 1_000_000n, + 1000, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.AAVE_POLYGON, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + console.log('Fetching OpenOcean quote (AAVE → USDC)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log(` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + const depositForBurnData = buildDepositForBurnCalldata(signerAddress, polyCctp.usdcAddress, baseCctp.cctpDomain); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.AAVE_POLYGON, signerAddress, ROUTER_POLYGON, inputAmount])); + exec.call(TOKENS.AAVE_POLYGON, encodeApprove(ooRouter, inputAmount)); + exec.call(ooRouter, swapData); + exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeTransfer(signerAddress, feeAmount)); + exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeApprove(polyCctp.tokenMessenger, ethers.MaxUint256)); + const usdcBalance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(ROUTER_POLYGON)); + exec.call(polyCctp.tokenMessenger, depositForBurnData).spliceArg(0, usdcBalance.returnWord()); + + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, inputAmount, ROUTER_POLYGON, callData); + + logTxnSummary( + 'Polygon AAVE → Base USDC (CCTP) — performModularExecution postFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log(`\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/cctp/performModularExecution.preFee.ts b/scripts/e2e/cctp/performModularExecution.preFee.ts new file mode 100644 index 0000000..b432ff9 --- /dev/null +++ b/scripts/e2e/cctp/performModularExecution.preFee.ts @@ -0,0 +1,114 @@ +/** + * Route: Polygon USDC → Base USDC (CCTP depositForBurn, no swap) + * Function: performModularExecution (modular) + * Fee: preFee — FEE_BPS of inputAmount USDC transferred to signer before bridge + * + * Modular action sequence: + * [0] AH.transferFrom(USDC, signer, router, inputAmount) + * [1] USDC.transfer(signer, feeAmount) — pre-bridge fee + * [2] USDC.approve(tokenMessenger, MaxUint256) + * [3] STATICCALL USDC.balanceOf(router) + * [4] tokenMessenger.depositForBurn(...) — spliceArg(0) patches amount from [3] + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/performModularExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, + ALLOWANCE_HOLDER, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number, +): string { + const iface = new ethers.Interface([ + 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external', + ]); + return iface.encodeFunctionData('depositForBurn', [ + 0n, + destinationCctpDomain, + ethers.zeroPadValue(recipientAddress, 32), + burnToken, + ethers.ZeroHash, + 1_000_000n, + 1000, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_POLYGON_CIRCLE, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero Circle native USDC on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(inputAmount - feeAmount, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + const depositForBurnData = buildDepositForBurnCalldata(signerAddress, polyCctp.usdcAddress, baseCctp.cctpDomain); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDC_POLYGON_CIRCLE, signerAddress, ROUTER_POLYGON, inputAmount])); + exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeTransfer(signerAddress, feeAmount)); + exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeApprove(polyCctp.tokenMessenger, ethers.MaxUint256)); + const usdcBalance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(ROUTER_POLYGON)); + exec.call(polyCctp.tokenMessenger, depositForBurnData).spliceArg(0, usdcBalance.returnWord()); + + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData); + + logTxnSummary( + 'Polygon USDC → Base USDC (CCTP) — performModularExecution preFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log(`\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts new file mode 100644 index 0000000..f622b53 --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts @@ -0,0 +1,219 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) + * Flags: post-fee (fee taken from USDC output after swap), output measured as USDC balanceOf delta + * bridge amount spliced into depositForBurn calldata at byte offset 4 (amount param) + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDC, deducted from swap output. + * BalanceOf (bit1=1): final USDC amount is measured as router USDC balance change (not returndata). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | balance-of (0x02) | bridge amount at byte offset 4 (depositForBurn amount param) +const FLAGS = 0x03n | bridgeAmountPositionFlag(4); +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number +): string { + const iface = new ethers.Interface([ + "function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external", + ]); + return iface.encodeFunctionData("depositForBurn", [ + 0n, + destinationCctpDomain, + ethers.zeroPadValue(recipientAddress, 32), + burnToken, + ethers.ZeroHash, + 1_000_000n, + 1000, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (post-fee, balanceOf, bridge-amount-pos=4)` + ); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + console.log("Fetching OpenOcean quote (AAVE → USDC)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated USDC output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)` + ); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + const depositForBurnData = buildDepositForBurnCalldata( + signerAddress, + polyCctp.usdcAddress, + baseCctp.cctpDomain + ); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: polyCctp.tokenMessenger, + value: 0n, + }, + depositForBurnData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData + ); + + logTxnSummary( + `Polygon AAVE → Base USDC (CCTP) — swapAndBridge postFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log( + `\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.` + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts new file mode 100644 index 0000000..c12c1ba --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts @@ -0,0 +1,219 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) + * Flags: post-fee (fee taken from USDC output after swap), output read from swap returndata word 0 + * bridge amount spliced into depositForBurn calldata at byte offset 4 (amount param) + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDC, deducted from swap output. + * Returndata (bit1=0): final USDC amount is read from word 0 of the swap call returndata. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | bridge amount at byte offset 4 (depositForBurn amount param) +const FLAGS = 0x01n | bridgeAmountPositionFlag(4); +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number +): string { + const iface = new ethers.Interface([ + "function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external", + ]); + return iface.encodeFunctionData("depositForBurn", [ + 0n, + destinationCctpDomain, + ethers.zeroPadValue(recipientAddress, 32), + burnToken, + ethers.ZeroHash, + 1_000_000n, + 1000, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (post-fee, returndata, bridge-amount-pos=4)` + ); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + console.log("Fetching OpenOcean quote (AAVE → USDC)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated USDC output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)` + ); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + const depositForBurnData = buildDepositForBurnCalldata( + signerAddress, + polyCctp.usdcAddress, + baseCctp.cctpDomain + ); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: polyCctp.tokenMessenger, + value: 0n, + }, + depositForBurnData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData + ); + + logTxnSummary( + `Polygon AAVE → Base USDC (CCTP) — swapAndBridge postFee/returndata`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log( + `\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.` + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts new file mode 100644 index 0000000..78ba078 --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts @@ -0,0 +1,221 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) + * Flags: pre-fee (fee taken from AAVE input before swap), output measured as USDC balanceOf delta + * bridge amount spliced into depositForBurn calldata at byte offset 4 (amount param) + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount, deducted from AAVE before the swap. + * BalanceOf (bit1=1): final USDC amount is measured as router USDC balance change (not returndata). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | balance-of (0x02) | bridge amount at byte offset 4 (depositForBurn amount param) +const FLAGS = 0x02n | bridgeAmountPositionFlag(4); +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number +): string { + const iface = new ethers.Interface([ + "function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external", + ]); + return iface.encodeFunctionData("depositForBurn", [ + 0n, + destinationCctpDomain, + ethers.zeroPadValue(recipientAddress, 32), + burnToken, + ethers.ZeroHash, + 1_000_000n, + 1000, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (pre-fee, balanceOf, bridge-amount-pos=4)` + ); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + console.log("Fetching OpenOcean quote (AAVE → USDC)..."); + const { ooRouter, swapData, minAmountOut } = await fetchOpenOceanQuote( + inputAmount + ); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const swapInputAmount = inputAmount - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log( + ` Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)` + ); + console.log(` Swap input: ${ethers.formatUnits(swapInputAmount, 18)} AAVE`); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + const depositForBurnData = buildDepositForBurnCalldata( + signerAddress, + polyCctp.usdcAddress, + baseCctp.cctpDomain + ); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: polyCctp.tokenMessenger, + value: 0n, + }, + depositForBurnData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData + ); + + logTxnSummary( + `Polygon AAVE → Base USDC (CCTP) — swapAndBridge preFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log( + `\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.` + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts b/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts new file mode 100644 index 0000000..c72b884 --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts @@ -0,0 +1,221 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) + * Flags: pre-fee (fee taken from AAVE input before swap), output read from swap returndata word 0 + * bridge amount spliced into depositForBurn calldata at byte offset 4 (amount param) + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount, deducted from AAVE before the swap. + * Returndata (bit1=0): final USDC amount is read from word 0 of the swap call returndata. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | bridge amount at byte offset 4 (depositForBurn amount param) +const FLAGS = bridgeAmountPositionFlag(4); +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number +): string { + const iface = new ethers.Interface([ + "function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external", + ]); + return iface.encodeFunctionData("depositForBurn", [ + 0n, + destinationCctpDomain, + ethers.zeroPadValue(recipientAddress, 32), + burnToken, + ethers.ZeroHash, + 1_000_000n, + 1000, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (pre-fee, returndata, bridge-amount-pos=4)` + ); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + console.log("Fetching OpenOcean quote (AAVE → USDC)..."); + const { ooRouter, swapData, minAmountOut } = await fetchOpenOceanQuote( + inputAmount + ); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const swapInputAmount = inputAmount - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log( + ` Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)` + ); + console.log(` Swap input: ${ethers.formatUnits(swapInputAmount, 18)} AAVE`); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + const depositForBurnData = buildDepositForBurnCalldata( + signerAddress, + polyCctp.usdcAddress, + baseCctp.cctpDomain + ); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: polyCctp.tokenMessenger, + value: 0n, + }, + depositForBurnData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData + ); + + logTxnSummary( + `Polygon AAVE → Base USDC (CCTP) — swapAndBridge preFee/returndata`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log( + `\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.` + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/config.ts b/scripts/e2e/config.ts index c14576d..6e6518c 100644 --- a/scripts/e2e/config.ts +++ b/scripts/e2e/config.ts @@ -34,7 +34,7 @@ export const ALLOWANCE_HOLDER = '0x0000000000001fF3684f28c67538d4D072C22734'; * Chains without an entry fall back to legacy `ROUTER_ADDRESS` when set. */ export const ROUTER_BY_CHAIN_ID: Record = { - [CHAIN_IDS.POLYGON]: '0x5bfbF2d49658e48D209449B3E263DC6F774B6E6f', + [CHAIN_IDS.POLYGON]: '0x7A113007177BF1cd86da69Dbd7d601dcEC9EbAbD', [CHAIN_IDS.ARBITRUM]: '0xe1788A0374EF5D4C35e62478FdB35F37CeE5B951', [CHAIN_IDS.BASE]: '0x96E8c261fCCDFca2CCffe8b4A33dC8a65f153785', [CHAIN_IDS.ETHEREUM]: '0xeB5ae85Fe7e3E272Ac77fd316079589C0Ed91648', @@ -206,6 +206,17 @@ export const STARGATE_AMOUNT_LD_OFFSET = 196; /** Fee applied in scripts that take pre-/post-route fees (basis points). */ export const FEE_BPS = Number(process.env.FEE_AMOUNT_BPS ?? '10'); +/** + * OpenOcean slippage tolerance used when fetching swap quotes. + * The value is passed directly to OO's `slippage` API parameter (percentage string, e.g. '3' = 3%). + * OO embeds this as `minReturn` in the swap calldata — if the actual on-chain output falls below + * `estimatedOut * (1 - slippage/100)`, OO reverts with "Return amount is not enough". + * AAVE's multi-hop route (AAVE→WMATIC→DAI→USDC) can move 2–3% between quote and execution, + * so 1% is too tight; 3% provides a safe margin while still protecting against severe slippage. + * Override via env: OO_SLIPPAGE_PERCENT=5 + */ +export const OO_SLIPPAGE_PERCENT = process.env.OO_SLIPPAGE_PERCENT ?? '3'; + export function bpsOf(amount: bigint, bps: number): bigint { return (amount * BigInt(bps)) / 10000n; } diff --git a/scripts/e2e/oft/performExecution.postFee.ts b/scripts/e2e/oft/performExecution.postFee.ts new file mode 100644 index 0000000..eb4a2d1 --- /dev/null +++ b/scripts/e2e/oft/performExecution.postFee.ts @@ -0,0 +1,183 @@ +/** + * Route: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2) + * Function: performExecution (monolithic) + * Fee: postFee — FEE_BPS of estimatedOut USDT0 deducted after swap + * + * Bridge amount position flag splices actual post-fee balance into send() amountLD at byte 196. + * bridge.value = nativeFeeWithBuffer (5% buffer on LZ fee) forwarded as LZ msg.value. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +import { Options } from '@layerzerolabs/lz-v2-utilities'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ARBITRUM_LZ_EID, + USDT0_OFT_ADAPTER_POLYGON, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + NO_FEE, + ZERO_BYTES32, + bridgeAmountPositionFlag, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); +const OFT_AMOUNT_LD_OFFSET = 196; + +const OFT_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDT0_POLYGON, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(USDT0_OFT_ADAPTER_POLYGON, OFT_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData('send', [ + { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.AAVE_POLYGON, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OpenOcean quote (AAVE → USDT0)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + const bridgeAmount = estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDT0: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log(` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)`); + console.log(` Min USDT0: ${ethers.formatUnits(minAmountOut, 6)}`); + + console.log('Fetching OFT quote (Polygon → Arbitrum)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote(provider, bridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { receiver: signerAddress, amount: feeAmount }, + bridge: { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: USDT0_OFT_ADAPTER_POLYGON, value: nativeFeeWithBuffer }, + flags: bridgeAmountPositionFlag(OFT_AMOUNT_LD_OFFSET), + }, + swapCallData: swapData, + bridgeCallData: oftSendData, + }; + + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); + + logTxnSummary( + 'Polygon AAVE → Arbitrum USDT0 (OFT) — performExecution postFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log('\nUSDT0 arrives on Arbitrum once LZ delivers the message.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/oft/performExecution.preFee.ts b/scripts/e2e/oft/performExecution.preFee.ts new file mode 100644 index 0000000..a26827f --- /dev/null +++ b/scripts/e2e/oft/performExecution.preFee.ts @@ -0,0 +1,137 @@ +/** + * Route: Polygon USDT0 → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2, no swap) + * Function: performExecution (monolithic) + * Fee: preFee — FEE_BPS of inputAmount USDT0 deducted before bridge + * + * Bridge amount position flag splices actual post-fee balance into send() amountLD at byte 196. + * bridge.value = nativeFeeWithBuffer forwarded as LZ msg.value. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +import { Options } from '@layerzerolabs/lz-v2-utilities'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ARBITRUM_LZ_EID, + USDT0_OFT_ADAPTER_POLYGON, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + NO_FEE, + NO_SWAP, + ZERO_BYTES32, + bridgeAmountPositionFlag, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); +const OFT_AMOUNT_LD_OFFSET = 196; + +const OFT_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(USDT0_OFT_ADAPTER_POLYGON, OFT_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData('send', [ + { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDT0_POLYGON, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDT0 on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDT0 balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OFT quote (Polygon → Arbitrum)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote(provider, bridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); + + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: TOKENS.USDT0_POLYGON, inputAmount }, + preFee: { receiver: signerAddress, amount: feeAmount }, + swap: NO_SWAP, + postFee: NO_FEE, + bridge: { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: USDT0_OFT_ADAPTER_POLYGON, value: nativeFeeWithBuffer }, + flags: bridgeAmountPositionFlag(OFT_AMOUNT_LD_OFFSET), + }, + swapCallData: '0x', + bridgeCallData: oftSendData, + }; + + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDT0_POLYGON, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); + + logTxnSummary( + 'Polygon USDT0 → Arbitrum USDT0 (OFT direct) — performExecution preFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log('\nUSDT0 arrives on Arbitrum once LZ delivers the message.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/oft/performModularExecution.postFee.ts b/scripts/e2e/oft/performModularExecution.postFee.ts new file mode 100644 index 0000000..e59d095 --- /dev/null +++ b/scripts/e2e/oft/performModularExecution.postFee.ts @@ -0,0 +1,178 @@ +/** + * Route: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2) + * Function: performModularExecution (modular) + * Fee: postFee — FEE_BPS of estimatedOut USDT0 transferred to signer after swap + * + * Modular action sequence: + * [0] AH.transferFrom(AAVE, signer, router, inputAmount) + * [1] AAVE.approve(ooRouter, inputAmount) + * [2] ooRouter swap — AAVE → USDT0 lands in router + * [3] USDT0.transfer(signer, feeAmount) + * [4] USDT0.approve(adapter, MaxUint256) + * [5] STATICCALL USDT0.balanceOf(router) + * [6] nativeCall adapter.send(...) — spliceWord patches amountLD at offset 196 from [5] + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performModularExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +import { Options } from '@layerzerolabs/lz-v2-utilities'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ALLOWANCE_HOLDER, + ARBITRUM_LZ_EID, + USDT0_OFT_ADAPTER_POLYGON, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); +const OFT_AMOUNT_LD_OFFSET = 196; + +const OFT_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDT0_POLYGON, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(USDT0_OFT_ADAPTER_POLYGON, OFT_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData('send', [ + { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.AAVE_POLYGON, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OpenOcean quote (AAVE → USDT0)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + const bridgeAmount = estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDT0: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log(` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)`); + console.log(` Min USDT0: ${ethers.formatUnits(minAmountOut, 6)}`); + + console.log('Fetching OFT quote (Polygon → Arbitrum)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote(provider, bridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.AAVE_POLYGON, signerAddress, ROUTER_POLYGON, inputAmount])); + exec.call(TOKENS.AAVE_POLYGON, encodeApprove(ooRouter, inputAmount)); + exec.call(ooRouter, swapData); + exec.call(TOKENS.USDT0_POLYGON, encodeTransfer(signerAddress, feeAmount)); + exec.call(TOKENS.USDT0_POLYGON, encodeApprove(USDT0_OFT_ADAPTER_POLYGON, ethers.MaxUint256)); + const usdt0Balance = exec.staticCall(TOKENS.USDT0_POLYGON, encodeBalanceOf(ROUTER_POLYGON)); + exec.nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) + .spliceWord(BigInt(OFT_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); + + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); + + logTxnSummary( + 'Polygon AAVE → Arbitrum USDT0 (OFT) — performModularExecution postFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log('\nUSDT0 arrives on Arbitrum once LZ delivers the message.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/oft/performModularExecution.preFee.ts b/scripts/e2e/oft/performModularExecution.preFee.ts new file mode 100644 index 0000000..097ef85 --- /dev/null +++ b/scripts/e2e/oft/performModularExecution.preFee.ts @@ -0,0 +1,134 @@ +/** + * Route: Polygon USDT0 → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2, no swap) + * Function: performModularExecution (modular) + * Fee: preFee — FEE_BPS of inputAmount USDT0 transferred to signer before bridge + * + * Modular action sequence: + * [0] AH.transferFrom(USDT0, signer, router, inputAmount) + * [1] USDT0.transfer(signer, feeAmount) — pre-bridge fee + * [2] USDT0.approve(adapter, MaxUint256) + * [3] STATICCALL USDT0.balanceOf(router) + * [4] nativeCall adapter.send(...) — spliceWord patches amountLD at offset 196 from [3] + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performModularExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +import { Options } from '@layerzerolabs/lz-v2-utilities'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ALLOWANCE_HOLDER, + ARBITRUM_LZ_EID, + USDT0_OFT_ADAPTER_POLYGON, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); +const OFT_AMOUNT_LD_OFFSET = 196; + +const OFT_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(USDT0_OFT_ADAPTER_POLYGON, OFT_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData('send', [ + { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDT0_POLYGON, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDT0 on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDT0 balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OFT quote (Polygon → Arbitrum)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote(provider, bridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); + + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDT0_POLYGON, signerAddress, ROUTER_POLYGON, inputAmount])); + exec.call(TOKENS.USDT0_POLYGON, encodeTransfer(signerAddress, feeAmount)); + exec.call(TOKENS.USDT0_POLYGON, encodeApprove(USDT0_OFT_ADAPTER_POLYGON, ethers.MaxUint256)); + const usdt0Balance = exec.staticCall(TOKENS.USDT0_POLYGON, encodeBalanceOf(ROUTER_POLYGON)); + exec.nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) + .spliceWord(BigInt(OFT_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); + + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDT0_POLYGON, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); + + logTxnSummary( + 'Polygon USDT0 → Arbitrum USDT0 (OFT direct) — performModularExecution preFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log('\nUSDT0 arrives on Arbitrum once LZ delivers the message.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts new file mode 100644 index 0000000..b291e9b --- /dev/null +++ b/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts @@ -0,0 +1,262 @@ +/** + * Route: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2) + * Flags: post-fee (fee taken from USDT0 output after swap), output measured as USDT0 balanceOf delta + * bridge amount spliced into send() calldata at byte offset 196 (sendParam.amountLD) + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDT0, deducted from swap output. + * BalanceOf (bit1=1): final USDT0 amount is measured as router USDT0 balance change (not returndata). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +import { Options } from "@layerzerolabs/lz-v2-utilities"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ARBITRUM_LZ_EID, + USDT0_OFT_ADAPTER_POLYGON, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | balance-of (0x02) | bridge amount at byte offset 196 (sendParam.amountLD) +const FLAGS = 0x03n | bridgeAmountPositionFlag(196); +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions() + .addExecutorLzReceiveOption(65000, 0) + .toHex(); + +const OFT_ABI = [ + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", +]; + +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDT0_POLYGON, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract( + USDT0_OFT_ADAPTER_POLYGON, + OFT_ABI, + provider + ); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: ARBITRUM_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + const nativeFee = fee.nativeFee as bigint; + return { + nativeFeeWithBuffer: (nativeFee * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData("send", [ + { + dstEid: ARBITRUM_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD: 0n, // placeholder — spliced at runtime at offset 196 + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (post-fee, balanceOf, bridge-amount-pos=196)` + ); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (AAVE → USDT0)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated USDT0 output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + const bridgeAmount = estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDT0: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)` + ); + console.log(` Bridge est: ${ethers.formatUnits(bridgeAmount, 6)}`); + console.log(` Min USDT0: ${ethers.formatUnits(minAmountOut, 6)}`); + + console.log("Fetching OFT quote (Polygon → Arbitrum)..."); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote( + provider, + bridgeAmount, + signerAddress + ); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log( + ` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0` + ); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON + ); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + value: nativeFeeWithBuffer, + }, + oftSendData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + nativeFeeWithBuffer + ); + + logTxnSummary( + `Polygon AAVE → Arbitrum USDT0 (OFT) — swapAndBridge postFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log("\nUSDT0 arrives on Arbitrum once LZ delivers the message."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts b/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts new file mode 100644 index 0000000..7d76600 --- /dev/null +++ b/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts @@ -0,0 +1,262 @@ +/** + * Route: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2) + * Flags: post-fee (fee taken from USDT0 output after swap), output read from swap returndata word 0 + * bridge amount spliced into send() calldata at byte offset 196 (sendParam.amountLD) + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDT0, deducted from swap output. + * Returndata (bit1=0): final USDT0 amount is read from word 0 of the swap call returndata. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/swapAndBridge.postFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +import { Options } from "@layerzerolabs/lz-v2-utilities"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ARBITRUM_LZ_EID, + USDT0_OFT_ADAPTER_POLYGON, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | bridge amount at byte offset 196 (sendParam.amountLD) +const FLAGS = 0x01n | bridgeAmountPositionFlag(196); +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions() + .addExecutorLzReceiveOption(65000, 0) + .toHex(); + +const OFT_ABI = [ + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", +]; + +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDT0_POLYGON, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract( + USDT0_OFT_ADAPTER_POLYGON, + OFT_ABI, + provider + ); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: ARBITRUM_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + const nativeFee = fee.nativeFee as bigint; + return { + nativeFeeWithBuffer: (nativeFee * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData("send", [ + { + dstEid: ARBITRUM_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD: 0n, // placeholder — spliced at runtime at offset 196 + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (post-fee, returndata, bridge-amount-pos=196)` + ); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (AAVE → USDT0)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated USDT0 output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + const bridgeAmount = estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDT0: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)` + ); + console.log(` Bridge est: ${ethers.formatUnits(bridgeAmount, 6)}`); + console.log(` Min USDT0: ${ethers.formatUnits(minAmountOut, 6)}`); + + console.log("Fetching OFT quote (Polygon → Arbitrum)..."); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote( + provider, + bridgeAmount, + signerAddress + ); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log( + ` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0` + ); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON + ); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + value: nativeFeeWithBuffer, + }, + oftSendData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + nativeFeeWithBuffer + ); + + logTxnSummary( + `Polygon AAVE → Arbitrum USDT0 (OFT) — swapAndBridge postFee/returndata`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log("\nUSDT0 arrives on Arbitrum once LZ delivers the message."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts new file mode 100644 index 0000000..f1e91ab --- /dev/null +++ b/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts @@ -0,0 +1,261 @@ +/** + * Route: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2) + * Flags: pre-fee (fee taken from AAVE input before swap), output measured as USDT0 balanceOf delta + * bridge amount spliced into send() calldata at byte offset 196 (sendParam.amountLD) + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount, deducted from AAVE before the swap. + * BalanceOf (bit1=1): final USDT0 amount is measured as router USDT0 balance change (not returndata). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +import { Options } from "@layerzerolabs/lz-v2-utilities"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ARBITRUM_LZ_EID, + USDT0_OFT_ADAPTER_POLYGON, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | balance-of (0x02) | bridge amount at byte offset 196 (sendParam.amountLD) +const FLAGS = 0x02n | bridgeAmountPositionFlag(196); +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions() + .addExecutorLzReceiveOption(65000, 0) + .toHex(); + +const OFT_ABI = [ + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", +]; + +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDT0_POLYGON, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract( + USDT0_OFT_ADAPTER_POLYGON, + OFT_ABI, + provider + ); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: ARBITRUM_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + const nativeFee = fee.nativeFee as bigint; + return { + nativeFeeWithBuffer: (nativeFee * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData("send", [ + { + dstEid: ARBITRUM_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD: 0n, // placeholder — spliced at runtime at offset 196 + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (pre-fee, balanceOf, bridge-amount-pos=196)` + ); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (AAVE → USDT0)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = estimatedOut; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDT0: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)` + ); + console.log(` Min USDT0: ${ethers.formatUnits(minAmountOut, 6)}`); + + console.log("Fetching OFT quote (Polygon → Arbitrum)..."); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote( + provider, + bridgeAmount, + signerAddress + ); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log( + ` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0` + ); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON + ); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + value: nativeFeeWithBuffer, + }, + oftSendData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + nativeFeeWithBuffer + ); + + logTxnSummary( + `Polygon AAVE → Arbitrum USDT0 (OFT) — swapAndBridge preFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log("\nUSDT0 arrives on Arbitrum once LZ delivers the message."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts b/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts new file mode 100644 index 0000000..5e6353b --- /dev/null +++ b/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts @@ -0,0 +1,261 @@ +/** + * Route: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2) + * Flags: pre-fee (fee taken from AAVE input before swap), output read from swap returndata word 0 + * bridge amount spliced into send() calldata at byte offset 196 (sendParam.amountLD) + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount, deducted from AAVE before the swap. + * Returndata (bit1=0): final USDT0 amount is read from word 0 of the swap call returndata. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/swapAndBridge.preFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +import { Options } from "@layerzerolabs/lz-v2-utilities"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ARBITRUM_LZ_EID, + USDT0_OFT_ADAPTER_POLYGON, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | bridge amount at byte offset 196 (sendParam.amountLD) +const FLAGS = bridgeAmountPositionFlag(196); +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions() + .addExecutorLzReceiveOption(65000, 0) + .toHex(); + +const OFT_ABI = [ + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", +]; + +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDT0_POLYGON, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract( + USDT0_OFT_ADAPTER_POLYGON, + OFT_ABI, + provider + ); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: ARBITRUM_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + const nativeFee = fee.nativeFee as bigint; + return { + nativeFeeWithBuffer: (nativeFee * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData("send", [ + { + dstEid: ARBITRUM_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD: 0n, // placeholder — spliced at runtime at offset 196 + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (pre-fee, returndata, bridge-amount-pos=196)` + ); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (AAVE → USDT0)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = estimatedOut; // estimated bridge amount (no post-fee subtraction here) + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDT0: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)` + ); + console.log(` Min USDT0: ${ethers.formatUnits(minAmountOut, 6)}`); + + console.log("Fetching OFT quote (Polygon → Arbitrum)..."); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote( + provider, + bridgeAmount, + signerAddress + ); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log( + ` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0` + ); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON + ); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + value: nativeFeeWithBuffer, + }, + oftSendData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + nativeFeeWithBuffer + ); + + logTxnSummary( + `Polygon AAVE → Arbitrum USDT0 (OFT) — swapAndBridge preFee/returndata`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log("\nUSDT0 arrives on Arbitrum once LZ delivers the message."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts new file mode 100644 index 0000000..df2b82e --- /dev/null +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts @@ -0,0 +1,191 @@ +/** + * Route: Arbitrum USDC → ETH (OpenOcean) → Base ETH (Stargate Native ETH Pool) + * Function: performExecution (monolithic) + * Fee: postFee — FEE_BPS of estimatedOut ETH deducted after swap + * + * BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to Stargate. + * amountLD = minAmountOut - fee - nativeFeeWithBuffer (pre-encoded in calldata). + * StargatePoolNative check: msg.value >= amountLD + nativeFee; satisfied since actual >= min. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + NATIVE_TOKEN_ADDRESS, + STARGATE_NATIVE_ARB, + BASE_LZ_EID, + STARGATE_AMOUNT_LD_OFFSET, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + BRIDGE_VALUE_FLAG, + NO_FEE, + ZERO_ADDRESS, + ZERO_BYTES32, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); + +const STARGATE_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.USDC_ARB, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 6), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_ARB, + account: ROUTER_ARB, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFee: bigint; nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(STARGATE_NATIVE_ARB, STARGATE_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: BASE_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + const nativeFee = fee.nativeFee as bigint; + return { + nativeFee, + nativeFeeWithBuffer: (nativeFee * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata(nativeFee: bigint, amountLD: bigint, recipient: string): string { + return STARGATE_IFACE.encodeFunctionData('send', [ + { dstEid: BASE_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_ARB, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ARB}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OpenOcean quote (USDC → ETH on Arbitrum)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + const estimatedBridgeAmount = estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log(` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + console.log('Fetching Stargate quote (Arbitrum → Base native pool)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, estimatedBridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); + console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); + + // amountLD pre-encoded: minAmountOut - fee - nativeFeeWithBuffer + const amountLD = minAmountOut - feeAmount - nativeFeeWithBuffer; + if (amountLD <= 0n) throw new Error('minAmountOut too small to cover fee + nativeFee'); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); + await ensureRouterNativeBalance(signer, ROUTER_ARB); + await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + + const stargateData = buildStargateCalldata(nativeFeeWithBuffer, amountLD, signerAddress); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: TOKENS.USDC_ARB, inputAmount }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { receiver: signerAddress, amount: feeAmount }, + bridge: { target: STARGATE_NATIVE_ARB, approvalSpender: ZERO_ADDRESS, value: 0n }, + flags: BRIDGE_VALUE_FLAG, + }, + swapCallData: swapData, + bridgeCallData: stargateData, + }; + + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + const receipt = await execViaAH(signer, ROUTER_ARB, TOKENS.USDC_ARB, inputAmount, ROUTER_ARB, callData); + + logTxnSummary( + 'Arbitrum USDC → ETH (OO) → Base ETH (Stargate native) — performExecution postFee', + CHAIN_IDS.ARBITRUM, + receipt, + ); + + console.log('\nETH arrives on Base once LZ delivers the message.'); + + void STARGATE_AMOUNT_LD_OFFSET; +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts b/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts new file mode 100644 index 0000000..3f301dd --- /dev/null +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts @@ -0,0 +1,178 @@ +/** + * Route: Arbitrum USDC → ETH (OpenOcean) → Base ETH (Stargate Native ETH Pool) + * Function: performModularExecution (modular) + * Fee: postFee — FEE_BPS of estimatedOut ETH sent to signer after swap + * + * Modular action sequence: + * [0] AH.transferFrom(USDC, signer, router, inputAmount) + * [1] USDC.approve(ooRouter, inputAmount) + * [2] call(ooRouter, swapData) — USDC → ETH lands in router + * [3] nativeCall(signer, '0x', feeAmount) — ETH fee out + * [4] nativeCall(Stargate, sendData, bridgeValue) — value = amountLD + nativeFeeWithBuffer + * + * amountLD = minAmountOut - feeAmount - nativeFeeWithBuffer (pre-encoded, surplus stays in router). + * bridgeValue = minAmountOut - feeAmount (amountLD + nativeFeeWithBuffer). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ALLOWANCE_HOLDER, + NATIVE_TOKEN_ADDRESS, + STARGATE_NATIVE_ARB, + BASE_LZ_EID, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); + +const STARGATE_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.USDC_ARB, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 6), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_ARB, + account: ROUTER_ARB, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(STARGATE_NATIVE_ARB, STARGATE_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: BASE_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata(nativeFee: bigint, amountLD: bigint, recipient: string): string { + return STARGATE_IFACE.encodeFunctionData('send', [ + { dstEid: BASE_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_ARB, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ARB}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OpenOcean quote (USDC → ETH on Arbitrum)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + const estimatedBridgeAmount = estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log(` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + console.log('Fetching Stargate quote (Arbitrum → Base native pool)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, estimatedBridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); + console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); + + const amountLD = minAmountOut - feeAmount - nativeFeeWithBuffer; + if (amountLD <= 0n) throw new Error('minAmountOut too small to cover fee + nativeFee'); + // bridgeValue = amountLD + nativeFeeWithBuffer = minAmountOut - feeAmount + const bridgeValue = minAmountOut - feeAmount; + + await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); + await ensureRouterNativeBalance(signer, ROUTER_ARB); + await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + + const stargateData = buildStargateCalldata(nativeFeeWithBuffer, amountLD, signerAddress); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDC_ARB, signerAddress, ROUTER_ARB, inputAmount])); + exec.call(TOKENS.USDC_ARB, encodeApprove(ooRouter, inputAmount)); + exec.call(ooRouter, swapData); + exec.nativeCall(signerAddress, '0x', feeAmount); + exec.nativeCall(STARGATE_NATIVE_ARB, stargateData, bridgeValue); + + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + const receipt = await execViaAH(signer, ROUTER_ARB, TOKENS.USDC_ARB, inputAmount, ROUTER_ARB, callData); + + logTxnSummary( + 'Arbitrum USDC → ETH (OO) → Base ETH (Stargate native) — performModularExecution postFee', + CHAIN_IDS.ARBITRUM, + receipt, + ); + + console.log('\nETH arrives on Base once LZ delivers the message.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts new file mode 100644 index 0000000..ac1647b --- /dev/null +++ b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts @@ -0,0 +1,184 @@ +/** + * Route: Base USDC → ETH (OpenOcean) → Arbitrum ETH (Stargate Native ETH Pool) + * Function: performExecution (monolithic) + * Fee: postFee — FEE_BPS of estimatedOut ETH deducted after swap + * + * BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to Stargate. + * amountLD = minAmountOut - fee - nativeFeeWithBuffer (pre-encoded in calldata). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + NATIVE_TOKEN_ADDRESS, + STARGATE_NATIVE_BASE, + ARBITRUM_LZ_EID, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + BRIDGE_VALUE_FLAG, + NO_FEE, + ZERO_ADDRESS, + ZERO_BYTES32, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_BASE = routerAddressForChain(CHAIN_IDS.BASE); + +const STARGATE_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.USDC_BASE, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 6), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_BASE, + account: ROUTER_BASE, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.BASE}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(STARGATE_NATIVE_BASE, STARGATE_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata(nativeFee: bigint, amountLD: bigint, recipient: string): string { + return STARGATE_IFACE.encodeFunctionData('send', [ + { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.BASE); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_BASE, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDC on Base`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_BASE}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OpenOcean quote (USDC → ETH on Base)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + const estimatedBridgeAmount = estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log(` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + console.log('Fetching Stargate quote (Base → Arbitrum native pool)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, estimatedBridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); + console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); + + const amountLD = minAmountOut - feeAmount - nativeFeeWithBuffer; + if (amountLD <= 0n) throw new Error('minAmountOut too small to cover fee + nativeFee'); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); + await ensureRouterNativeBalance(signer, ROUTER_BASE); + await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); + + const stargateData = buildStargateCalldata(nativeFeeWithBuffer, amountLD, signerAddress); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: TOKENS.USDC_BASE, inputAmount }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { receiver: signerAddress, amount: feeAmount }, + bridge: { target: STARGATE_NATIVE_BASE, approvalSpender: ZERO_ADDRESS, value: 0n }, + flags: BRIDGE_VALUE_FLAG, + }, + swapCallData: swapData, + bridgeCallData: stargateData, + }; + + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); + const receipt = await execViaAH(signer, ROUTER_BASE, TOKENS.USDC_BASE, inputAmount, ROUTER_BASE, callData); + + logTxnSummary( + 'Base USDC → ETH (OO) → Arbitrum ETH (Stargate native) — performExecution postFee', + CHAIN_IDS.BASE, + receipt, + ); + + console.log('\nETH arrives on Arbitrum once LZ delivers the message.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts b/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts new file mode 100644 index 0000000..7eb6f04 --- /dev/null +++ b/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts @@ -0,0 +1,174 @@ +/** + * Route: Base USDC → ETH (OpenOcean) → Arbitrum ETH (Stargate Native ETH Pool) + * Function: performModularExecution (modular) + * Fee: postFee — FEE_BPS of estimatedOut ETH sent to signer after swap + * + * Modular action sequence: + * [0] AH.transferFrom(USDC, signer, router, inputAmount) + * [1] USDC.approve(ooRouter, inputAmount) + * [2] call(ooRouter, swapData) — USDC → ETH lands in router + * [3] nativeCall(signer, '0x', feeAmount) + * [4] nativeCall(Stargate, sendData, bridgeValue) — value = amountLD + nativeFeeWithBuffer + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + ALLOWANCE_HOLDER, + NATIVE_TOKEN_ADDRESS, + STARGATE_NATIVE_BASE, + ARBITRUM_LZ_EID, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_BASE = routerAddressForChain(CHAIN_IDS.BASE); + +const STARGATE_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.USDC_BASE, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 6), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_BASE, + account: ROUTER_BASE, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.BASE}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(STARGATE_NATIVE_BASE, STARGATE_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata(nativeFee: bigint, amountLD: bigint, recipient: string): string { + return STARGATE_IFACE.encodeFunctionData('send', [ + { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.BASE); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_BASE, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDC on Base`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_BASE}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching OpenOcean quote (USDC → ETH on Base)...'); + const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + const estimatedBridgeAmount = estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log(` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + console.log('Fetching Stargate quote (Base → Arbitrum native pool)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, estimatedBridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); + console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); + + const amountLD = minAmountOut - feeAmount - nativeFeeWithBuffer; + if (amountLD <= 0n) throw new Error('minAmountOut too small to cover fee + nativeFee'); + const bridgeValue = minAmountOut - feeAmount; + + await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); + await ensureRouterNativeBalance(signer, ROUTER_BASE); + await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); + + const stargateData = buildStargateCalldata(nativeFeeWithBuffer, amountLD, signerAddress); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDC_BASE, signerAddress, ROUTER_BASE, inputAmount])); + exec.call(TOKENS.USDC_BASE, encodeApprove(ooRouter, inputAmount)); + exec.call(ooRouter, swapData); + exec.nativeCall(signerAddress, '0x', feeAmount); + exec.nativeCall(STARGATE_NATIVE_BASE, stargateData, bridgeValue); + + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); + const receipt = await execViaAH(signer, ROUTER_BASE, TOKENS.USDC_BASE, inputAmount, ROUTER_BASE, callData); + + logTxnSummary( + 'Base USDC → ETH (OO) → Arbitrum ETH (Stargate native) — performModularExecution postFee', + CHAIN_IDS.BASE, + receipt, + ); + + console.log('\nETH arrives on Arbitrum once LZ delivers the message.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts b/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts new file mode 100644 index 0000000..b56d40e --- /dev/null +++ b/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts @@ -0,0 +1,229 @@ +/** + * Route: Polygon POL (native) → USDT0 (OpenOcean) → Arbitrum USDT0 (LZ OFT Adapter) + * Function: performExecution (monolithic) + * Fee: postFee — FEE_BPS of estimatedOut USDT0 deducted after swap + * + * Input is native POL; msg.value = ooSwapNativeWei + nativeFeeWithBuffer. + * swap.value = POL forwarded to OO router; bridge.value = nativeFeeWithBuffer (LZ fee). + * Bridge amount position flag splices actual post-fee USDT0 balance at byte 196. + * + * For native-input cases this script must be run with sufficient POL balance. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers, parseEther } from 'ethers'; +import * as dotenv from 'dotenv'; +import { Options } from '@layerzerolabs/lz-v2-utilities'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + NATIVE_TOKEN_ADDRESS, + USDT0_OFT_ADAPTER_POLYGON, + ARBITRUM_LZ_EID, + STARGATE_AMOUNT_LD_OFFSET, +} from '../config'; +import { execViaAH } from '../utils/allowanceHolder'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + NO_FEE, + ZERO_ADDRESS, + ZERO_BYTES32, + bridgeAmountPositionFlag, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); +const NATIVE_INPUT_GAS_RESERVE = parseEther('0.01'); +const NATIVE_INPUT_GAS_LIMIT_ESTIMATE = 2_000_000n; + +const OFT_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; value?: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmountWei: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; + nativeSwapWei: bigint; +}> { + const params: Record = { + inTokenAddress: NATIVE_TOKEN_ADDRESS, + outTokenAddress: TOKENS.USDT0_POLYGON, + amount: ethers.formatEther(inputAmountWei), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + nativeSwapWei: q.value !== undefined && q.value !== '' ? BigInt(q.value) : 0n, + }; +} + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(USDT0_OFT_ADAPTER_POLYGON, OFT_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData('send', [ + { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const rawBalance = await provider.getBalance(signerAddress); + if (rawBalance <= NATIVE_INPUT_GAS_RESERVE) { + throw new Error(`Signer ${signerAddress} POL balance (${ethers.formatEther(rawBalance)}) below reserve`); + } + + const feeData = await provider.getFeeData(); + const maxFeePerGas = feeData.maxFeePerGas ?? feeData.gasPrice ?? 500_000_000n; + const gasReserve = maxFeePerGas * NATIVE_INPUT_GAS_LIMIT_ESTIMATE; + console.log(` Gas reserve: ${ethers.formatEther(gasReserve)} POL`); + + // Start with full usable balance; capped below if lz fee eats too much + let inputAmountWei = rawBalance - NATIVE_INPUT_GAS_RESERVE - 20n; + if (inputAmountWei <= 0n) throw new Error('POL balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`POL balance: ${ethers.formatEther(rawBalance)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + let ooRouter = ''; + let swapData = ''; + let nativeSwapWei = 0n; + let feeAmount = 0n; + let estimatedBridgeAmount = 0n; + let minAmountOut = 0n; + let nativeFeeWithBuffer = 0n; + let amountReceivedLD = 0n; + + // Re-quote loop: cap inputAmountWei if balance can't cover lz fee + gas reserve + for (let iter = 0; iter < 6; iter++) { + console.log('Fetching OpenOcean quote (POL → USDT0)...'); + const q = await fetchOpenOceanQuote(inputAmountWei); + ooRouter = q.ooRouter; + swapData = q.swapData; + nativeSwapWei = q.nativeSwapWei; + feeAmount = bpsOf(q.estimatedOut, FEE_BPS); + estimatedBridgeAmount = q.estimatedOut - feeAmount; + minAmountOut = q.minAmountOut; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDT0: ${ethers.formatUnits(q.estimatedOut, 6)}`); + console.log(` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0`); + + console.log('Fetching OFT quote (Polygon → Arbitrum)...'); + ({ nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote(provider, estimatedBridgeAmount, signerAddress)); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); + + const maxAffordable = rawBalance - nativeFeeWithBuffer - gasReserve; + if (maxAffordable <= 0n) { + throw new Error(`POL balance cannot cover lz fee (${ethers.formatEther(nativeFeeWithBuffer)}) + gas reserve`); + } + if (inputAmountWei <= maxAffordable) { + break; + } + console.warn(` Capping swap input from ${ethers.formatEther(inputAmountWei)} to ${ethers.formatEther(maxAffordable)} POL`); + inputAmountWei = maxAffordable; + } + + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterNativeBalance(signer, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + + const rawOoWei = nativeSwapWei > 0n ? nativeSwapWei : inputAmountWei; + const polOrEthToOo = rawOoWei <= inputAmountWei ? rawOoWei : inputAmountWei; + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: NATIVE_TOKEN_ADDRESS, inputAmount: inputAmountWei }, + preFee: NO_FEE, + swap: { + target: ooRouter, + approvalSpender: ZERO_ADDRESS, + outputToken: TOKENS.USDT0_POLYGON, + value: polOrEthToOo, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + postFee: { receiver: signerAddress, amount: feeAmount }, + bridge: { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: USDT0_OFT_ADAPTER_POLYGON, value: nativeFeeWithBuffer }, + flags: bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), + }, + swapCallData: swapData, + bridgeCallData: oftSendData, + }; + + const txValue = inputAmountWei + nativeFeeWithBuffer; + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + // Native input — no ERC-20 allowance needed for AH; pass NATIVE_TOKEN_ADDRESS + const receipt = await execViaAH(signer, ROUTER_POLYGON, NATIVE_TOKEN_ADDRESS, inputAmountWei, ROUTER_POLYGON, callData, txValue); + + logTxnSummary( + 'Polygon POL → USDT0 (OO) → Arbitrum USDT0 (OFT) — performExecution postFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log('\nUSDT0 arrives on Arbitrum once LZ delivers the message.'); + + void amountReceivedLD; +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts b/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts new file mode 100644 index 0000000..2bd1c19 --- /dev/null +++ b/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts @@ -0,0 +1,210 @@ +/** + * Route: Polygon POL (native) → USDT0 (OpenOcean) → Arbitrum USDT0 (LZ OFT Adapter) + * Function: performModularExecution (modular) + * Fee: postFee — FEE_BPS of estimatedOut USDT0 transferred to signer after swap + * + * Input is native POL; msg.value = ooSwapNativeWei + nativeFeeWithBuffer. + * + * Modular action sequence: + * [0] nativeCall(ooRouter, swapData, polOrEthToOo) — POL → USDT0 lands in router + * [1] USDT0.transfer(signer, feeAmount) + * [2] USDT0.approve(adapter, MaxUint256) + * [3] STATICCALL USDT0.balanceOf(router) + * [4] nativeCall(adapter, oftSendData, nativeFeeWithBuffer) — splicePayloadWord(196) from [3] + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts + */ +import axios from 'axios'; +import { ethers, parseEther } from 'ethers'; +import * as dotenv from 'dotenv'; +import { Options } from '@layerzerolabs/lz-v2-utilities'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + NATIVE_TOKEN_ADDRESS, + USDT0_OFT_ADAPTER_POLYGON, + ARBITRUM_LZ_EID, + STARGATE_AMOUNT_LD_OFFSET, +} from '../config'; +import { execViaAH } from '../utils/allowanceHolder'; +import { encodeApprove, encodeTransfer, encodeBalanceOf } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); +const NATIVE_INPUT_GAS_RESERVE = parseEther('0.01'); +const NATIVE_INPUT_GAS_LIMIT_ESTIMATE = 2_000_000n; + +const OFT_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; value?: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmountWei: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; + nativeSwapWei: bigint; +}> { + const params: Record = { + inTokenAddress: NATIVE_TOKEN_ADDRESS, + outTokenAddress: TOKENS.USDT0_POLYGON, + amount: ethers.formatEther(inputAmountWei), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: '1', + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + nativeSwapWei: q.value !== undefined && q.value !== '' ? BigInt(q.value) : 0n, + }; +} + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(USDT0_OFT_ADAPTER_POLYGON, OFT_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData('send', [ + { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const rawBalance = await provider.getBalance(signerAddress); + if (rawBalance <= NATIVE_INPUT_GAS_RESERVE) { + throw new Error(`Signer ${signerAddress} POL balance (${ethers.formatEther(rawBalance)}) below reserve`); + } + + const feeData = await provider.getFeeData(); + const maxFeePerGas = feeData.maxFeePerGas ?? feeData.gasPrice ?? 500_000_000n; + const gasReserve = maxFeePerGas * NATIVE_INPUT_GAS_LIMIT_ESTIMATE; + console.log(` Gas reserve: ${ethers.formatEther(gasReserve)} POL`); + + let inputAmountWei = rawBalance - NATIVE_INPUT_GAS_RESERVE - 20n; + if (inputAmountWei <= 0n) throw new Error('POL balance too small'); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`POL balance: ${ethers.formatEther(rawBalance)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + let ooRouter = ''; + let swapData = ''; + let nativeSwapWei = 0n; + let feeAmount = 0n; + let estimatedBridgeAmount = 0n; + let nativeFeeWithBuffer = 0n; + let amountReceivedLD = 0n; + + for (let iter = 0; iter < 6; iter++) { + console.log('Fetching OpenOcean quote (POL → USDT0)...'); + const q = await fetchOpenOceanQuote(inputAmountWei); + ooRouter = q.ooRouter; + swapData = q.swapData; + nativeSwapWei = q.nativeSwapWei; + feeAmount = bpsOf(q.estimatedOut, FEE_BPS); + estimatedBridgeAmount = q.estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDT0: ${ethers.formatUnits(q.estimatedOut, 6)}`); + console.log(` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0`); + + console.log('Fetching OFT quote (Polygon → Arbitrum)...'); + ({ nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote(provider, estimatedBridgeAmount, signerAddress)); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); + + const maxAffordable = rawBalance - nativeFeeWithBuffer - gasReserve; + if (maxAffordable <= 0n) { + throw new Error(`POL balance cannot cover lz fee (${ethers.formatEther(nativeFeeWithBuffer)}) + gas reserve`); + } + if (inputAmountWei <= maxAffordable) { + break; + } + console.warn(` Capping swap input from ${ethers.formatEther(inputAmountWei)} to ${ethers.formatEther(maxAffordable)} POL`); + inputAmountWei = maxAffordable; + } + + await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); + await ensureRouterNativeBalance(signer, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + + const rawOoWei = nativeSwapWei > 0n ? nativeSwapWei : inputAmountWei; + const polOrEthToOo = rawOoWei <= inputAmountWei ? rawOoWei : inputAmountWei; + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const exec = new ModularActionsBuilder(); + exec.nativeCall(ooRouter, swapData, polOrEthToOo); + exec.call(TOKENS.USDT0_POLYGON, encodeTransfer(signerAddress, feeAmount)); + exec.call(TOKENS.USDT0_POLYGON, encodeApprove(USDT0_OFT_ADAPTER_POLYGON, ethers.MaxUint256)); + const usdt0Balance = exec.staticCall(TOKENS.USDT0_POLYGON, encodeBalanceOf(ROUTER_POLYGON)); + exec.nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) + .splicePayloadWord(BigInt(STARGATE_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); + + const txValue = inputAmountWei + nativeFeeWithBuffer; + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + const receipt = await execViaAH(signer, ROUTER_POLYGON, NATIVE_TOKEN_ADDRESS, inputAmountWei, ROUTER_POLYGON, callData, txValue); + + logTxnSummary( + 'Polygon POL → USDT0 (OO) → Arbitrum USDT0 (OFT) — performModularExecution postFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log('\nUSDT0 arrives on Arbitrum once LZ delivers the message.'); + + void amountReceivedLD; +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts new file mode 100644 index 0000000..f1de77b --- /dev/null +++ b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts @@ -0,0 +1,135 @@ +/** + * Route: Polygon USDC → Base USDC (Stargate USDC Pool, no swap) + * Function: performExecution (monolithic) + * Fee: postFee — FEE_BPS of inputAmount USDC deducted; bridge amount position flag splices + * actual post-fee balance into amountLD at byte 196 of Stargate send() calldata. + * bridge.value = nativeFeeWithBuffer forwarded as LZ msg.value. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + STARGATE_USDC_POLYGON, + BASE_LZ_EID, + STARGATE_AMOUNT_LD_OFFSET, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + NO_FEE, + NO_SWAP, + ZERO_BYTES32, + bridgeAmountPositionFlag, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +const STARGATE_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(STARGATE_USDC_POLYGON, STARGATE_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: BASE_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata(nativeFee: bigint, recipient: string): string { + // amountLD = 0 placeholder; router splices actual balance at STARGATE_AMOUNT_LD_OFFSET + return STARGATE_IFACE.encodeFunctionData('send', [ + { dstEid: BASE_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_POLYGON_CIRCLE, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDC on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const estimatedBridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(`Est. bridge: ${ethers.formatUnits(estimatedBridgeAmount, 6)} USDC`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching Stargate quote (Polygon → Base USDC pool)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, estimatedBridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDC`); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, STARGATE_USDC_POLYGON); + + const stargateData = buildStargateCalldata(nativeFeeWithBuffer, signerAddress); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: TOKENS.USDC_POLYGON_CIRCLE, inputAmount }, + preFee: NO_FEE, + swap: NO_SWAP, + postFee: { receiver: signerAddress, amount: feeAmount }, + bridge: { target: STARGATE_USDC_POLYGON, approvalSpender: STARGATE_USDC_POLYGON, value: nativeFeeWithBuffer }, + flags: bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), + }, + swapCallData: '0x', + bridgeCallData: stargateData, + }; + + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData); + + logTxnSummary( + 'Polygon USDC → Base USDC (Stargate USDC pool) — performExecution postFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log('\nUSDC arrives on Base once LZ delivers the message.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts new file mode 100644 index 0000000..87dd89b --- /dev/null +++ b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts @@ -0,0 +1,133 @@ +/** + * Route: Polygon USDC → Base USDC (Stargate USDC Pool, no swap) + * Function: performModularExecution (modular) + * Fee: postFee — FEE_BPS of inputAmount USDC transferred to signer; staticCall balance spliced + * into Stargate amountLD at STARGATE_AMOUNT_LD_OFFSET (byte 196). + * + * Modular action sequence: + * [0] AH.transferFrom(USDC, signer, router, inputAmount) + * [1] USDC.transfer(signer, feeAmount) + * [2] USDC.approve(stargatePool, MaxUint256) + * [3] STATICCALL USDC.balanceOf(router) + * [4] nativeCall(Stargate, sendData, nativeFeeWithBuffer) — splicePayloadWord(196) from [3] + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ALLOWANCE_HOLDER, + STARGATE_USDC_POLYGON, + BASE_LZ_EID, + STARGATE_AMOUNT_LD_OFFSET, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +const STARGATE_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(STARGATE_USDC_POLYGON, STARGATE_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { dstEid: BASE_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }; + const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata(nativeFee: bigint, recipient: string): string { + return STARGATE_IFACE.encodeFunctionData('send', [ + { dstEid: BASE_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_POLYGON_CIRCLE, signerAddress, provider); + if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDC on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const estimatedBridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(`Est. bridge: ${ethers.formatUnits(estimatedBridgeAmount, 6)} USDC`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log('Fetching Stargate quote (Polygon → Base USDC pool)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, estimatedBridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDC`); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, STARGATE_USDC_POLYGON); + + const stargateData = buildStargateCalldata(nativeFeeWithBuffer, signerAddress); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDC_POLYGON_CIRCLE, signerAddress, ROUTER_POLYGON, inputAmount])); + exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeTransfer(signerAddress, feeAmount)); + exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeApprove(STARGATE_USDC_POLYGON, ethers.MaxUint256)); + const usdcBalance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(ROUTER_POLYGON)); + exec.nativeCall(STARGATE_USDC_POLYGON, stargateData, nativeFeeWithBuffer) + .splicePayloadWord(BigInt(STARGATE_AMOUNT_LD_OFFSET), usdcBalance.returnWord()); + + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData); + + logTxnSummary( + 'Polygon USDC → Base USDC (Stargate USDC pool) — performModularExecution postFee', + CHAIN_IDS.POLYGON, + receipt, + ); + + console.log('\nUSDC arrives on Base once LZ delivers the message.'); +} + +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts new file mode 100644 index 0000000..f65ba2f --- /dev/null +++ b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts @@ -0,0 +1,263 @@ +/** + * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) + * Flags: post-fee (fee taken from ETH output after swap), output measured as ETH balanceOf delta + * bridge-value flag: router forwards finalETH as msg.value to Stargate + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut ETH, deducted from swap output. + * BalanceOf (bit1=1): final ETH amount is measured as router ETH balance change (not returndata). + * BridgeValue (bit2=1): router forwards finalETH as msg.value to Stargate send(). + * + * amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer (conservative floor). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + NATIVE_TOKEN_ADDRESS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + STARGATE_NATIVE_ARB, + BASE_LZ_EID, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { + ZERO_BYTES32, + BRIDGE_VALUE_FLAG, + ZERO_ADDRESS, +} from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | balance-of (0x02) | bridge-value (0x04): forward finalETH as msg.value +const FLAGS = 0x03n | BRIDGE_VALUE_FLAG; +const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); + +const STARGATE_ABI = [ + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", +]; + +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.USDC_ARB, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 6), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_ARB, + account: ROUTER_ARB, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string +): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract( + STARGATE_NATIVE_ARB, + STARGATE_ABI, + provider + ); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: BASE_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + return { + nativeFee: fee.nativeFee as bigint, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata( + nativeFee: bigint, + recipient: string, + amountLD: bigint +): string { + return STARGATE_IFACE.encodeFunctionData("send", [ + { + dstEid: BASE_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.USDC_ARB, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ARB}`); + console.log( + `Flags: 0x${FLAGS.toString(16)} (post-fee, balanceOf, bridge-value)` + ); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (USDC → ETH)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated ETH output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + const bridgeEstimate = estimatedOut - feeAmount; + console.log(` OO router: ${ooRouter}`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log( + ` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)` + ); + console.log(` Bridge est: ${ethers.formatEther(bridgeEstimate)}`); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + console.log("Fetching Stargate quote..."); + const { nativeFee, amountReceivedLD } = await fetchStargateQuote( + provider, + bridgeEstimate, + signerAddress + ); + const nativeFeeWithBuffer = (nativeFee * 105n) / 100n; + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); + console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); + + // amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer (conservative floor) + const amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer; + if (amountLD <= 0n) + throw new Error( + "estimatedOut too small to cover fee + nativeFeeWithBuffer" + ); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); + await ensureRouterNativeBalance(signer, ROUTER_ARB); + await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.USDC_ARB, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: STARGATE_NATIVE_ARB, + approvalSpender: ZERO_ADDRESS, + value: 0n, + }, + stargateData, + ]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + const receipt = await execViaAH( + signer, + ROUTER_ARB, + TOKENS.USDC_ARB, + inputAmount, + ROUTER_ARB, + callData, + 0n + ); + + logTxnSummary( + `Arbitrum USDC → Base ETH (Stargate) — swapAndBridge postFee/balanceOf`, + CHAIN_IDS.ARBITRUM, + receipt + ); + + console.log("\nETH arrives on Base once LZ delivers the message."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts new file mode 100644 index 0000000..fc7d448 --- /dev/null +++ b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts @@ -0,0 +1,265 @@ +/** + * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) + * Flags: post-fee (fee taken from ETH output after swap), output read from swap returndata word 0 + * bridge-value flag: router forwards finalETH as msg.value to Stargate + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut ETH, deducted from swap output. + * Returndata (bit1=0): final ETH amount is read from word 0 of the swap call returndata. + * BridgeValue (bit2=1): router forwards finalETH as msg.value to Stargate send(). + * + * amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer (conservative floor). + * StargatePoolNative checks msg.value >= amountLD + nativeFee; satisfied since actual >= minAmountOut. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + NATIVE_TOKEN_ADDRESS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + STARGATE_NATIVE_ARB, + BASE_LZ_EID, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { + ZERO_BYTES32, + BRIDGE_VALUE_FLAG, + ZERO_ADDRESS, +} from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | bridge-value (0x04): forward finalETH as msg.value +const FLAGS = 0x01n | BRIDGE_VALUE_FLAG; +const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); + +const STARGATE_ABI = [ + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", +]; + +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.USDC_ARB, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 6), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_ARB, + account: ROUTER_ARB, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string +): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract( + STARGATE_NATIVE_ARB, + STARGATE_ABI, + provider + ); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: BASE_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + return { + nativeFee: fee.nativeFee as bigint, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata( + nativeFee: bigint, + recipient: string, + amountLD: bigint +): string { + return STARGATE_IFACE.encodeFunctionData("send", [ + { + dstEid: BASE_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.USDC_ARB, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ARB}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (post-fee, returndata, bridge-value)` + ); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (USDC → ETH)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated ETH output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log( + ` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)` + ); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + console.log("Fetching Stargate quote..."); + const bridgeEstimate = estimatedOut - feeAmount; + const { nativeFee, amountReceivedLD } = await fetchStargateQuote( + provider, + bridgeEstimate, + signerAddress + ); + const nativeFeeWithBuffer = (nativeFee * 105n) / 100n; + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); + console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); + + // amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer (conservative floor) + const amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer; + if (amountLD <= 0n) + throw new Error( + "estimatedOut too small to cover fee + nativeFeeWithBuffer" + ); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); + await ensureRouterNativeBalance(signer, ROUTER_ARB); + await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.USDC_ARB, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: STARGATE_NATIVE_ARB, + approvalSpender: ZERO_ADDRESS, + value: 0n, + }, + stargateData, + ]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + const receipt = await execViaAH( + signer, + ROUTER_ARB, + TOKENS.USDC_ARB, + inputAmount, + ROUTER_ARB, + callData, + 0n + ); + + logTxnSummary( + `Arbitrum USDC → Base ETH (Stargate) — swapAndBridge postFee/returndata`, + CHAIN_IDS.ARBITRUM, + receipt + ); + + console.log("\nETH arrives on Base once LZ delivers the message."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts new file mode 100644 index 0000000..069fd53 --- /dev/null +++ b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts @@ -0,0 +1,261 @@ +/** + * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) + * Flags: pre-fee (fee taken from USDC input before swap), output measured as ETH balanceOf delta + * bridge-value flag: router forwards finalETH as msg.value to Stargate + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount USDC, deducted before the swap. + * BalanceOf (bit1=1): final ETH amount is measured as router ETH balance change (not returndata). + * BridgeValue (bit2=1): router forwards finalETH as msg.value to Stargate send(). + * + * amountLD = estimatedOut - nativeFeeWithBuffer (conservative floor; actual >= amountLD). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + NATIVE_TOKEN_ADDRESS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + STARGATE_NATIVE_ARB, + BASE_LZ_EID, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { + ZERO_BYTES32, + BRIDGE_VALUE_FLAG, + ZERO_ADDRESS, +} from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | balance-of (0x02) | bridge-value (0x04): forward finalETH as msg.value +const FLAGS = 0x02n | BRIDGE_VALUE_FLAG; +const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); + +const STARGATE_ABI = [ + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", +]; + +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.USDC_ARB, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 6), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_ARB, + account: ROUTER_ARB, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string +): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract( + STARGATE_NATIVE_ARB, + STARGATE_ABI, + provider + ); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: BASE_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + return { + nativeFee: fee.nativeFee as bigint, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata( + nativeFee: bigint, + recipient: string, + amountLD: bigint +): string { + return STARGATE_IFACE.encodeFunctionData("send", [ + { + dstEid: BASE_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.USDC_ARB, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ARB}`); + console.log( + `Flags: 0x${FLAGS.toString(16)} (pre-fee, balanceOf, bridge-value)` + ); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + // pre-fee: deduct from input USDC before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const swapInput = inputAmount - feeAmount; + + console.log("Fetching OpenOcean quote (USDC → ETH)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(swapInput); + console.log(` OO router: ${ooRouter}`); + console.log( + ` Pre-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)` + ); + console.log(` Swap input: ${ethers.formatUnits(swapInput, 6)} USDC`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + console.log("Fetching Stargate quote..."); + const { nativeFee, amountReceivedLD } = await fetchStargateQuote( + provider, + estimatedOut, + signerAddress + ); + const nativeFeeWithBuffer = (nativeFee * 105n) / 100n; + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); + console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); + + // amountLD = estimatedOut - nativeFeeWithBuffer (conservative floor) + const amountLD = estimatedOut - nativeFeeWithBuffer; + if (amountLD <= 0n) + throw new Error("estimatedOut too small to cover nativeFeeWithBuffer"); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); + await ensureRouterNativeBalance(signer, ROUTER_ARB); + await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.USDC_ARB, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: STARGATE_NATIVE_ARB, + approvalSpender: ZERO_ADDRESS, + value: 0n, + }, + stargateData, + ]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + const receipt = await execViaAH( + signer, + ROUTER_ARB, + TOKENS.USDC_ARB, + inputAmount, + ROUTER_ARB, + callData, + 0n + ); + + logTxnSummary( + `Arbitrum USDC → Base ETH (Stargate) — swapAndBridge preFee/balanceOf`, + CHAIN_IDS.ARBITRUM, + receipt + ); + + console.log("\nETH arrives on Base once LZ delivers the message."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts new file mode 100644 index 0000000..c897ee8 --- /dev/null +++ b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts @@ -0,0 +1,262 @@ +/** + * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) + * Flags: pre-fee (fee taken from USDC input before swap), output read from swap returndata word 0 + * bridge-value flag: router forwards finalETH as msg.value to Stargate + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount USDC, deducted before the swap. + * Returndata (bit1=0): final ETH amount is read from word 0 of the swap call returndata. + * BridgeValue (bit2=1): router forwards finalETH as msg.value to Stargate send(). + * + * amountLD is pre-encoded conservatively as estimatedOut - nativeFeeWithBuffer. + * StargatePoolNative checks msg.value >= amountLD + nativeFee; satisfied since actual >= amountLD + buffer. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + NATIVE_TOKEN_ADDRESS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + STARGATE_NATIVE_ARB, + BASE_LZ_EID, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { + ZERO_BYTES32, + BRIDGE_VALUE_FLAG, + ZERO_ADDRESS, +} from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | bridge-value (0x04): forward finalETH as msg.value +const FLAGS = BRIDGE_VALUE_FLAG; +const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); + +const STARGATE_ABI = [ + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", +]; + +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.USDC_ARB, + outTokenAddress: NATIVE_TOKEN_ADDRESS, + amount: ethers.formatUnits(inputAmount, 6), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_ARB, + account: ROUTER_ARB, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string +): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract( + STARGATE_NATIVE_ARB, + STARGATE_ABI, + provider + ); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: BASE_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + return { + nativeFee: fee.nativeFee as bigint, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +function buildStargateCalldata( + nativeFee: bigint, + recipient: string, + amountLD: bigint +): string { + return STARGATE_IFACE.encodeFunctionData("send", [ + { + dstEid: BASE_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.USDC_ARB, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ARB}`); + console.log( + `Flags: 0x${FLAGS.toString(16)} (pre-fee, returndata, bridge-value)` + ); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + // pre-fee: deduct from input USDC before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const swapInput = inputAmount - feeAmount; + + console.log("Fetching OpenOcean quote (USDC → ETH)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(swapInput); + console.log(` OO router: ${ooRouter}`); + console.log( + ` Pre-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)` + ); + console.log(` Swap input: ${ethers.formatUnits(swapInput, 6)} USDC`); + console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); + console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); + + console.log("Fetching Stargate quote..."); + const { nativeFee, amountReceivedLD } = await fetchStargateQuote( + provider, + estimatedOut, + signerAddress + ); + const nativeFeeWithBuffer = (nativeFee * 105n) / 100n; + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); + console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); + + // amountLD = estimatedOut - nativeFeeWithBuffer (conservative floor for pre-encoded calldata) + const amountLD = estimatedOut - nativeFeeWithBuffer; + if (amountLD <= 0n) + throw new Error("estimatedOut too small to cover nativeFeeWithBuffer"); + + await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); + await ensureRouterNativeBalance(signer, ROUTER_ARB); + await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.USDC_ARB, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: STARGATE_NATIVE_ARB, + approvalSpender: ZERO_ADDRESS, + value: 0n, + }, + stargateData, + ]); + + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + const receipt = await execViaAH( + signer, + ROUTER_ARB, + TOKENS.USDC_ARB, + inputAmount, + ROUTER_ARB, + callData, + 0n + ); + + logTxnSummary( + `Arbitrum USDC → Base ETH (Stargate) — swapAndBridge preFee/returndata`, + CHAIN_IDS.ARBITRUM, + receipt + ); + + console.log("\nETH arrives on Base once LZ delivers the message."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/swap.postFee.balanceOf.ts b/scripts/e2e/swap/swap.postFee.balanceOf.ts new file mode 100644 index 0000000..50e518c --- /dev/null +++ b/scripts/e2e/swap/swap.postFee.balanceOf.ts @@ -0,0 +1,174 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) — standalone swap, no bridge + * Flags: post-fee (fee taken from USDC output after swap), output measured as USDC balanceOf delta + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDC, deducted from swap output. + * BalanceOf (bit1=1): final USDC amount is measured as router USDC balance change (not returndata). + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/swap/swap.postFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | balance-of (0x02) +const FLAGS = 0x03n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (post-fee, balanceOf)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (AAVE → USDC)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated USDC output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)` + ); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData + ); + + logTxnSummary( + `Polygon AAVE → USDC — swap postFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/swap.postFee.returndata.ts b/scripts/e2e/swap/swap.postFee.returndata.ts new file mode 100644 index 0000000..7280e6f --- /dev/null +++ b/scripts/e2e/swap/swap.postFee.returndata.ts @@ -0,0 +1,169 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) — standalone swap, no bridge + * Flags: post-fee (fee taken from USDC output after swap), output read from swap returndata word 0 + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDC, deducted from swap output. + * Returndata (bit1=0): final USDC amount is read from word 0 of the swap call returndata. + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/swap/swap.postFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | returndata (0x00) +const FLAGS = 0x01n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (post-fee, returndata)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (AAVE → USDC)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated USDC output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)` + ); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData + ); + + logTxnSummary( + `Polygon AAVE → USDC — swap postFee/returndata`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/swap.preFee.balanceOf.ts b/scripts/e2e/swap/swap.preFee.balanceOf.ts new file mode 100644 index 0000000..7bac539 --- /dev/null +++ b/scripts/e2e/swap/swap.preFee.balanceOf.ts @@ -0,0 +1,174 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) — standalone swap, no bridge + * Flags: pre-fee (fee taken from AAVE input before swap), output measured as USDC balanceOf delta + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount AAVE, deducted before the swap. + * BalanceOf (bit1=1): final USDC amount is measured as router USDC balance change (not returndata). + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/swap/swap.preFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | balance-of (0x02) +const FLAGS = 0x02n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (pre-fee, balanceOf)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (AAVE → USDC)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log( + ` Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)` + ); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData + ); + + logTxnSummary( + `Polygon AAVE → USDC — swap preFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/swap.preFee.returndata.ts b/scripts/e2e/swap/swap.preFee.returndata.ts new file mode 100644 index 0000000..61c7d76 --- /dev/null +++ b/scripts/e2e/swap/swap.preFee.returndata.ts @@ -0,0 +1,169 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) — standalone swap, no bridge + * Flags: pre-fee (fee taken from AAVE input before swap), output read from swap returndata word 0 + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount AAVE, deducted before the swap. + * Returndata (bit1=0): final USDC amount is read from word 0 of the swap call returndata. + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/swap/swap.preFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | returndata (0x00) +const FLAGS = 0x00n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +interface OoQuoteResponse { + data: { to: string; data: string; outAmount: string; minOutAmount: string }; +} + +async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ + ooRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; +}> { + const params: Record = { + inTokenAddress: TOKENS.AAVE_POLYGON, + outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, + amount: ethers.formatUnits(inputAmount, 18), + slippage: OO_SLIPPAGE_PERCENT, + sender: ROUTER_POLYGON, + account: ROUTER_POLYGON, + gasPrice: "1", + }; + if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; + const response = await axios.get(url, { params }); + const q = response.data.data; + return { + ooRouter: q.to, + swapData: q.data, + estimatedOut: BigInt(q.outAmount), + minAmountOut: BigInt(q.minOutAmount), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (pre-fee, returndata)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching OpenOcean quote (AAVE → USDC)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + console.log(` OO router: ${ooRouter}`); + console.log( + ` Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)` + ); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData + ); + + logTxnSummary( + `Polygon AAVE → USDC — swap preFee/returndata`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swapBridgeViaArbitrumNative.ts b/scripts/e2e/swapBridgeViaArbitrumNative.ts index 71c531b..0b0236c 100644 --- a/scripts/e2e/swapBridgeViaArbitrumNative.ts +++ b/scripts/e2e/swapBridgeViaArbitrumNative.ts @@ -50,6 +50,7 @@ import { bpsOf, RPC, OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, ALLOWANCE_HOLDER, NATIVE_TOKEN_ADDRESS, } from './config'; @@ -67,6 +68,7 @@ import { MonolithicExecutionCall, NO_FEE, ZERO_ADDRESS, + ZERO_BYTES32, monolithicArgs, } from './utils/contractTypes'; import { sleep } from './utils/sleep'; @@ -167,7 +169,6 @@ interface OoSwapQuoteResponse { async function fetchOoQuote( routerAddress: string, inputAmount: bigint, - slippageBps: number = 100, ): Promise<{ ooRouter: string; swapData: string; @@ -178,7 +179,7 @@ async function fetchOoQuote( inTokenAddress: TOKENS.AAVE_ETH, outTokenAddress: NATIVE_TOKEN_ADDRESS, amount: ethers.formatUnits(inputAmount, 18), - slippage: (slippageBps / 100).toString(), + slippage: OO_SLIPPAGE_PERCENT, sender: routerAddress, account: routerAddress, gasPrice: '20', @@ -343,7 +344,7 @@ async function executeLeg( ooRouter, swapData, ); - execCalldata = routerIface.encodeFunctionData('performModularExecution', [actions]); + execCalldata = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, actions]); } else { const mono = buildMonolithic(signerAddress, inputAmount, feeAmount, minAmountOut, ooRouter, swapData); execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono)); diff --git a/scripts/e2e/swapBridgeViaCctp.ts b/scripts/e2e/swapBridgeViaCctp.ts index 22a734a..c55af37 100644 --- a/scripts/e2e/swapBridgeViaCctp.ts +++ b/scripts/e2e/swapBridgeViaCctp.ts @@ -27,6 +27,7 @@ import { bpsOf, RPC, OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, ALLOWANCE_HOLDER, } from './config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; @@ -38,6 +39,7 @@ import { MonolithicExecutionCall, NO_FEE, NO_SWAP, + ZERO_BYTES32, bridgeAmountPositionFlag, monolithicArgs, } from './utils/contractTypes'; @@ -64,7 +66,6 @@ interface OpenOceanSwapQuoteResponse { async function fetchOpenOceanSwapQuote( routerAddress: string, inputAmount: bigint, - slippageBps: number = 100, ): Promise<{ routerAddress: string; swapData: string; @@ -75,7 +76,7 @@ async function fetchOpenOceanSwapQuote( inTokenAddress: TOKENS.AAVE_POLYGON, outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, amount: ethers.formatUnits(inputAmount, 18), - slippage: (slippageBps / 100).toString(), + slippage: OO_SLIPPAGE_PERCENT, sender: routerAddress, account: routerAddress, gasPrice: '1', @@ -286,6 +287,7 @@ async function executeLegUsdcPolygonToBaseCctp(args: { let execCalldata: string; if (useModular) { execCalldata = routerIface.encodeFunctionData('performModularExecution', [ + ZERO_BYTES32, buildModularActionsUsdcPolygonToBaseCctp( signerAddress, ROUTER_POLYGON, @@ -373,6 +375,7 @@ async function executeLeg(args: { let execCalldata: string; if (useModular) { execCalldata = routerIface.encodeFunctionData('performModularExecution', [ + ZERO_BYTES32, buildModularActions( signerAddress, ROUTER_POLYGON, diff --git a/scripts/e2e/swapBridgeViaCctpSimple.ts b/scripts/e2e/swapBridgeViaCctpSimple.ts index d5831e5..5ffc4fb 100644 --- a/scripts/e2e/swapBridgeViaCctpSimple.ts +++ b/scripts/e2e/swapBridgeViaCctpSimple.ts @@ -33,7 +33,7 @@ import { import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; import { getWalletErc20Balance } from './utils/erc20'; import { ROUTER_ABI } from './utils/routerAbi'; -import type { BridgeData, FeeData, InputData } from './utils/contractTypes'; +import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from './utils/contractTypes'; import { logTxnSummary } from './utils/txnLogSummary'; import { ensureRouterErc20Balance, @@ -95,6 +95,7 @@ function buildBridgeCalldata( }; return routerIface.encodeFunctionData('bridge', [ + ZERO_BYTES32, input, args.fee, bridgeData, diff --git a/scripts/e2e/swapBridgeViaOft.ts b/scripts/e2e/swapBridgeViaOft.ts index 5259910..53971f3 100644 --- a/scripts/e2e/swapBridgeViaOft.ts +++ b/scripts/e2e/swapBridgeViaOft.ts @@ -51,6 +51,7 @@ import { bpsOf, RPC, OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, ALLOWANCE_HOLDER, ARBITRUM_LZ_EID, USDT0_OFT_ADAPTER_POLYGON, @@ -72,6 +73,7 @@ import { MonolithicExecutionCall, NO_FEE, NO_SWAP, + ZERO_BYTES32, bridgeAmountPositionFlag, monolithicArgs, } from './utils/contractTypes'; @@ -124,7 +126,6 @@ interface OoSwapQuoteResponse { */ async function fetchOpenOceanQuote( inputAmount: bigint, - slippageBps: number = 100, ): Promise<{ ooRouter: string; swapData: string; @@ -135,7 +136,7 @@ async function fetchOpenOceanQuote( inTokenAddress: TOKENS.AAVE_POLYGON, outTokenAddress: TOKENS.USDT0_POLYGON, amount: ethers.formatUnits(inputAmount, 18), // AAVE has 18 decimals - slippage: (slippageBps / 100).toString(), + slippage: OO_SLIPPAGE_PERCENT, sender: ROUTER_POLYGON, account: ROUTER_POLYGON, gasPrice: '1', @@ -393,6 +394,7 @@ async function executeCase1Leg(args: { let execCalldata: string; if (useModular) { execCalldata = routerIface.encodeFunctionData('performModularExecution', [ + ZERO_BYTES32, buildCase1Modular( signerAddress, inputAmount, @@ -584,6 +586,7 @@ async function executeCase2Leg(args: { let execCalldata: string; if (useModular) { execCalldata = routerIface.encodeFunctionData('performModularExecution', [ + ZERO_BYTES32, buildCase2Modular( signerAddress, inputAmount, diff --git a/scripts/e2e/swapBridgeViaStargateNative.ts b/scripts/e2e/swapBridgeViaStargateNative.ts index 18cedfe..54961c5 100644 --- a/scripts/e2e/swapBridgeViaStargateNative.ts +++ b/scripts/e2e/swapBridgeViaStargateNative.ts @@ -68,6 +68,7 @@ import { bpsOf, RPC, OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, ALLOWANCE_HOLDER, NATIVE_TOKEN_ADDRESS, STARGATE_NATIVE_ARB, @@ -94,6 +95,7 @@ import { NO_FEE, NO_SWAP, ZERO_ADDRESS, + ZERO_BYTES32, bridgeAmountPositionFlag, monolithicArgs, } from './utils/contractTypes'; @@ -366,7 +368,6 @@ async function fetchOoQuote( cfg: OoSwapConfig, routerAddress: string, amount: bigint, - slippageBps: number = 100, ): Promise<{ ooRouter: string; swapData: string; @@ -379,7 +380,7 @@ async function fetchOoQuote( inTokenAddress: cfg.inToken, outTokenAddress: cfg.outToken, amount: ethers.formatUnits(amount, cfg.inDecimals), - slippage: (slippageBps / 100).toString(), + slippage: OO_SLIPPAGE_PERCENT, sender: routerAddress, account: routerAddress, gasPrice: cfg.gasPrice, @@ -973,7 +974,7 @@ async function executeLeg( signerAddress, routerAddress, cfg, inputAmountWei, feeAmount, nativeFeeWithBuffer, stargateData, ); } - execCalldata = routerIface.encodeFunctionData('performModularExecution', [actions]); + execCalldata = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, actions]); } else { let mono: MonolithicExecutionCall; if (cfg.isNativePool) { diff --git a/scripts/e2e/utils/contractTypes.ts b/scripts/e2e/utils/contractTypes.ts index 2721773..9f5dfac 100644 --- a/scripts/e2e/utils/contractTypes.ts +++ b/scripts/e2e/utils/contractTypes.ts @@ -52,6 +52,10 @@ export const BRIDGE_AMOUNT_POSITION_FLAG = 8n; export const BRIDGE_AMOUNT_POSITION_SHIFT = 16n; export const MAX_BRIDGE_AMOUNT_POSITION = 0xffffn; +/** 32-byte zero; use as `requestHash` when scripts do not assign a request id. */ +export const ZERO_BYTES32 = + '0x0000000000000000000000000000000000000000000000000000000000000000' as const; + export function bridgeAmountPositionFlag(position: bigint | number): bigint { const positionBigInt = BigInt(position); if (positionBigInt < 0n || positionBigInt > MAX_BRIDGE_AMOUNT_POSITION) { @@ -60,12 +64,11 @@ export function bridgeAmountPositionFlag(position: bigint | number): bigint { return BRIDGE_AMOUNT_POSITION_FLAG | (positionBigInt << BRIDGE_AMOUNT_POSITION_SHIFT); } -export function monolithicArgs(call: MonolithicExecutionCall) { - return [ - call.exec, - call.swapCallData, - call.bridgeCallData, - ] as const; +export function monolithicArgs( + call: MonolithicExecutionCall, + requestHash: string = ZERO_BYTES32, +): readonly [string, MonolithicExecution, string, string] { + return [requestHash, call.exec, call.swapCallData, call.bridgeCallData] as const; } // ─── Sentinel / zero helpers ────────────────────────────────────────────────── diff --git a/scripts/e2e/utils/reproducibility.ts b/scripts/e2e/utils/reproducibility.ts index 6252190..9db88d0 100644 --- a/scripts/e2e/utils/reproducibility.ts +++ b/scripts/e2e/utils/reproducibility.ts @@ -14,7 +14,9 @@ */ import { ethers } from 'ethers'; import { getErc20Contract, encodeApprove } from './erc20'; +import { execViaAH } from './allowanceHolder'; import { ROUTER_ABI } from './routerAbi'; +import { ZERO_BYTES32 } from './contractTypes'; const SEED_WEI = 20n; @@ -100,7 +102,11 @@ export async function ensureRouterApproval( splices: [], }, ]; - const calldata = routerIface.encodeFunctionData('performModularExecution', [actions]); - const tx = await signer.sendTransaction({ to: openRouter, data: calldata }); - await tx.wait(); + const calldata = routerIface.encodeFunctionData('performModularExecution', [ + ZERO_BYTES32, + actions, + ]); + // Route through AllowanceHolder so _msgSender() resolves correctly inside the router. + // amount=0 because we are not pulling user tokens — we only need AH to forward the call. + await execViaAH(signer, openRouter, tokenResolved, 0n, openRouter, calldata); } diff --git a/scripts/e2e/utils/routerAbi.ts b/scripts/e2e/utils/routerAbi.ts index 9494de6..206584a 100644 --- a/scripts/e2e/utils/routerAbi.ts +++ b/scripts/e2e/utils/routerAbi.ts @@ -3,8 +3,9 @@ * called from e2e scripts. Structs must exactly match the Solidity definitions. */ export const ROUTER_ABI = [ - // Monolithic path + // Monolithic path — `requestHash` is first for indexer-friendly calldata layout `function performExecution( + bytes32 requestHash, ( (address user, address inputToken, uint256 inputAmount) input, (address receiver, uint256 amount) preFee, @@ -19,11 +20,35 @@ export const ROUTER_ABI = [ // Modular path `function performModularExecution( + bytes32 requestHash, (uint256 actionInfo, bytes data, uint256[] splices)[] actions ) external payable`, + // Standalone swap — pull, optional fee, swap; returns finalAmount + `function swap( + bytes32 requestHash, + (address user, address inputToken, uint256 inputAmount) input, + uint256 flags, + (address receiver, uint256 amount) fee, + (address target, address approvalSpender, address outputToken, uint256 value, uint256 minOutput, uint256 returnDataWordOffset) swapData, + bytes swapCallData + ) external payable returns (uint256)`, + + // Swap + bridge — pull, optional fee, swap, then bridge with optional amount splicing + `function swapAndBridge( + bytes32 requestHash, + (address user, address inputToken, uint256 inputAmount) input, + uint256 flags, + (address receiver, uint256 amount) fee, + (address target, address approvalSpender, address outputToken, uint256 value, uint256 minOutput, uint256 returnDataWordOffset) swapData, + bytes swapCallData, + (address target, address approvalSpender, uint256 value) bridgeData, + bytes bridgeCallData + ) external payable`, + // Simple bridge path (no swap, no splicing — caller pre-encodes finalAmount into data) `function bridge( + bytes32 requestHash, (address user, address inputToken, uint256 inputAmount) input, (address receiver, uint256 amount) fee, (address target, address approvalSpender, uint256 value) bridgeData, From 08d78aac39f9316f4026b466e42a210ae221e045 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 18 May 2026 12:43:48 +0530 Subject: [PATCH 09/42] refactor: tests --- .../e2e/arbitrum/performExecution.preFee.ts | 132 +++++++++++++++ .../performModularExecution.preFee.ts | 118 +++++++++++++ scripts/e2e/cctp/bridge.preFee.ts | 118 +++++++++++++ scripts/e2e/oft/bridge.preFee.ts | 160 ++++++++++++++++++ scripts/e2e/relay/aave.bridge.preFee.ts | 97 +++++++++++ .../e2e/relay/aave.performExecution.preFee.ts | 112 ++++++++++++ .../aave.performModularExecution.preFee.ts | 107 ++++++++++++ scripts/e2e/relay/usdc.bridge.preFee.ts | 97 +++++++++++ .../e2e/relay/usdc.performExecution.preFee.ts | 112 ++++++++++++ .../usdc.performModularExecution.preFee.ts | 107 ++++++++++++ .../stargate/polygonUsdcBase.bridge.preFee.ts | 158 +++++++++++++++++ 11 files changed, 1318 insertions(+) create mode 100644 scripts/e2e/arbitrum/performExecution.preFee.ts create mode 100644 scripts/e2e/arbitrum/performModularExecution.preFee.ts create mode 100644 scripts/e2e/cctp/bridge.preFee.ts create mode 100644 scripts/e2e/oft/bridge.preFee.ts create mode 100644 scripts/e2e/relay/aave.bridge.preFee.ts create mode 100644 scripts/e2e/relay/aave.performExecution.preFee.ts create mode 100644 scripts/e2e/relay/aave.performModularExecution.preFee.ts create mode 100644 scripts/e2e/relay/usdc.bridge.preFee.ts create mode 100644 scripts/e2e/relay/usdc.performExecution.preFee.ts create mode 100644 scripts/e2e/relay/usdc.performModularExecution.preFee.ts create mode 100644 scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts diff --git a/scripts/e2e/arbitrum/performExecution.preFee.ts b/scripts/e2e/arbitrum/performExecution.preFee.ts new file mode 100644 index 0000000..63ff1f4 --- /dev/null +++ b/scripts/e2e/arbitrum/performExecution.preFee.ts @@ -0,0 +1,132 @@ +/** + * Route: Ethereum ETH → Arbitrum ETH (Arbitrum inbox depositEth, no swap) + * Function: performExecution (monolithic) + * Fee: preFee — FEE_BPS of inputAmount ETH deducted before bridge + * + * BRIDGE_VALUE_FLAG set: router forwards the remaining ETH after preFee as + * msg.value to inbox.depositEth(). Input is native ETH so we call execDirect + * (no AllowanceHolder needed — router checks msg.value >= inputAmount directly). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + ARBITRUM_INBOX, + FEE_BPS, + bpsOf, + RPC, + NATIVE_TOKEN_ADDRESS, +} from '../config'; +import { execDirect } from '../utils/allowanceHolder'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + BRIDGE_VALUE_FLAG, + NO_FEE, + NO_SWAP, + ZERO_ADDRESS, + ZERO_BYTES32, + monolithicArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterNativeBalance } from '../utils/reproducibility'; + +const ROUTER_ETH = routerAddressForChain(CHAIN_IDS.ETHEREUM); + +/** Gas reserve kept in the signer's wallet to cover the transaction itself. */ +const GAS_RESERVE = ethers.parseEther('0.005'); + +function buildDepositEthCalldata(): string { + return new ethers.Interface([ + 'function depositEth() external payable returns (uint256)', + ]).encodeFunctionData('depositEth', []); +} + +async function estimateArbitrumBridgeFee(provider: ethers.Provider): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ParentToChildMessageGasEstimator } = require('@arbitrum/sdk'); + const estimator = new ParentToChildMessageGasEstimator(provider); + const l2GasPrice = (await new ethers.JsonRpcProvider(RPC.ARBITRUM).getFeeData()).gasPrice ?? 0n; + const submissionFee = await estimator.estimateSubmissionFee(provider, 0n, 0n); + const executionCost = 250000n * (l2GasPrice + (l2GasPrice * 20n) / 100n); + const totalFee = BigInt(submissionFee.toString()) + executionCost; + console.log(` Estimated Arbitrum bridge fee: ${ethers.formatEther(totalFee)} ETH`); + return totalFee; + } catch (err) { + const fallback = ethers.parseEther('0.001'); + console.warn(` Arb fee estimation failed (${(err as Error).message}), using fallback: ${ethers.formatEther(fallback)} ETH`); + return fallback; + } +} + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.ETHEREUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const rawBalance = await provider.getBalance(signerAddress); + const inputAmount = rawBalance - GAS_RESERVE - 20n; + if (inputAmount <= 0n) { + throw new Error(`Signer ${signerAddress} has insufficient ETH on Ethereum (balance: ${ethers.formatEther(rawBalance)})`); + } + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeValue = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ETH}`); + console.log(`ETH balance: ${ethers.formatEther(rawBalance)}`); + console.log(`Input amount: ${ethers.formatEther(inputAmount)} (balance minus gas reserve)`); + console.log(`Pre-bridge fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); + console.log(`Bridge value: ${ethers.formatEther(bridgeValue)} ETH`); + + const arbFee = await estimateArbitrumBridgeFee(provider); + if (bridgeValue < arbFee) { + console.warn(` Warning: bridgeValue (${ethers.formatEther(bridgeValue)}) may be below Arbitrum bridge cost (${ethers.formatEther(arbFee)})`); + } + + await ensureRouterNativeBalance(signer, ROUTER_ETH); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken: NATIVE_TOKEN_ADDRESS, inputAmount }, + preFee: { receiver: signerAddress, amount: feeAmount }, + swap: NO_SWAP, + postFee: NO_FEE, + bridge: { target: ARBITRUM_INBOX, approvalSpender: ZERO_ADDRESS, value: 0n }, + flags: BRIDGE_VALUE_FLAG, + }, + swapCallData: '0x', + bridgeCallData: buildDepositEthCalldata(), + }; + + const routerIface = new ethers.Interface(ROUTER_ABI); + const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + // Native ETH input — send directly to the router; no AllowanceHolder needed. + console.log('Sending direct router tx → router.performExecution...'); + const receipt = await execDirect(signer, ROUTER_ETH, callData, inputAmount); + + logTxnSummary('Ethereum ETH → Arbitrum ETH (depositEth direct) — performExecution preFee', CHAIN_IDS.ETHEREUM, receipt); + + console.log('\nETH arrives on Arbitrum once the retryable ticket is processed.'); + + void arbFee; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/arbitrum/performModularExecution.preFee.ts b/scripts/e2e/arbitrum/performModularExecution.preFee.ts new file mode 100644 index 0000000..4048057 --- /dev/null +++ b/scripts/e2e/arbitrum/performModularExecution.preFee.ts @@ -0,0 +1,118 @@ +/** + * Route: Ethereum ETH → Arbitrum ETH (Arbitrum inbox depositEth, no swap) + * Function: performModularExecution (modular) + * Fee: preFee — FEE_BPS of inputAmount ETH deducted before bridge + * + * Modular action sequence: + * [0] nativeCall(signer, '0x', feeAmount) — preFee ETH to signer + * [1] nativeCall(inbox, depositEth(), bridgeValue) — bridge remaining ETH + * + * Input is native ETH so we call execDirect (no AllowanceHolder needed — + * performModularExecution has no _pullFromUser; ETH arrives via msg.value). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performModularExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + ARBITRUM_INBOX, + FEE_BPS, + bpsOf, + RPC, +} from '../config'; +import { execDirect } from '../utils/allowanceHolder'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterNativeBalance } from '../utils/reproducibility'; + +const ROUTER_ETH = routerAddressForChain(CHAIN_IDS.ETHEREUM); + +/** Gas reserve kept in the signer's wallet to cover the transaction itself. */ +const GAS_RESERVE = ethers.parseEther('0.005'); + +function buildDepositEthCalldata(): string { + return new ethers.Interface([ + 'function depositEth() external payable returns (uint256)', + ]).encodeFunctionData('depositEth', []); +} + +async function estimateArbitrumBridgeFee(provider: ethers.Provider): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ParentToChildMessageGasEstimator } = require('@arbitrum/sdk'); + const estimator = new ParentToChildMessageGasEstimator(provider); + const l2GasPrice = (await new ethers.JsonRpcProvider(RPC.ARBITRUM).getFeeData()).gasPrice ?? 0n; + const submissionFee = await estimator.estimateSubmissionFee(provider, 0n, 0n); + const executionCost = 250000n * (l2GasPrice + (l2GasPrice * 20n) / 100n); + const totalFee = BigInt(submissionFee.toString()) + executionCost; + console.log(` Estimated Arbitrum bridge fee: ${ethers.formatEther(totalFee)} ETH`); + return totalFee; + } catch (err) { + const fallback = ethers.parseEther('0.001'); + console.warn(` Arb fee estimation failed (${(err as Error).message}), using fallback: ${ethers.formatEther(fallback)} ETH`); + return fallback; + } +} + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.ETHEREUM); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const rawBalance = await provider.getBalance(signerAddress); + const inputAmount = rawBalance - GAS_RESERVE - 20n; + if (inputAmount <= 0n) { + throw new Error(`Signer ${signerAddress} has insufficient ETH on Ethereum (balance: ${ethers.formatEther(rawBalance)})`); + } + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeValue = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_ETH}`); + console.log(`ETH balance: ${ethers.formatEther(rawBalance)}`); + console.log(`Input amount: ${ethers.formatEther(inputAmount)} (balance minus gas reserve)`); + console.log(`Pre-bridge fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); + console.log(`Bridge value: ${ethers.formatEther(bridgeValue)} ETH`); + + const arbFee = await estimateArbitrumBridgeFee(provider); + if (bridgeValue < arbFee) { + console.warn(` Warning: bridgeValue (${ethers.formatEther(bridgeValue)}) may be below Arbitrum bridge cost (${ethers.formatEther(arbFee)})`); + } + + await ensureRouterNativeBalance(signer, ROUTER_ETH); + + const exec = new ModularActionsBuilder(); + exec.nativeCall(signerAddress, '0x', feeAmount); + exec.nativeCall(ARBITRUM_INBOX, buildDepositEthCalldata(), bridgeValue); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + // Native ETH input — send directly to the router; no AllowanceHolder needed. + console.log('Sending direct router tx → router.performModularExecution...'); + const receipt = await execDirect(signer, ROUTER_ETH, callData, inputAmount); + + logTxnSummary('Ethereum ETH → Arbitrum ETH (depositEth direct) — performModularExecution preFee', CHAIN_IDS.ETHEREUM, receipt); + + console.log('\nETH arrives on Arbitrum once the retryable ticket is processed.'); + + void arbFee; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/cctp/bridge.preFee.ts b/scripts/e2e/cctp/bridge.preFee.ts new file mode 100644 index 0000000..196daf6 --- /dev/null +++ b/scripts/e2e/cctp/bridge.preFee.ts @@ -0,0 +1,118 @@ +/** + * Route: Polygon USDC → Base USDC (CCTP v2, no swap) + * Function: bridge (simple bridge entrypoint) + * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge + * + * Bridge amount is pre-encoded in depositForBurn calldata (no splice needed). + * Uses router.bridge() rather than performExecution / performModularExecution. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/bridge.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number, + amount: bigint, + fastPath: boolean = true, +): string { + const iface = new ethers.Interface([ + 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external', + ]); + const mintRecipient = ethers.zeroPadValue(recipientAddress, 32); + const maxFee = fastPath ? 1_000_000n : 0n; + const minFinalityThreshold = fastPath ? 1000 : 2000; + return iface.encodeFunctionData('depositForBurn', [ + amount, + destinationCctpDomain, + mintRecipient, + burnToken, + ethers.ZeroHash, + maxFee, + minFinalityThreshold, + ]); +} + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.USDC_POLYGON_CIRCLE; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero Circle native USDC on Polygon. Fund ${inputToken} on Polygon PoS.`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Input USDC: ${ethers.formatUnits(inputAmount, 6)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); + + const depositData = buildDepositForBurnCalldata( + signerAddress, + polyCctp.usdcAddress, + baseCctp.cctpDomain, + bridgeAmount, + ); + + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { target: polyCctp.tokenMessenger, approvalSpender: polyCctp.tokenMessenger, value: 0n }; + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, depositData]); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, polyCctp.tokenMessenger); + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.bridge...'); + const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); + + logTxnSummary('Polygon USDC → Base USDC — CCTP — bridge preFee', CHAIN_IDS.POLYGON, receipt); + + console.log(`\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/oft/bridge.preFee.ts b/scripts/e2e/oft/bridge.preFee.ts new file mode 100644 index 0000000..e1f1033 --- /dev/null +++ b/scripts/e2e/oft/bridge.preFee.ts @@ -0,0 +1,160 @@ +/** + * Route: Polygon USDT0 → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2, no swap) + * Function: bridge (simple bridge entrypoint) + * Fee: preFee — FEE_BPS of inputAmount USDT0 deducted before bridge + * + * Bridge amount is pre-encoded in OFT send() calldata (no splice needed). + * bridge.value = nativeFeeWithBuffer forwarded as LZ msg.value. + * Uses router.bridge() rather than performExecution / performModularExecution. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/bridge.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +import { Options } from '@layerzerolabs/lz-v2-utilities'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ARBITRUM_LZ_EID, + USDT0_OFT_ADAPTER_POLYGON, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); + +const OFT_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const OFT_IFACE = new ethers.Interface(OFT_ABI); + +async function fetchOftQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(USDT0_OFT_ADAPTER_POLYGON, OFT_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: ARBITRUM_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: '0x', + oftCmd: '0x', + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +/** + * Builds OFT send() calldata with the exact bridgeAmount pre-encoded (no 0 placeholder). + * Used by router.bridge() which has no splice mechanism. + */ +function buildOftSendCalldata(bridgeAmount: bigint, nativeFee: bigint, recipient: string): string { + return OFT_IFACE.encodeFunctionData('send', [ + { + dstEid: ARBITRUM_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD: bridgeAmount, + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: '0x', + oftCmd: '0x', + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.USDT0_POLYGON; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero USDT0 on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDT0 balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); + + console.log('Fetching OFT quote (Polygon → Arbitrum)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote(provider, bridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); + + const sendData = buildOftSendCalldata(bridgeAmount, nativeFeeWithBuffer, signerAddress); + + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + value: nativeFeeWithBuffer, + }; + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, sendData]); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, USDT0_OFT_ADAPTER_POLYGON); + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.bridge...'); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + inputToken, + inputAmount, + ROUTER_POLYGON, + execCalldata, + nativeFeeWithBuffer, + ); + + logTxnSummary('Polygon USDT0 → Arbitrum USDT0 (OFT) — bridge preFee', CHAIN_IDS.POLYGON, receipt); + + console.log('\nUSDT0 arrives on Arbitrum once LZ delivers the message.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/relay/aave.bridge.preFee.ts b/scripts/e2e/relay/aave.bridge.preFee.ts new file mode 100644 index 0000000..15efa33 --- /dev/null +++ b/scripts/e2e/relay/aave.bridge.preFee.ts @@ -0,0 +1,97 @@ +/** + * Route: Polygon AAVE → Base AAVE via Relay.link (no swap) + * Function: bridge (simple bridge entrypoint) + * Fee: preFee — FEE_BPS of inputAmount AAVE deducted before bridge + * + * Bridge amount is pre-encoded in Relay deposit calldata. + * Uses router.bridge() rather than performExecution / performModularExecution. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/aave.bridge.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; +import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.AAVE_POLYGON; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 18)}`); + + console.log('Fetching Relay.link quote...'); + const quote = await fetchRelayQuoteV2({ + routerAddress: ROUTER_POLYGON, + recipient: signerAddress, + originChainId: CHAIN_IDS.POLYGON, + destinationChainId: CHAIN_IDS.BASE, + originCurrency: TOKENS.AAVE_POLYGON, + destinationCurrency: TOKENS.AAVE_BASE, + amount: bridgeAmount, + }); + const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); + console.log(`Relay spender: ${relaySpender}`); + console.log(`Deposit target: ${depositTarget}`); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); + + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { target: depositTarget, approvalSpender: relaySpender, value: 0n }; + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, depositData]); + + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.bridge...'); + const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); + + logTxnSummary('Polygon AAVE → Base AAVE — Relay — bridge preFee', CHAIN_IDS.POLYGON, receipt); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/relay/aave.performExecution.preFee.ts b/scripts/e2e/relay/aave.performExecution.preFee.ts new file mode 100644 index 0000000..ea6ea8c --- /dev/null +++ b/scripts/e2e/relay/aave.performExecution.preFee.ts @@ -0,0 +1,112 @@ +/** + * Route: Polygon AAVE → Base AAVE via Relay.link (no swap) + * Function: performExecution (monolithic) + * Fee: preFee — FEE_BPS of inputAmount AAVE deducted before bridge + * + * Fetches a Relay.link /quote/v2 for the net bridge amount, then encodes a + * MonolithicExecutionCall with preFee and the deposit calldata. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/aave.performExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + NO_FEE, + NO_SWAP, + ZERO_BYTES32, + monolithicArgs, +} from '../utils/contractTypes'; +import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.AAVE_POLYGON; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 18)}`); + + console.log('Fetching Relay.link quote...'); + const quote = await fetchRelayQuoteV2({ + routerAddress: ROUTER_POLYGON, + recipient: signerAddress, + originChainId: CHAIN_IDS.POLYGON, + destinationChainId: CHAIN_IDS.BASE, + originCurrency: TOKENS.AAVE_POLYGON, + destinationCurrency: TOKENS.AAVE_BASE, + amount: bridgeAmount, + }); + const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); + console.log(`Relay spender: ${relaySpender}`); + console.log(`Deposit target: ${depositTarget}`); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken, inputAmount }, + preFee: { receiver: signerAddress, amount: feeAmount }, + swap: NO_SWAP, + postFee: NO_FEE, + bridge: { target: depositTarget, approvalSpender: relaySpender, value: 0n }, + flags: 0n, + }, + swapCallData: '0x', + bridgeCallData: depositData, + }; + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.performExecution...'); + const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); + + logTxnSummary('Polygon AAVE → Base AAVE — Relay — performExecution preFee', CHAIN_IDS.POLYGON, receipt); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/relay/aave.performModularExecution.preFee.ts b/scripts/e2e/relay/aave.performModularExecution.preFee.ts new file mode 100644 index 0000000..eaa5dd0 --- /dev/null +++ b/scripts/e2e/relay/aave.performModularExecution.preFee.ts @@ -0,0 +1,107 @@ +/** + * Route: Polygon AAVE → Base AAVE via Relay.link (no swap) + * Function: performModularExecution (modular) + * Fee: preFee — FEE_BPS of inputAmount AAVE deducted before bridge + * + * Modular action sequence: + * [0] AH.transferFrom(AAVE, signer, router, inputAmount) + * [1] AAVE.transfer(signer, feeAmount) — preFee out + * [2] AAVE.approve(relaySpender, bridgeAmount) + * [3] call(depositTarget, depositData) — Relay bridge + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/aave.performModularExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ALLOWANCE_HOLDER, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, encodeTransfer, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.AAVE_POLYGON; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 18)}`); + + console.log('Fetching Relay.link quote...'); + const quote = await fetchRelayQuoteV2({ + routerAddress: ROUTER_POLYGON, + recipient: signerAddress, + originChainId: CHAIN_IDS.POLYGON, + destinationChainId: CHAIN_IDS.BASE, + originCurrency: TOKENS.AAVE_POLYGON, + destinationCurrency: TOKENS.AAVE_BASE, + amount: bridgeAmount, + }); + const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); + console.log(`Relay spender: ${relaySpender}`); + console.log(`Deposit target: ${depositTarget}`); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [inputToken, signerAddress, ROUTER_POLYGON, inputAmount])); + exec.call(inputToken, encodeTransfer(signerAddress, feeAmount)); + exec.call(inputToken, encodeApprove(relaySpender, bridgeAmount)); + exec.call(depositTarget, depositData); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.performModularExecution...'); + const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); + + logTxnSummary('Polygon AAVE → Base AAVE — Relay — performModularExecution preFee', CHAIN_IDS.POLYGON, receipt); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/relay/usdc.bridge.preFee.ts b/scripts/e2e/relay/usdc.bridge.preFee.ts new file mode 100644 index 0000000..3b374b6 --- /dev/null +++ b/scripts/e2e/relay/usdc.bridge.preFee.ts @@ -0,0 +1,97 @@ +/** + * Route: Polygon USDC → Base USDC via Relay.link (no swap) + * Function: bridge (simple bridge entrypoint) + * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge + * + * Bridge amount is pre-encoded in Relay deposit calldata. + * Uses router.bridge() rather than performExecution / performModularExecution. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/usdc.bridge.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; +import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.USDC_POLYGON_CIRCLE; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero Circle native USDC on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); + + console.log('Fetching Relay.link quote...'); + const quote = await fetchRelayQuoteV2({ + routerAddress: ROUTER_POLYGON, + recipient: signerAddress, + originChainId: CHAIN_IDS.POLYGON, + destinationChainId: CHAIN_IDS.BASE, + originCurrency: TOKENS.USDC_POLYGON_CIRCLE, + destinationCurrency: TOKENS.USDC_BASE, + amount: bridgeAmount, + }); + const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); + console.log(`Relay spender: ${relaySpender}`); + console.log(`Deposit target: ${depositTarget}`); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); + + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { target: depositTarget, approvalSpender: relaySpender, value: 0n }; + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, depositData]); + + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.bridge...'); + const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); + + logTxnSummary('Polygon USDC → Base USDC — Relay — bridge preFee', CHAIN_IDS.POLYGON, receipt); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/relay/usdc.performExecution.preFee.ts b/scripts/e2e/relay/usdc.performExecution.preFee.ts new file mode 100644 index 0000000..9be5f2b --- /dev/null +++ b/scripts/e2e/relay/usdc.performExecution.preFee.ts @@ -0,0 +1,112 @@ +/** + * Route: Polygon USDC → Base USDC via Relay.link (no swap) + * Function: performExecution (monolithic) + * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge + * + * Fetches a Relay.link /quote/v2 for the net bridge amount, then encodes a + * MonolithicExecutionCall with preFee and the deposit calldata. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/usdc.performExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { + MonolithicExecutionCall, + NO_FEE, + NO_SWAP, + ZERO_BYTES32, + monolithicArgs, +} from '../utils/contractTypes'; +import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.USDC_POLYGON_CIRCLE; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero Circle native USDC on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); + + console.log('Fetching Relay.link quote...'); + const quote = await fetchRelayQuoteV2({ + routerAddress: ROUTER_POLYGON, + recipient: signerAddress, + originChainId: CHAIN_IDS.POLYGON, + destinationChainId: CHAIN_IDS.BASE, + originCurrency: TOKENS.USDC_POLYGON_CIRCLE, + destinationCurrency: TOKENS.USDC_BASE, + amount: bridgeAmount, + }); + const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); + console.log(`Relay spender: ${relaySpender}`); + console.log(`Deposit target: ${depositTarget}`); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); + + const mono: MonolithicExecutionCall = { + exec: { + input: { user: signerAddress, inputToken, inputAmount }, + preFee: { receiver: signerAddress, amount: feeAmount }, + swap: NO_SWAP, + postFee: NO_FEE, + bridge: { target: depositTarget, approvalSpender: relaySpender, value: 0n }, + flags: 0n, + }, + swapCallData: '0x', + bridgeCallData: depositData, + }; + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.performExecution...'); + const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); + + logTxnSummary('Polygon USDC → Base USDC — Relay — performExecution preFee', CHAIN_IDS.POLYGON, receipt); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/relay/usdc.performModularExecution.preFee.ts b/scripts/e2e/relay/usdc.performModularExecution.preFee.ts new file mode 100644 index 0000000..66cbc85 --- /dev/null +++ b/scripts/e2e/relay/usdc.performModularExecution.preFee.ts @@ -0,0 +1,107 @@ +/** + * Route: Polygon USDC → Base USDC via Relay.link (no swap) + * Function: performModularExecution (modular) + * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge + * + * Modular action sequence: + * [0] AH.transferFrom(USDC, signer, router, inputAmount) + * [1] USDC.transfer(signer, feeAmount) — preFee out + * [2] USDC.approve(relaySpender, bridgeAmount) + * [3] call(depositTarget, depositData) — Relay bridge + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/usdc.performModularExecution.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ALLOWANCE_HOLDER, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { encodeApprove, encodeTransfer, getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.USDC_POLYGON_CIRCLE; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero Circle native USDC on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); + + console.log('Fetching Relay.link quote...'); + const quote = await fetchRelayQuoteV2({ + routerAddress: ROUTER_POLYGON, + recipient: signerAddress, + originChainId: CHAIN_IDS.POLYGON, + destinationChainId: CHAIN_IDS.BASE, + originCurrency: TOKENS.USDC_POLYGON_CIRCLE, + destinationCurrency: TOKENS.USDC_BASE, + amount: bridgeAmount, + }); + const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); + console.log(`Relay spender: ${relaySpender}`); + console.log(`Deposit target: ${depositTarget}`); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); + + const ahIface = new ethers.Interface([ + 'function transferFrom(address token, address owner, address recipient, uint256 amount)', + ]); + const exec = new ModularActionsBuilder(); + exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [inputToken, signerAddress, ROUTER_POLYGON, inputAmount])); + exec.call(inputToken, encodeTransfer(signerAddress, feeAmount)); + exec.call(inputToken, encodeApprove(relaySpender, bridgeAmount)); + exec.call(depositTarget, depositData); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.performModularExecution...'); + const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); + + logTxnSummary('Polygon USDC → Base USDC — Relay — performModularExecution preFee', CHAIN_IDS.POLYGON, receipt); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts b/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts new file mode 100644 index 0000000..5483846 --- /dev/null +++ b/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts @@ -0,0 +1,158 @@ +/** + * Route: Polygon USDC → Base USDC (Stargate USDC Pool, no swap) + * Function: bridge (simple bridge entrypoint) + * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge + * + * Bridge amount is pre-encoded in Stargate send() calldata (no splice needed). + * bridge.value = nativeFeeWithBuffer forwarded as LZ msg.value. + * Uses router.bridge() rather than performExecution / performModularExecution. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + STARGATE_USDC_POLYGON, + BASE_LZ_EID, +} from '../config'; +import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; +import { getWalletErc20Balance } from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +const STARGATE_ABI = [ + 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', + 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', + 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', +]; +const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); + +async function fetchStargateQuote( + provider: ethers.JsonRpcProvider, + bridgeAmountLD: bigint, + recipient: string, +): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract(STARGATE_USDC_POLYGON, STARGATE_ABI, provider); + const to32 = ethers.zeroPadValue(recipient, 32); + const sendParam = { + dstEid: BASE_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: '0x', + composeMsg: '0x', + oftCmd: '0x', + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + return { + nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, + }; +} + +/** + * Builds Stargate send() calldata with the exact bridgeAmount pre-encoded (no 0 placeholder). + * Used by router.bridge() which has no splice mechanism. + */ +function buildStargateCalldata(bridgeAmount: bigint, nativeFee: bigint, recipient: string): string { + return STARGATE_IFACE.encodeFunctionData('send', [ + { + dstEid: BASE_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD: bridgeAmount, + minAmountLD: 0n, + extraOptions: '0x', + composeMsg: '0x', + oftCmd: '0x', + }, + { nativeFee, lzTokenFee: 0n }, + recipient, + ]); +} + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const inputToken = TOKENS.USDC_POLYGON_CIRCLE; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero USDC on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error('Balance too small'); + + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); + + console.log('Fetching Stargate quote (Polygon → Base USDC pool)...'); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, bridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDC`); + + const sendData = buildStargateCalldata(bridgeAmount, nativeFeeWithBuffer, signerAddress); + + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { + target: STARGATE_USDC_POLYGON, + approvalSpender: STARGATE_USDC_POLYGON, + value: nativeFeeWithBuffer, + }; + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, sendData]); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, STARGATE_USDC_POLYGON); + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.bridge...'); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + inputToken, + inputAmount, + ROUTER_POLYGON, + execCalldata, + nativeFeeWithBuffer, + ); + + logTxnSummary('Polygon USDC → Base USDC (Stargate USDC pool) — bridge preFee', CHAIN_IDS.POLYGON, receipt); + + console.log('\nUSDC arrives on Base once LZ delivers the message.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From d24bb5a90d7d4e23d463a6ecaff380fd18692f6d Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 18 May 2026 12:57:39 +0530 Subject: [PATCH 10/42] fix: swap receiver --- src/combined/BungeeOpenRouterV2Unchecked.sol | 48 ++++++++++++++------ 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index 729699c..a44240e 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -214,6 +214,10 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /** * @notice Pull → optional pre/post fee → swap. * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. + * @param receiver Address that ultimately receives the swap output (net of any post-swap fee). + * For pre-fee / no-fee: the swap router must be instructed (via `swapCallData`) to send + * tokens directly to `receiver`; the contract never holds the output. + * For post-fee: tokens land at this contract, fee is deducted, net is forwarded to `receiver`. * @param flags Packed flags; OR masks then test with `flags & MASK != 0`. Masks: `FEE_FLAG_BIT_MASK` (0x01), `BALANCE_FLAG_BIT_MASK` (0x02). * Common values: `0` (pre-fee, returndata), `1` (post-fee, returndata), * `2` (pre-fee, balance delta), `3` (post-fee, balance delta). @@ -226,12 +230,16 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { function swap( bytes32 requestHash, InputData calldata input, + address receiver, uint256 flags, FeeData calldata fee, SwapData calldata swapData, bytes calldata swapCallData ) external payable returns (uint256 finalAmount) { - if (input.user == address(0) || input.inputToken == address(0) || swapData.target == address(0)) { + if ( + input.user == address(0) || input.inputToken == address(0) || swapData.target == address(0) + || receiver == address(0) + ) { revert InvalidExecution(); } @@ -259,11 +267,15 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); } + // Post-fee: swap output lands at this contract so the fee can be deducted before forwarding. + // Pre-fee / no-fee: swap calldata encodes `receiver` as the output recipient; tokens never touch this contract. + address outputReceiver = postFee ? address(this) : receiver; + // BALANCE_FLAG_BIT_MASK: unset ⇒ decode output word from returndata; set ⇒ output = delta on outputToken - finalAmount = _execSwap(swapData, swapCallData, flags & BALANCE_FLAG_BIT_MASK != 0); + finalAmount = _execSwap(swapData, swapCallData, flags & BALANCE_FLAG_BIT_MASK != 0, outputReceiver); if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); - // collect post-swap fee + // collect post-swap fee and forward net to receiver if (postFee) { uint256 feeAmount = fee.amount; if (feeAmount > finalAmount) revert InsufficientFunds(); @@ -271,7 +283,10 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { unchecked { finalAmount -= feeAmount; } + // Tokens are at this contract; transfer net output to receiver + CurrencyLib.transfer(swapData.outputToken, receiver, finalAmount); } + // Pre-fee / no-fee: tokens were sent directly to `receiver` by the swap router; nothing to transfer emit RequestExecuted(requestHash); } @@ -340,8 +355,9 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } // BALANCE_FLAG_BIT_MASK: same returndata vs balance-delta measurement as `swap` + // Swap output always lands at this contract regardless of fee timing — tokens must be here for bridging. bool useBalanceOf = flags & BALANCE_FLAG_BIT_MASK != 0; - finalAmount = _execSwap(swapData, swapCallData, useBalanceOf); + finalAmount = _execSwap(swapData, swapCallData, useBalanceOf, address(this)); if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); if (postFee) { @@ -508,7 +524,8 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } // Monolithic path: only `BALANCE_FLAG_BIT_MASK` is read for `_execSwap`; fee uses `preFee` / `postFee`, not bit 0. - finalAmount = _execSwap(exec.swap, swapCallData, exec.flags & BALANCE_FLAG_BIT_MASK != 0); + // Swap output always lands at this contract; it feeds directly into the bridge step. + finalAmount = _execSwap(exec.swap, swapCallData, exec.flags & BALANCE_FLAG_BIT_MASK != 0, address(this)); if (finalAmount < exec.swap.minOutput) revert SwapOutputInsufficient(); finalToken = exec.swap.outputToken; } @@ -518,17 +535,22 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // ========================================================================= /// @dev Execute swap; output measured via returndata word or output-token balance delta. - /// useBalanceOf=true: measure output as (balance after - balance before). + /// useBalanceOf=true: measure output as (balance after - balance before) at `outputReceiver`. /// useBalanceOf=false: decode output from returndata at swapData.returnDataWordOffset. - function _execSwap(SwapData calldata swapData, bytes calldata swapCallData, bool useBalanceOf) - internal - returns (uint256 finalAmount) - { + /// `outputReceiver` must be `address(this)` when tokens are expected at the contract + /// (post-swap fee path, bridge path) or `user` when the swap router sends directly to them + /// (pre-swap fee / no-fee standalone swap). + function _execSwap( + SwapData calldata swapData, + bytes calldata swapCallData, + bool useBalanceOf, + address outputReceiver + ) internal returns (uint256 finalAmount) { if (useBalanceOf) { - // Balance delta mode: snapshot before, call, measure delta - uint256 before = CurrencyLib.balanceOf(swapData.outputToken, address(this)); + // Balance delta mode: snapshot before, call, measure delta at the expected recipient + uint256 before = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver); _doCallCalldata(swapData.target, swapData.value, swapCallData, false); - finalAmount = CurrencyLib.balanceOf(swapData.outputToken, address(this)) - before; + finalAmount = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver) - before; } else { // Returndata mode: decode output from a specific word in returndata bytes memory ret = _doCallCalldata(swapData.target, swapData.value, swapCallData, true); From 22c39b6fd4b73b6746b140e25b18c9926c6db331 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 18 May 2026 17:06:27 +0530 Subject: [PATCH 11/42] fix: sum amount + bd.value --- src/combined/BungeeOpenRouterV2Unchecked.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index a44240e..8bc6b8c 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -577,7 +577,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } // when set, forward amount as msg.value for native-token bridges - uint256 bridgeValue = flags & BRIDGE_VALUE_FLAG_BIT_MASK != 0 ? amount : bd.value; + uint256 bridgeValue = flags & BRIDGE_VALUE_FLAG_BIT_MASK != 0 ? amount + bd.value : bd.value; _doCall(bd.target, bridgeValue, bData); } From 2dc00802a20a292e876c7ebdfd22d94dde868b02 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 18 May 2026 17:15:59 +0530 Subject: [PATCH 12/42] fix: comments --- src/combined/BungeeOpenRouterV2Unchecked.sol | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index 8bc6b8c..6445c6b 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -68,8 +68,8 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { SwapData swap; FeeData postFee; BridgeData bridge; - /// Packed flags; monolithic pipeline tests `BALANCE_FLAG_BIT_MASK` in `_execSwap` - /// and `BRIDGE_VALUE_FLAG_BIT_MASK` in `_doBridge`. + /// Packed flags; monolithic pipeline tests `BALANCE_FLAG_BIT_MASK` in `_execSwap`, + /// `BRIDGE_VALUE_FLAG_BIT_MASK` and `BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK` in `_doBridge`. /// Fee timing uses `preFee` / `postFee` structs — `FEE_FLAG_BIT_MASK` (bit 0) is ignored here. uint256 flags; } @@ -102,7 +102,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // bits 31..16 : bridge amount word byte offset, uint16, used only when bit 3 is set // bits 15..4 : reserved (0) // bit 3 : BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK (0x08) — splice finalAmount into bridge calldata - // bit 2 : BRIDGE_VALUE_FLAG_BIT_MASK (0x04) — bridge msg.value: static value vs final amount + // bit 2 : BRIDGE_VALUE_FLAG_BIT_MASK (0x04) — bridge msg.value: bridge.value alone vs finalAmount + bridge.value // bit 1 : BALANCE_FLAG_BIT_MASK (0x02) — swap output: returndata vs balance delta // bit 0 : FEE_FLAG_BIT_MASK (0x01) — swap fee: pre- vs post-swap (standalone paths only) // @@ -114,7 +114,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // 0x01 00000001 yes returndata word bridge.value // 0x02 00000010 no balance delta on outputToken bridge.value // 0x03 00000011 yes balance delta on outputToken bridge.value - // 0x04 00000100 no returndata word finalAmount + // 0x04 00000100 no returndata word finalAmount + bridge.value // // FEE_FLAG_BIT_MASK selects bit 0 — fee timing. // Cleared — pull → deduct fee from input token → swap remainder. @@ -125,8 +125,8 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // Set — snapshot outputToken balance before call, measure (after − before) as output. // // BRIDGE_VALUE_FLAG_BIT_MASK selects bit 2 — bridge native value source. - // Cleared — forward `bridge.value`. - // Set — forward finalAmount as msg.value. + // Cleared — forward `bridge.value` as msg.value. + // Set — forward `finalAmount + bridge.value` as msg.value (bridge.value carries static addend, e.g. LZ nativeFee). // // BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK selects bit 3 — bridge calldata amount splicing. // Cleared — no runtime amount splice. @@ -140,7 +140,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { /// @dev Bit mask 0x02: measure swap output by balance delta when `(flags & mask) != 0`; clear = returndata word. uint256 internal constant BALANCE_FLAG_BIT_MASK = 0x02; - /// @dev Bit mask 0x04: bridge.value is ignored and finalAmount is forwarded as msg.value. + /// @dev Bit mask 0x04: `finalAmount + bridge.value` is forwarded as msg.value (bridge.value acts as a static addend, e.g. LZ nativeFee). uint256 internal constant BRIDGE_VALUE_FLAG_BIT_MASK = 0x04; /// @dev Bit mask 0x08: splice finalAmount into bridge calldata at the uint16 position packed in flags. From c88af75b1757fffb777367899c9c0f2481a4cb42 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 18 May 2026 17:16:09 +0530 Subject: [PATCH 13/42] test: fix stargate tests --- ...arbUsdcBaseEth.performExecution.postFee.ts | 18 +++++----- ...baseUsdcArbEth.performExecution.postFee.ts | 15 ++++---- .../swapAndBridge.postFee.balanceOf.ts | 24 ++++++------- .../swapAndBridge.postFee.returndata.ts | 25 ++++++-------- .../swapAndBridge.preFee.balanceOf.ts | 22 ++++++------ .../swapAndBridge.preFee.returndata.ts | 23 ++++++------- scripts/e2e/swapBridgeViaArbitrumNative.ts | 2 +- scripts/e2e/swapBridgeViaStargateNative.ts | 34 ++++++++----------- 8 files changed, 77 insertions(+), 86 deletions(-) diff --git a/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts index df2b82e..0ded523 100644 --- a/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts @@ -3,9 +3,9 @@ * Function: performExecution (monolithic) * Fee: postFee — FEE_BPS of estimatedOut ETH deducted after swap * - * BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to Stargate. - * amountLD = minAmountOut - fee - nativeFeeWithBuffer (pre-encoded in calldata). - * StargatePoolNative check: msg.value >= amountLD + nativeFee; satisfied since actual >= min. + * BRIDGE_VALUE_FLAG set: router forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate. + * BRIDGE_AMOUNT_POSITION_FLAG set: router splices finalETH into amountLD at runtime. + * Stargate receives the exact actual post-swap, post-fee ETH as amountLD. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts @@ -39,6 +39,7 @@ import { ZERO_ADDRESS, ZERO_BYTES32, monolithicArgs, + bridgeAmountPositionFlag, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; @@ -142,9 +143,8 @@ async function main() { console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); - // amountLD pre-encoded: minAmountOut - fee - nativeFeeWithBuffer - const amountLD = minAmountOut - feeAmount - nativeFeeWithBuffer; - if (amountLD <= 0n) throw new Error('minAmountOut too small to cover fee + nativeFee'); + // estimatedBridgeAmount is a placeholder; router splices the actual finalETH at runtime + const amountLD = estimatedBridgeAmount; await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); await ensureRouterNativeBalance(signer, ROUTER_ARB); @@ -165,8 +165,8 @@ async function main() { returnDataWordOffset: 0n, }, postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { target: STARGATE_NATIVE_ARB, approvalSpender: ZERO_ADDRESS, value: 0n }, - flags: BRIDGE_VALUE_FLAG, + bridge: { target: STARGATE_NATIVE_ARB, approvalSpender: ZERO_ADDRESS, value: nativeFeeWithBuffer }, + flags: BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), }, swapCallData: swapData, bridgeCallData: stargateData, @@ -184,8 +184,6 @@ async function main() { ); console.log('\nETH arrives on Base once LZ delivers the message.'); - - void STARGATE_AMOUNT_LD_OFFSET; } main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts index ac1647b..081a259 100644 --- a/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts +++ b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts @@ -3,8 +3,9 @@ * Function: performExecution (monolithic) * Fee: postFee — FEE_BPS of estimatedOut ETH deducted after swap * - * BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to Stargate. - * amountLD = minAmountOut - fee - nativeFeeWithBuffer (pre-encoded in calldata). + * BRIDGE_VALUE_FLAG set: router forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate. + * BRIDGE_AMOUNT_POSITION_FLAG set: router splices finalETH into amountLD at runtime. + * Stargate receives the exact actual post-swap, post-fee ETH as amountLD. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts @@ -26,6 +27,7 @@ import { NATIVE_TOKEN_ADDRESS, STARGATE_NATIVE_BASE, ARBITRUM_LZ_EID, + STARGATE_AMOUNT_LD_OFFSET, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; import { getWalletErc20Balance } from '../utils/erc20'; @@ -37,6 +39,7 @@ import { ZERO_ADDRESS, ZERO_BYTES32, monolithicArgs, + bridgeAmountPositionFlag, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; @@ -138,8 +141,8 @@ async function main() { console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); - const amountLD = minAmountOut - feeAmount - nativeFeeWithBuffer; - if (amountLD <= 0n) throw new Error('minAmountOut too small to cover fee + nativeFee'); + // estimatedBridgeAmount is a placeholder; router splices the actual finalETH at runtime + const amountLD = estimatedBridgeAmount; await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); await ensureRouterNativeBalance(signer, ROUTER_BASE); @@ -160,8 +163,8 @@ async function main() { returnDataWordOffset: 0n, }, postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { target: STARGATE_NATIVE_BASE, approvalSpender: ZERO_ADDRESS, value: 0n }, - flags: BRIDGE_VALUE_FLAG, + bridge: { target: STARGATE_NATIVE_BASE, approvalSpender: ZERO_ADDRESS, value: nativeFeeWithBuffer }, + flags: BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), }, swapCallData: swapData, bridgeCallData: stargateData, diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts index f65ba2f..e9c8e90 100644 --- a/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts +++ b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts @@ -1,13 +1,13 @@ /** * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) * Flags: post-fee (fee taken from ETH output after swap), output measured as ETH balanceOf delta - * bridge-value flag: router forwards finalETH as msg.value to Stargate + * bridge-value + bridge-amount-position flags: router splices finalETH into amountLD and + * forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate * * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut ETH, deducted from swap output. * BalanceOf (bit1=1): final ETH amount is measured as router ETH balance change (not returndata). - * BridgeValue (bit2=1): router forwards finalETH as msg.value to Stargate send(). - * - * amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer (conservative floor). + * BridgeValue (bit2=1): router forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate. + * BridgeAmountPosition (bit3=1): router splices finalETH into amountLD at STARGATE_AMOUNT_LD_OFFSET. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts @@ -40,7 +40,9 @@ import { ZERO_BYTES32, BRIDGE_VALUE_FLAG, ZERO_ADDRESS, + bridgeAmountPositionFlag, } from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -48,8 +50,8 @@ import { ensureRouterApproval, } from "../utils/reproducibility"; -// post-fee (0x01) | balance-of (0x02) | bridge-value (0x04): forward finalETH as msg.value -const FLAGS = 0x03n | BRIDGE_VALUE_FLAG; +// post-fee (0x01) | balance-of (0x02) | bridge-value (0x04) | bridge-amount-position (0x08 + offset) +const FLAGS = 0x03n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); const STARGATE_ABI = [ @@ -194,12 +196,8 @@ async function main() { console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); - // amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer (conservative floor) - const amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer; - if (amountLD <= 0n) - throw new Error( - "estimatedOut too small to cover fee + nativeFeeWithBuffer" - ); + // bridgeEstimate is a placeholder for amountLD; router splices the actual finalETH at runtime + const amountLD = bridgeEstimate; await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); await ensureRouterNativeBalance(signer, ROUTER_ARB); @@ -232,7 +230,7 @@ async function main() { { target: STARGATE_NATIVE_ARB, approvalSpender: ZERO_ADDRESS, - value: 0n, + value: nativeFeeWithBuffer, }, stargateData, ]); diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts index fc7d448..6218e6f 100644 --- a/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts +++ b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts @@ -1,14 +1,13 @@ /** * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) * Flags: post-fee (fee taken from ETH output after swap), output read from swap returndata word 0 - * bridge-value flag: router forwards finalETH as msg.value to Stargate + * bridge-value + bridge-amount-position flags: router splices finalETH into amountLD and + * forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate * * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut ETH, deducted from swap output. * Returndata (bit1=0): final ETH amount is read from word 0 of the swap call returndata. - * BridgeValue (bit2=1): router forwards finalETH as msg.value to Stargate send(). - * - * amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer (conservative floor). - * StargatePoolNative checks msg.value >= amountLD + nativeFee; satisfied since actual >= minAmountOut. + * BridgeValue (bit2=1): router forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate. + * BridgeAmountPosition (bit3=1): router splices finalETH into amountLD at STARGATE_AMOUNT_LD_OFFSET. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts @@ -41,7 +40,9 @@ import { ZERO_BYTES32, BRIDGE_VALUE_FLAG, ZERO_ADDRESS, + bridgeAmountPositionFlag, } from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -49,8 +50,8 @@ import { ensureRouterApproval, } from "../utils/reproducibility"; -// post-fee (0x01) | bridge-value (0x04): forward finalETH as msg.value -const FLAGS = 0x01n | BRIDGE_VALUE_FLAG; +// post-fee (0x01) | bridge-value (0x04) | bridge-amount-position (0x08 + offset): splice finalETH into amountLD + forward with nativeFee +const FLAGS = 0x01n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); const STARGATE_ABI = [ @@ -196,12 +197,8 @@ async function main() { console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); - // amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer (conservative floor) - const amountLD = estimatedOut - feeAmount - nativeFeeWithBuffer; - if (amountLD <= 0n) - throw new Error( - "estimatedOut too small to cover fee + nativeFeeWithBuffer" - ); + // bridgeEstimate is a placeholder for amountLD; router splices the actual finalETH at runtime + const amountLD = bridgeEstimate; await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); await ensureRouterNativeBalance(signer, ROUTER_ARB); @@ -234,7 +231,7 @@ async function main() { { target: STARGATE_NATIVE_ARB, approvalSpender: ZERO_ADDRESS, - value: 0n, + value: nativeFeeWithBuffer, }, stargateData, ]); diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts index 069fd53..789b18b 100644 --- a/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts +++ b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts @@ -1,13 +1,13 @@ /** * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) * Flags: pre-fee (fee taken from USDC input before swap), output measured as ETH balanceOf delta - * bridge-value flag: router forwards finalETH as msg.value to Stargate + * bridge-value + bridge-amount-position flags: router splices finalETH into amountLD and + * forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate * * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount USDC, deducted before the swap. * BalanceOf (bit1=1): final ETH amount is measured as router ETH balance change (not returndata). - * BridgeValue (bit2=1): router forwards finalETH as msg.value to Stargate send(). - * - * amountLD = estimatedOut - nativeFeeWithBuffer (conservative floor; actual >= amountLD). + * BridgeValue (bit2=1): router forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate. + * BridgeAmountPosition (bit3=1): router splices finalETH into amountLD at STARGATE_AMOUNT_LD_OFFSET. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts @@ -40,7 +40,9 @@ import { ZERO_BYTES32, BRIDGE_VALUE_FLAG, ZERO_ADDRESS, + bridgeAmountPositionFlag, } from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -48,8 +50,8 @@ import { ensureRouterApproval, } from "../utils/reproducibility"; -// pre-fee (0x00) | balance-of (0x02) | bridge-value (0x04): forward finalETH as msg.value -const FLAGS = 0x02n | BRIDGE_VALUE_FLAG; +// pre-fee (0x00) | balance-of (0x02) | bridge-value (0x04) | bridge-amount-position (0x08 + offset) +const FLAGS = 0x02n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); const STARGATE_ABI = [ @@ -194,10 +196,8 @@ async function main() { console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); - // amountLD = estimatedOut - nativeFeeWithBuffer (conservative floor) - const amountLD = estimatedOut - nativeFeeWithBuffer; - if (amountLD <= 0n) - throw new Error("estimatedOut too small to cover nativeFeeWithBuffer"); + // estimatedOut is a placeholder for amountLD; router splices the actual finalETH at runtime + const amountLD = estimatedOut; await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); await ensureRouterNativeBalance(signer, ROUTER_ARB); @@ -230,7 +230,7 @@ async function main() { { target: STARGATE_NATIVE_ARB, approvalSpender: ZERO_ADDRESS, - value: 0n, + value: nativeFeeWithBuffer, }, stargateData, ]); diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts index c897ee8..c412fdb 100644 --- a/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts +++ b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts @@ -1,14 +1,13 @@ /** * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) * Flags: pre-fee (fee taken from USDC input before swap), output read from swap returndata word 0 - * bridge-value flag: router forwards finalETH as msg.value to Stargate + * bridge-value + bridge-amount-position flags: router splices finalETH into amountLD and + * forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate * * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount USDC, deducted before the swap. * Returndata (bit1=0): final ETH amount is read from word 0 of the swap call returndata. - * BridgeValue (bit2=1): router forwards finalETH as msg.value to Stargate send(). - * - * amountLD is pre-encoded conservatively as estimatedOut - nativeFeeWithBuffer. - * StargatePoolNative checks msg.value >= amountLD + nativeFee; satisfied since actual >= amountLD + buffer. + * BridgeValue (bit2=1): router forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate. + * BridgeAmountPosition (bit3=1): router splices finalETH into amountLD at STARGATE_AMOUNT_LD_OFFSET. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts @@ -41,7 +40,9 @@ import { ZERO_BYTES32, BRIDGE_VALUE_FLAG, ZERO_ADDRESS, + bridgeAmountPositionFlag, } from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -49,8 +50,8 @@ import { ensureRouterApproval, } from "../utils/reproducibility"; -// pre-fee (0x00) | bridge-value (0x04): forward finalETH as msg.value -const FLAGS = BRIDGE_VALUE_FLAG; +// pre-fee (0x00) | bridge-value (0x04) | bridge-amount-position (0x08 + offset): splice finalETH into amountLD + forward with nativeFee +const FLAGS = BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); const STARGATE_ABI = [ @@ -195,10 +196,8 @@ async function main() { console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); - // amountLD = estimatedOut - nativeFeeWithBuffer (conservative floor for pre-encoded calldata) - const amountLD = estimatedOut - nativeFeeWithBuffer; - if (amountLD <= 0n) - throw new Error("estimatedOut too small to cover nativeFeeWithBuffer"); + // estimatedOut is a placeholder for amountLD; router splices the actual finalETH at runtime + const amountLD = estimatedOut; await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); await ensureRouterNativeBalance(signer, ROUTER_ARB); @@ -231,7 +230,7 @@ async function main() { { target: STARGATE_NATIVE_ARB, approvalSpender: ZERO_ADDRESS, - value: 0n, + value: nativeFeeWithBuffer, }, stargateData, ]); diff --git a/scripts/e2e/swapBridgeViaArbitrumNative.ts b/scripts/e2e/swapBridgeViaArbitrumNative.ts index 0b0236c..1dce431 100644 --- a/scripts/e2e/swapBridgeViaArbitrumNative.ts +++ b/scripts/e2e/swapBridgeViaArbitrumNative.ts @@ -239,7 +239,7 @@ function buildMonolithic( bridge: { target: ARBITRUM_INBOX, approvalSpender: ZERO_ADDRESS, - value: 0n, // ignored when BRIDGE_VALUE_FLAG is set + value: 0n, // no addend: bridgeValue = finalETH + 0 = finalETH }, flags: BRIDGE_VALUE_FLAG, }, diff --git a/scripts/e2e/swapBridgeViaStargateNative.ts b/scripts/e2e/swapBridgeViaStargateNative.ts index 54961c5..2aa3aea 100644 --- a/scripts/e2e/swapBridgeViaStargateNative.ts +++ b/scripts/e2e/swapBridgeViaStargateNative.ts @@ -9,10 +9,10 @@ * * Native-pool mechanics (cases 1 & 3): * send() requires msg.value >= amountLD + nativeFee (StargatePoolNative._assertMessagingFee). - * Monolithic: BRIDGE_VALUE_FLAG set (router forwards actualFinalAmount as msg.value). - * amountLD = minAmountOut - fee - nativeFeeWithBuffer; no splice flag. - * Since actual >= min (OO slippage), msg.value >= amountLD + nativeFeeWithBuffer ✓ - * Modular: amountLD = minAmountOut - fee - nativeFeeWithBuffer (same). + * Monolithic: BRIDGE_VALUE_FLAG + BRIDGE_AMOUNT_POSITION_FLAG set. + * Router splices finalETH into amountLD at runtime; msg.value = finalETH + nativeFeeWithBuffer. + * finalETH + nativeFeeWithBuffer >= finalETH + nativeFee ✓; destination gets exact finalETH. + * Modular: amountLD = minAmountOut - fee - nativeFeeWithBuffer (static; no splice available). * nativeCall Stargate with value = amountLD + nativeFeeWithBuffer = minAmountOut - fee. * * ERC20-pool mechanics (case 2): @@ -484,9 +484,10 @@ function buildStargateCalldata( /** * Monolithic for native-pool cases (cases 1 & 3): * - OO swap input token → native ETH - * - BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to Stargate - * - amountLD = minAmountOut - fee - nativeFeeWithBuffer; pre-encoded; no splice flag needed - * - StargatePoolNative checks msg.value >= amountLD + nativeFee; satisfied since actual >= min + * - BRIDGE_VALUE_FLAG + BRIDGE_AMOUNT_POSITION_FLAG: router splices finalETH into amountLD at + * runtime and forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate + * - StargatePoolNative checks msg.value >= amountLD + nativeFee; satisfied since + * finalETH + nativeFeeWithBuffer >= finalETH + nativeFee ✓ */ function buildNativePoolMonolithic( signer: string, @@ -497,6 +498,7 @@ function buildNativePoolMonolithic( ooRouter: string, swapData: string, stargateData: string, + nativeFeeWithBuffer: bigint, ): MonolithicExecutionCall { return { exec: { @@ -514,9 +516,9 @@ function buildNativePoolMonolithic( bridge: { target: cfg.bridgeContract, approvalSpender: ZERO_ADDRESS, // no ERC20 approval for native ETH - value: 0n, // ignored when BRIDGE_VALUE_FLAG is set + value: nativeFeeWithBuffer, // added to finalETH as msg.value by BRIDGE_VALUE_FLAG }, - flags: BRIDGE_VALUE_FLAG, + flags: BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), }, swapCallData: swapData, bridgeCallData: stargateData, @@ -944,15 +946,9 @@ async function executeLeg( } // ──────────────────────────────────────────────────────────────────────────── - let amountLD: bigint; - if (cfg.isNativePool) { - amountLD = minAmountOut - feeAmount - nativeFeeWithBuffer; - if (amountLD <= 0n) { - throw new Error(`${cfg.name}: minAmountOut too small to cover fee + nativeFee.`); - } - } else { - amountLD = 0n; - } + // Native pool: use estimatedBridgeAmount as placeholder; router splices actual finalETH at runtime. + // ERC20 pool: 0n placeholder; router splices actual post-fee balance at runtime. + const amountLD = cfg.isNativePool ? estimatedBridgeAmount : 0n; const stargateData = buildStargateCalldata(cfg.destLzEid, nativeFeeWithBuffer, signerAddress, amountLD, cfg.lzExtraOptions); @@ -980,7 +976,7 @@ async function executeLeg( if (cfg.isNativePool) { mono = buildNativePoolMonolithic( signerAddress, cfg, inputAmountWei, feeAmount, minAmountOut, - ooRouter, swapData, stargateData, + ooRouter, swapData, stargateData, nativeFeeWithBuffer, ); } else if (cfg.isNativeInput) { mono = buildNativeInErc20BridgeMonolithic( From 29ab09904b8ad9c5a6da127f736fe81a7300f161 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 18 May 2026 17:58:34 +0530 Subject: [PATCH 14/42] fix: stargate tests --- ...arbUsdcBaseEth.performExecution.postFee.ts | 2 +- ...BaseEth.performModularExecution.postFee.ts | 2 +- ...baseUsdcArbEth.performExecution.postFee.ts | 2 +- ...cArbEth.performModularExecution.postFee.ts | 2 +- ...olygonUsdcBase.performExecution.postFee.ts | 2 +- ...sdcBase.performModularExecution.postFee.ts | 2 +- .../swapAndBridge.postFee.balanceOf.ts | 58 +++++++++---------- .../swapAndBridge.postFee.returndata.ts | 58 +++++++++---------- .../swapAndBridge.preFee.balanceOf.ts | 58 +++++++++---------- .../swapAndBridge.preFee.returndata.ts | 58 +++++++++---------- 10 files changed, 122 insertions(+), 122 deletions(-) diff --git a/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts index 0ded523..198a451 100644 --- a/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts @@ -175,7 +175,7 @@ async function main() { const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); - const receipt = await execViaAH(signer, ROUTER_ARB, TOKENS.USDC_ARB, inputAmount, ROUTER_ARB, callData); + const receipt = await execViaAH(signer, ROUTER_ARB, TOKENS.USDC_ARB, inputAmount, ROUTER_ARB, callData, nativeFeeWithBuffer); logTxnSummary( 'Arbitrum USDC → ETH (OO) → Base ETH (Stargate native) — performExecution postFee', diff --git a/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts b/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts index 3f301dd..49c6a92 100644 --- a/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts @@ -164,7 +164,7 @@ async function main() { const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); - const receipt = await execViaAH(signer, ROUTER_ARB, TOKENS.USDC_ARB, inputAmount, ROUTER_ARB, callData); + const receipt = await execViaAH(signer, ROUTER_ARB, TOKENS.USDC_ARB, inputAmount, ROUTER_ARB, callData, nativeFeeWithBuffer); logTxnSummary( 'Arbitrum USDC → ETH (OO) → Base ETH (Stargate native) — performModularExecution postFee', diff --git a/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts index 081a259..fa2f89d 100644 --- a/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts +++ b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts @@ -173,7 +173,7 @@ async function main() { const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); - const receipt = await execViaAH(signer, ROUTER_BASE, TOKENS.USDC_BASE, inputAmount, ROUTER_BASE, callData); + const receipt = await execViaAH(signer, ROUTER_BASE, TOKENS.USDC_BASE, inputAmount, ROUTER_BASE, callData, nativeFeeWithBuffer); logTxnSummary( 'Base USDC → ETH (OO) → Arbitrum ETH (Stargate native) — performExecution postFee', diff --git a/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts b/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts index 7eb6f04..d26740e 100644 --- a/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts @@ -160,7 +160,7 @@ async function main() { const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); - const receipt = await execViaAH(signer, ROUTER_BASE, TOKENS.USDC_BASE, inputAmount, ROUTER_BASE, callData); + const receipt = await execViaAH(signer, ROUTER_BASE, TOKENS.USDC_BASE, inputAmount, ROUTER_BASE, callData, nativeFeeWithBuffer); logTxnSummary( 'Base USDC → ETH (OO) → Arbitrum ETH (Stargate native) — performModularExecution postFee', diff --git a/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts index f1de77b..50c0083 100644 --- a/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts @@ -121,7 +121,7 @@ async function main() { const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); - const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); logTxnSummary( 'Polygon USDC → Base USDC (Stargate USDC pool) — performExecution postFee', diff --git a/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts index 87dd89b..7ecf31e 100644 --- a/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts @@ -119,7 +119,7 @@ async function main() { const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); - const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData); + const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); logTxnSummary( 'Polygon USDC → Base USDC (Stargate USDC pool) — performModularExecution postFee', diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts index e9c8e90..46c549f 100644 --- a/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts +++ b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts @@ -1,5 +1,5 @@ /** - * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) + * Route: Base USDC → native ETH (OpenOcean) → Arbitrum ETH (Stargate Native Pool on Base, LayerZero v2) * Flags: post-fee (fee taken from ETH output after swap), output measured as ETH balanceOf delta * bridge-value + bridge-amount-position flags: router splices finalETH into amountLD and * forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate @@ -27,8 +27,8 @@ import { RPC, OPEN_OCEAN_API_KEY, OO_SLIPPAGE_PERCENT, - STARGATE_NATIVE_ARB, - BASE_LZ_EID, + STARGATE_NATIVE_BASE, + ARBITRUM_LZ_EID, } from "../config"; import { execViaAH, @@ -52,7 +52,7 @@ import { // post-fee (0x01) | balance-of (0x02) | bridge-value (0x04) | bridge-amount-position (0x08 + offset) const FLAGS = 0x03n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); -const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); +const ROUTER_BASE = routerAddressForChain(CHAIN_IDS.BASE); const STARGATE_ABI = [ "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", @@ -73,16 +73,16 @@ async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ minAmountOut: bigint; }> { const params: Record = { - inTokenAddress: TOKENS.USDC_ARB, + inTokenAddress: TOKENS.USDC_BASE, outTokenAddress: NATIVE_TOKEN_ADDRESS, amount: ethers.formatUnits(inputAmount, 6), slippage: OO_SLIPPAGE_PERCENT, - sender: ROUTER_ARB, - account: ROUTER_ARB, + sender: ROUTER_BASE, + account: ROUTER_BASE, gasPrice: "1", }; if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; - const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.BASE}/swap_quote`; const response = await axios.get(url, { params }); const q = response.data.data; return { @@ -99,13 +99,13 @@ async function fetchStargateQuote( recipient: string ): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { const contract = new ethers.Contract( - STARGATE_NATIVE_ARB, + STARGATE_NATIVE_BASE, STARGATE_ABI, provider ); const to32 = ethers.zeroPadValue(recipient, 32); const sendParam = { - dstEid: BASE_LZ_EID, + dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, @@ -130,7 +130,7 @@ function buildStargateCalldata( ): string { return STARGATE_IFACE.encodeFunctionData("send", [ { - dstEid: BASE_LZ_EID, + dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, @@ -147,23 +147,23 @@ async function main() { const privateKey = process.env.PRIVATE_KEY; if (!privateKey) throw new Error("PRIVATE_KEY env var required"); - const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const provider = new ethers.JsonRpcProvider(RPC.BASE); const signer = new ethers.Wallet(privateKey, provider); const signerAddress = await signer.getAddress(); const { balance: walletBalance } = await getWalletErc20Balance( - TOKENS.USDC_ARB, + TOKENS.USDC_BASE, signerAddress, provider ); if (walletBalance === 0n) - throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + throw new Error(`Signer ${signerAddress} has zero USDC on Base`); const inputAmount = walletBalance - 20n; if (inputAmount === 0n) throw new Error("Balance too small"); console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_ARB}`); + console.log(`Router: ${ROUTER_BASE}`); console.log( `Flags: 0x${FLAGS.toString(16)} (post-fee, balanceOf, bridge-value)` ); @@ -186,7 +186,7 @@ async function main() { console.log(` Bridge est: ${ethers.formatEther(bridgeEstimate)}`); console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); - console.log("Fetching Stargate quote..."); + console.log("Fetching Stargate quote (Base native pool → Arbitrum)..."); const { nativeFee, amountReceivedLD } = await fetchStargateQuote( provider, bridgeEstimate, @@ -199,9 +199,9 @@ async function main() { // bridgeEstimate is a placeholder for amountLD; router splices the actual finalETH at runtime const amountLD = bridgeEstimate; - await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); - await ensureRouterNativeBalance(signer, ROUTER_ARB); - await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); + await ensureRouterNativeBalance(signer, ROUTER_BASE); + await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); const stargateData = buildStargateCalldata( nativeFeeWithBuffer, @@ -213,7 +213,7 @@ async function main() { ZERO_BYTES32, { user: signerAddress, - inputToken: TOKENS.USDC_ARB, + inputToken: TOKENS.USDC_BASE, inputAmount: inputAmount, }, FLAGS, @@ -228,31 +228,31 @@ async function main() { }, swapData, { - target: STARGATE_NATIVE_ARB, + target: STARGATE_NATIVE_BASE, approvalSpender: ZERO_ADDRESS, value: nativeFeeWithBuffer, }, stargateData, ]); - await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); const receipt = await execViaAH( signer, - ROUTER_ARB, - TOKENS.USDC_ARB, + ROUTER_BASE, + TOKENS.USDC_BASE, inputAmount, - ROUTER_ARB, + ROUTER_BASE, callData, - 0n + nativeFeeWithBuffer ); logTxnSummary( - `Arbitrum USDC → Base ETH (Stargate) — swapAndBridge postFee/balanceOf`, - CHAIN_IDS.ARBITRUM, + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge postFee/balanceOf`, + CHAIN_IDS.BASE, receipt ); - console.log("\nETH arrives on Base once LZ delivers the message."); + console.log("\nETH arrives on Arbitrum once LZ delivers the message."); } main().catch((err) => { diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts index 6218e6f..6ccdd7e 100644 --- a/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts +++ b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts @@ -1,5 +1,5 @@ /** - * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) + * Route: Base USDC → native ETH (OpenOcean) → Arbitrum ETH (Stargate Native Pool on Base, LayerZero v2) * Flags: post-fee (fee taken from ETH output after swap), output read from swap returndata word 0 * bridge-value + bridge-amount-position flags: router splices finalETH into amountLD and * forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate @@ -27,8 +27,8 @@ import { RPC, OPEN_OCEAN_API_KEY, OO_SLIPPAGE_PERCENT, - STARGATE_NATIVE_ARB, - BASE_LZ_EID, + STARGATE_NATIVE_BASE, + ARBITRUM_LZ_EID, } from "../config"; import { execViaAH, @@ -52,7 +52,7 @@ import { // post-fee (0x01) | bridge-value (0x04) | bridge-amount-position (0x08 + offset): splice finalETH into amountLD + forward with nativeFee const FLAGS = 0x01n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); -const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); +const ROUTER_BASE = routerAddressForChain(CHAIN_IDS.BASE); const STARGATE_ABI = [ "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", @@ -73,16 +73,16 @@ async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ minAmountOut: bigint; }> { const params: Record = { - inTokenAddress: TOKENS.USDC_ARB, + inTokenAddress: TOKENS.USDC_BASE, outTokenAddress: NATIVE_TOKEN_ADDRESS, amount: ethers.formatUnits(inputAmount, 6), slippage: OO_SLIPPAGE_PERCENT, - sender: ROUTER_ARB, - account: ROUTER_ARB, + sender: ROUTER_BASE, + account: ROUTER_BASE, gasPrice: "1", }; if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; - const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.BASE}/swap_quote`; const response = await axios.get(url, { params }); const q = response.data.data; return { @@ -99,13 +99,13 @@ async function fetchStargateQuote( recipient: string ): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { const contract = new ethers.Contract( - STARGATE_NATIVE_ARB, + STARGATE_NATIVE_BASE, STARGATE_ABI, provider ); const to32 = ethers.zeroPadValue(recipient, 32); const sendParam = { - dstEid: BASE_LZ_EID, + dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, @@ -130,7 +130,7 @@ function buildStargateCalldata( ): string { return STARGATE_IFACE.encodeFunctionData("send", [ { - dstEid: BASE_LZ_EID, + dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, @@ -147,23 +147,23 @@ async function main() { const privateKey = process.env.PRIVATE_KEY; if (!privateKey) throw new Error("PRIVATE_KEY env var required"); - const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const provider = new ethers.JsonRpcProvider(RPC.BASE); const signer = new ethers.Wallet(privateKey, provider); const signerAddress = await signer.getAddress(); const { balance: walletBalance } = await getWalletErc20Balance( - TOKENS.USDC_ARB, + TOKENS.USDC_BASE, signerAddress, provider ); if (walletBalance === 0n) - throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + throw new Error(`Signer ${signerAddress} has zero USDC on Base`); const inputAmount = walletBalance - 20n; if (inputAmount === 0n) throw new Error("Balance too small"); console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_ARB}`); + console.log(`Router: ${ROUTER_BASE}`); console.log( `Flags: 0x${FLAGS.toString( 16 @@ -186,7 +186,7 @@ async function main() { ); console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); - console.log("Fetching Stargate quote..."); + console.log("Fetching Stargate quote (Base native pool → Arbitrum)..."); const bridgeEstimate = estimatedOut - feeAmount; const { nativeFee, amountReceivedLD } = await fetchStargateQuote( provider, @@ -200,9 +200,9 @@ async function main() { // bridgeEstimate is a placeholder for amountLD; router splices the actual finalETH at runtime const amountLD = bridgeEstimate; - await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); - await ensureRouterNativeBalance(signer, ROUTER_ARB); - await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); + await ensureRouterNativeBalance(signer, ROUTER_BASE); + await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); const stargateData = buildStargateCalldata( nativeFeeWithBuffer, @@ -214,7 +214,7 @@ async function main() { ZERO_BYTES32, { user: signerAddress, - inputToken: TOKENS.USDC_ARB, + inputToken: TOKENS.USDC_BASE, inputAmount: inputAmount, }, FLAGS, @@ -229,31 +229,31 @@ async function main() { }, swapData, { - target: STARGATE_NATIVE_ARB, + target: STARGATE_NATIVE_BASE, approvalSpender: ZERO_ADDRESS, value: nativeFeeWithBuffer, }, stargateData, ]); - await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); const receipt = await execViaAH( signer, - ROUTER_ARB, - TOKENS.USDC_ARB, + ROUTER_BASE, + TOKENS.USDC_BASE, inputAmount, - ROUTER_ARB, + ROUTER_BASE, callData, - 0n + nativeFeeWithBuffer ); logTxnSummary( - `Arbitrum USDC → Base ETH (Stargate) — swapAndBridge postFee/returndata`, - CHAIN_IDS.ARBITRUM, + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge postFee/returndata`, + CHAIN_IDS.BASE, receipt ); - console.log("\nETH arrives on Base once LZ delivers the message."); + console.log("\nETH arrives on Arbitrum once LZ delivers the message."); } main().catch((err) => { diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts index 789b18b..8addf9b 100644 --- a/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts +++ b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts @@ -1,5 +1,5 @@ /** - * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) + * Route: Base USDC → native ETH (OpenOcean) → Arbitrum ETH (Stargate Native Pool on Base, LayerZero v2) * Flags: pre-fee (fee taken from USDC input before swap), output measured as ETH balanceOf delta * bridge-value + bridge-amount-position flags: router splices finalETH into amountLD and * forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate @@ -27,8 +27,8 @@ import { RPC, OPEN_OCEAN_API_KEY, OO_SLIPPAGE_PERCENT, - STARGATE_NATIVE_ARB, - BASE_LZ_EID, + STARGATE_NATIVE_BASE, + ARBITRUM_LZ_EID, } from "../config"; import { execViaAH, @@ -52,7 +52,7 @@ import { // pre-fee (0x00) | balance-of (0x02) | bridge-value (0x04) | bridge-amount-position (0x08 + offset) const FLAGS = 0x02n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); -const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); +const ROUTER_BASE = routerAddressForChain(CHAIN_IDS.BASE); const STARGATE_ABI = [ "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", @@ -73,16 +73,16 @@ async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ minAmountOut: bigint; }> { const params: Record = { - inTokenAddress: TOKENS.USDC_ARB, + inTokenAddress: TOKENS.USDC_BASE, outTokenAddress: NATIVE_TOKEN_ADDRESS, amount: ethers.formatUnits(inputAmount, 6), slippage: OO_SLIPPAGE_PERCENT, - sender: ROUTER_ARB, - account: ROUTER_ARB, + sender: ROUTER_BASE, + account: ROUTER_BASE, gasPrice: "1", }; if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; - const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.BASE}/swap_quote`; const response = await axios.get(url, { params }); const q = response.data.data; return { @@ -99,13 +99,13 @@ async function fetchStargateQuote( recipient: string ): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { const contract = new ethers.Contract( - STARGATE_NATIVE_ARB, + STARGATE_NATIVE_BASE, STARGATE_ABI, provider ); const to32 = ethers.zeroPadValue(recipient, 32); const sendParam = { - dstEid: BASE_LZ_EID, + dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, @@ -130,7 +130,7 @@ function buildStargateCalldata( ): string { return STARGATE_IFACE.encodeFunctionData("send", [ { - dstEid: BASE_LZ_EID, + dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, @@ -147,23 +147,23 @@ async function main() { const privateKey = process.env.PRIVATE_KEY; if (!privateKey) throw new Error("PRIVATE_KEY env var required"); - const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const provider = new ethers.JsonRpcProvider(RPC.BASE); const signer = new ethers.Wallet(privateKey, provider); const signerAddress = await signer.getAddress(); const { balance: walletBalance } = await getWalletErc20Balance( - TOKENS.USDC_ARB, + TOKENS.USDC_BASE, signerAddress, provider ); if (walletBalance === 0n) - throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + throw new Error(`Signer ${signerAddress} has zero USDC on Base`); const inputAmount = walletBalance - 20n; if (inputAmount === 0n) throw new Error("Balance too small"); console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_ARB}`); + console.log(`Router: ${ROUTER_BASE}`); console.log( `Flags: 0x${FLAGS.toString(16)} (pre-fee, balanceOf, bridge-value)` ); @@ -186,7 +186,7 @@ async function main() { console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); - console.log("Fetching Stargate quote..."); + console.log("Fetching Stargate quote (Base native pool → Arbitrum)..."); const { nativeFee, amountReceivedLD } = await fetchStargateQuote( provider, estimatedOut, @@ -199,9 +199,9 @@ async function main() { // estimatedOut is a placeholder for amountLD; router splices the actual finalETH at runtime const amountLD = estimatedOut; - await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); - await ensureRouterNativeBalance(signer, ROUTER_ARB); - await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); + await ensureRouterNativeBalance(signer, ROUTER_BASE); + await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); const stargateData = buildStargateCalldata( nativeFeeWithBuffer, @@ -213,7 +213,7 @@ async function main() { ZERO_BYTES32, { user: signerAddress, - inputToken: TOKENS.USDC_ARB, + inputToken: TOKENS.USDC_BASE, inputAmount: inputAmount, }, FLAGS, @@ -228,31 +228,31 @@ async function main() { }, swapData, { - target: STARGATE_NATIVE_ARB, + target: STARGATE_NATIVE_BASE, approvalSpender: ZERO_ADDRESS, value: nativeFeeWithBuffer, }, stargateData, ]); - await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); const receipt = await execViaAH( signer, - ROUTER_ARB, - TOKENS.USDC_ARB, + ROUTER_BASE, + TOKENS.USDC_BASE, inputAmount, - ROUTER_ARB, + ROUTER_BASE, callData, - 0n + nativeFeeWithBuffer ); logTxnSummary( - `Arbitrum USDC → Base ETH (Stargate) — swapAndBridge preFee/balanceOf`, - CHAIN_IDS.ARBITRUM, + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge preFee/balanceOf`, + CHAIN_IDS.BASE, receipt ); - console.log("\nETH arrives on Base once LZ delivers the message."); + console.log("\nETH arrives on Arbitrum once LZ delivers the message."); } main().catch((err) => { diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts index c412fdb..0a2ee3a 100644 --- a/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts +++ b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts @@ -1,5 +1,5 @@ /** - * Route: Arbitrum USDC → native ETH (OpenOcean) → Base ETH (Stargate Native Pool, LayerZero v2) + * Route: Base USDC → native ETH (OpenOcean) → Arbitrum ETH (Stargate Native Pool on Base, LayerZero v2) * Flags: pre-fee (fee taken from USDC input before swap), output read from swap returndata word 0 * bridge-value + bridge-amount-position flags: router splices finalETH into amountLD and * forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate @@ -27,8 +27,8 @@ import { RPC, OPEN_OCEAN_API_KEY, OO_SLIPPAGE_PERCENT, - STARGATE_NATIVE_ARB, - BASE_LZ_EID, + STARGATE_NATIVE_BASE, + ARBITRUM_LZ_EID, } from "../config"; import { execViaAH, @@ -52,7 +52,7 @@ import { // pre-fee (0x00) | bridge-value (0x04) | bridge-amount-position (0x08 + offset): splice finalETH into amountLD + forward with nativeFee const FLAGS = BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); -const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); +const ROUTER_BASE = routerAddressForChain(CHAIN_IDS.BASE); const STARGATE_ABI = [ "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", @@ -73,16 +73,16 @@ async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ minAmountOut: bigint; }> { const params: Record = { - inTokenAddress: TOKENS.USDC_ARB, + inTokenAddress: TOKENS.USDC_BASE, outTokenAddress: NATIVE_TOKEN_ADDRESS, amount: ethers.formatUnits(inputAmount, 6), slippage: OO_SLIPPAGE_PERCENT, - sender: ROUTER_ARB, - account: ROUTER_ARB, + sender: ROUTER_BASE, + account: ROUTER_BASE, gasPrice: "1", }; if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; - const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ARBITRUM}/swap_quote`; + const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.BASE}/swap_quote`; const response = await axios.get(url, { params }); const q = response.data.data; return { @@ -99,13 +99,13 @@ async function fetchStargateQuote( recipient: string ): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { const contract = new ethers.Contract( - STARGATE_NATIVE_ARB, + STARGATE_NATIVE_BASE, STARGATE_ABI, provider ); const to32 = ethers.zeroPadValue(recipient, 32); const sendParam = { - dstEid: BASE_LZ_EID, + dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, @@ -130,7 +130,7 @@ function buildStargateCalldata( ): string { return STARGATE_IFACE.encodeFunctionData("send", [ { - dstEid: BASE_LZ_EID, + dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, @@ -147,23 +147,23 @@ async function main() { const privateKey = process.env.PRIVATE_KEY; if (!privateKey) throw new Error("PRIVATE_KEY env var required"); - const provider = new ethers.JsonRpcProvider(RPC.ARBITRUM); + const provider = new ethers.JsonRpcProvider(RPC.BASE); const signer = new ethers.Wallet(privateKey, provider); const signerAddress = await signer.getAddress(); const { balance: walletBalance } = await getWalletErc20Balance( - TOKENS.USDC_ARB, + TOKENS.USDC_BASE, signerAddress, provider ); if (walletBalance === 0n) - throw new Error(`Signer ${signerAddress} has zero USDC on Arbitrum`); + throw new Error(`Signer ${signerAddress} has zero USDC on Base`); const inputAmount = walletBalance - 20n; if (inputAmount === 0n) throw new Error("Balance too small"); console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_ARB}`); + console.log(`Router: ${ROUTER_BASE}`); console.log( `Flags: 0x${FLAGS.toString(16)} (pre-fee, returndata, bridge-value)` ); @@ -186,7 +186,7 @@ async function main() { console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); - console.log("Fetching Stargate quote..."); + console.log("Fetching Stargate quote (Base native pool → Arbitrum)..."); const { nativeFee, amountReceivedLD } = await fetchStargateQuote( provider, estimatedOut, @@ -199,9 +199,9 @@ async function main() { // estimatedOut is a placeholder for amountLD; router splices the actual finalETH at runtime const amountLD = estimatedOut; - await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); - await ensureRouterNativeBalance(signer, ROUTER_ARB); - await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); + await ensureRouterNativeBalance(signer, ROUTER_BASE); + await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); const stargateData = buildStargateCalldata( nativeFeeWithBuffer, @@ -213,7 +213,7 @@ async function main() { ZERO_BYTES32, { user: signerAddress, - inputToken: TOKENS.USDC_ARB, + inputToken: TOKENS.USDC_BASE, inputAmount: inputAmount, }, FLAGS, @@ -228,31 +228,31 @@ async function main() { }, swapData, { - target: STARGATE_NATIVE_ARB, + target: STARGATE_NATIVE_BASE, approvalSpender: ZERO_ADDRESS, value: nativeFeeWithBuffer, }, stargateData, ]); - await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); + await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); const receipt = await execViaAH( signer, - ROUTER_ARB, - TOKENS.USDC_ARB, + ROUTER_BASE, + TOKENS.USDC_BASE, inputAmount, - ROUTER_ARB, + ROUTER_BASE, callData, - 0n + nativeFeeWithBuffer ); logTxnSummary( - `Arbitrum USDC → Base ETH (Stargate) — swapAndBridge preFee/returndata`, - CHAIN_IDS.ARBITRUM, + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge preFee/returndata`, + CHAIN_IDS.BASE, receipt ); - console.log("\nETH arrives on Base once LZ delivers the message."); + console.log("\nETH arrives on Arbitrum once LZ delivers the message."); } main().catch((err) => { From 362b8be7293e113633e71a202a43a06104ff9ac2 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 18 May 2026 20:20:49 +0530 Subject: [PATCH 15/42] feat: kyberswap, 0x swap scripts, fix swap scripts --- ...pAndBridge.postFee.returndata.kyberswap.ts | 273 ++++++++++++++++++ scripts/e2e/config.ts | 13 +- scripts/e2e/routerUsdc.withdraw.modular.ts | 103 +++++++ .../e2e/swap/kyberswap.postFee.balanceOf.ts | 232 +++++++++++++++ .../e2e/swap/kyberswap.postFee.returndata.ts | 227 +++++++++++++++ .../e2e/swap/kyberswap.preFee.balanceOf.ts | 238 +++++++++++++++ .../e2e/swap/kyberswap.preFee.returndata.ts | 234 +++++++++++++++ scripts/e2e/swap/swap.postFee.balanceOf.ts | 1 + scripts/e2e/swap/swap.postFee.returndata.ts | 1 + scripts/e2e/swap/swap.preFee.balanceOf.ts | 1 + scripts/e2e/swap/swap.preFee.returndata.ts | 1 + scripts/e2e/swap/zerox.postFee.balanceOf.ts | 228 +++++++++++++++ scripts/e2e/swap/zerox.postFee.returndata.ts | 217 ++++++++++++++ scripts/e2e/swap/zerox.preFee.balanceOf.ts | 235 +++++++++++++++ scripts/e2e/swap/zerox.preFee.returndata.ts | 227 +++++++++++++++ scripts/e2e/utils/routerAbi.ts | 1 + 16 files changed, 2230 insertions(+), 2 deletions(-) create mode 100644 scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts create mode 100644 scripts/e2e/routerUsdc.withdraw.modular.ts create mode 100644 scripts/e2e/swap/kyberswap.postFee.balanceOf.ts create mode 100644 scripts/e2e/swap/kyberswap.postFee.returndata.ts create mode 100644 scripts/e2e/swap/kyberswap.preFee.balanceOf.ts create mode 100644 scripts/e2e/swap/kyberswap.preFee.returndata.ts create mode 100644 scripts/e2e/swap/zerox.postFee.balanceOf.ts create mode 100644 scripts/e2e/swap/zerox.postFee.returndata.ts create mode 100644 scripts/e2e/swap/zerox.preFee.balanceOf.ts create mode 100644 scripts/e2e/swap/zerox.preFee.returndata.ts diff --git a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts new file mode 100644 index 0000000..b09b480 --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts @@ -0,0 +1,273 @@ +/** + * Route: Polygon AAVE → USDC (KyberSwap) → Base USDC (CCTP depositForBurn) + * Flags: post-fee (fee taken from USDC output after swap), output read from swap returndata word 0 + * bridge amount spliced into depositForBurn calldata at byte offset 4 (amount param) + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDC, deducted from swap output. + * Returndata (bit1=0): final USDC amount is read from word 0 of the swap call returndata. + * + * Usage: + * PRIVATE_KEY=0x... KYBERSWAP_API_KEY=... ts-node scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + CCTP_CONFIG, + FEE_BPS, + bpsOf, + RPC, + KYBERSWAP_API_KEY, + SWAP_SLIPPAGE_BPS, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | bridge amount at byte offset 4 (depositForBurn amount param) +const FLAGS = 0x01n | bridgeAmountPositionFlag(4); +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const KYBERSWAP_BASE_URL = "https://aggregator-api.kyberswap.com"; +const KYBERSWAP_CHAIN = "polygon"; + +interface KyberRouteData { + routeSummary: object; + routerAddress: string; +} + +interface KyberBuildData { + routerAddress: string; + amountOut: string; + data: string; + transactionValue: string; +} + +/** + * Post-fee Kyber build: sender and recipient are the router so gross USDC stays on-contract + * before fee deduction and CCTP burn (same net shape as the OpenOcean post-fee script). + */ +async function fetchKyberSwapQuote( + amountIn: bigint, + routerAddress: string, +): Promise<{ + ksRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; + value: bigint; +}> { + const headers: Record = {}; + if (KYBERSWAP_API_KEY) { + headers["x-client-id"] = KYBERSWAP_API_KEY; + } + + const routeUrl = + `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/routes` + + `?tokenIn=${TOKENS.AAVE_POLYGON}&tokenOut=${TOKENS.USDC_POLYGON_CIRCLE}` + + `&amountIn=${amountIn.toString()}&excludedSources=bebop&gasInclude=true`; + + const routeResp = await axios.get<{ code: number; data: KyberRouteData }>( + routeUrl, + { headers }, + ); + if (!routeResp.data?.data?.routeSummary) { + throw new Error( + `KyberSwap routes call failed: ${JSON.stringify(routeResp.data)}`, + ); + } + const { routeSummary } = routeResp.data.data; + + const buildUrl = `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/route/build`; + const buildResp = await axios.post<{ code: number; data: KyberBuildData }>( + buildUrl, + { + routeSummary, + sender: routerAddress, + recipient: routerAddress, + slippageTolerance: SWAP_SLIPPAGE_BPS, + }, + { headers }, + ); + if (!buildResp.data?.data?.data) { + throw new Error( + `KyberSwap build call failed: ${JSON.stringify(buildResp.data)}`, + ); + } + + const { routerAddress: ksRouter, amountOut, data, transactionValue } = + buildResp.data.data; + const estimatedOut = BigInt(amountOut); + + return { + ksRouter, + swapData: data, + estimatedOut, + minAmountOut: + (estimatedOut * (10000n - BigInt(SWAP_SLIPPAGE_BPS))) / 10000n, + value: BigInt(transactionValue ?? "0"), + }; +} + +function buildDepositForBurnCalldata( + recipientAddress: string, + burnToken: string, + destinationCctpDomain: number +): string { + const iface = new ethers.Interface([ + "function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external", + ]); + return iface.encodeFunctionData("depositForBurn", [ + 0n, + destinationCctpDomain, + ethers.zeroPadValue(recipientAddress, 32), + burnToken, + ethers.ZeroHash, + 1_000_000n, + 1000, + ]); +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + if (!KYBERSWAP_API_KEY) { + console.warn( + "KYBERSWAP_API_KEY not set — unauthenticated requests may be rate-limited", + ); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider, + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (post-fee, returndata, bridge-amount-pos=4)`, + ); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + + console.log("Fetching KyberSwap quote (AAVE → USDC)..."); + const { ksRouter, swapData, estimatedOut, minAmountOut, value } = + await fetchKyberSwapQuote(inputAmount, ROUTER_POLYGON); + + // post-fee: deduct from estimated USDC output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` KS router: ${ksRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`, + ); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + const depositForBurnData = buildDepositForBurnCalldata( + signerAddress, + polyCctp.usdcAddress, + baseCctp.cctpDomain + ); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: ksRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: polyCctp.tokenMessenger, + value: 0n, + }, + depositForBurnData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData + ); + + logTxnSummary( + `Polygon AAVE → Base USDC (CCTP) — swapAndBridge postFee/returndata (Kyber)`, + CHAIN_IDS.POLYGON, + receipt + ); + + console.log( + `\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.` + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/config.ts b/scripts/e2e/config.ts index 6e6518c..a285251 100644 --- a/scripts/e2e/config.ts +++ b/scripts/e2e/config.ts @@ -34,9 +34,9 @@ export const ALLOWANCE_HOLDER = '0x0000000000001fF3684f28c67538d4D072C22734'; * Chains without an entry fall back to legacy `ROUTER_ADDRESS` when set. */ export const ROUTER_BY_CHAIN_ID: Record = { - [CHAIN_IDS.POLYGON]: '0x7A113007177BF1cd86da69Dbd7d601dcEC9EbAbD', + [CHAIN_IDS.POLYGON]: '0x5abf9dccabc44ea9421f1e1Fbd6BA6A4f2387342', [CHAIN_IDS.ARBITRUM]: '0xe1788A0374EF5D4C35e62478FdB35F37CeE5B951', - [CHAIN_IDS.BASE]: '0x96E8c261fCCDFca2CCffe8b4A33dC8a65f153785', + [CHAIN_IDS.BASE]: '0x91b536E79cd3607b593f3011937862609D608253', [CHAIN_IDS.ETHEREUM]: '0xeB5ae85Fe7e3E272Ac77fd316079589C0Ed91648', }; @@ -236,3 +236,12 @@ export const RPC = { export const RELAY_API_KEY: string | undefined = process.env.RELAY_API_KEY; export const OPEN_OCEAN_API_KEY: string | undefined = process.env.OPEN_OCEAN_API_KEY; +export const KYBERSWAP_API_KEY: string | undefined = + process.env.KYBERSWAP_API_KEY; +export const ZEROX_API_KEY: string | undefined = process.env.ZEROX_API_KEY; + +/** + * Swap slippage in basis points for KyberSwap and 0x (300 = 3%). + * Matches the default OO_SLIPPAGE_PERCENT of 3%. + */ +export const SWAP_SLIPPAGE_BPS = 300; diff --git a/scripts/e2e/routerUsdc.withdraw.modular.ts b/scripts/e2e/routerUsdc.withdraw.modular.ts new file mode 100644 index 0000000..210307d --- /dev/null +++ b/scripts/e2e/routerUsdc.withdraw.modular.ts @@ -0,0 +1,103 @@ +/** + * Polygon: sweep USDC from `BungeeOpenRouterV2Unchecked` to the tx sender using + * `performModularExecution` only — no AllowanceHolder, no pull step. + * + * Actions: + * [0] STATICCALL USDC.balanceOf(router) — stored returndata (32-byte uint256) + * [1] CALL USDC.transfer(caller, 0) — amount word spliced from [0], so net effect + * is transferring the router's entire USDC balance to `msg.sender` of this tx. + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/polygon/routerUsdc.withdraw.modular.ts + * + * Requires the router contract to actually hold Polygon USDC + * ({@link TOKENS.USDC_POLYGON_CIRCLE}). + */ +import { ethers } from 'ethers'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { CHAIN_IDS, routerAddressForChain, TOKENS, RPC } from './config'; +import { + encodeBalanceOf, + encodeTransfer, + getWalletErc20Balance, +} from './utils/erc20'; +import { ROUTER_ABI } from './utils/routerAbi'; +import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from './utils/contractTypes'; +import { logTxnSummary } from './utils/txnLogSummary'; + +async function main(): Promise { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } + + const chainId = CHAIN_IDS.POLYGON; + const rpcUrl = process.env.POLYGON_RPC ?? process.env.RPC_URL ?? RPC.POLYGON; + const routerAddress = routerAddressForChain(chainId); + const usdc = TOKENS.USDC_POLYGON_CIRCLE; + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: routerBalance } = await getWalletErc20Balance( + usdc, + routerAddress, + provider, + ); + if (routerBalance === 0n) { + throw new Error(`Router ${routerAddress} holds zero USDC on Polygon`); + } + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${routerAddress}`); + console.log(`Router USDC bal: ${ethers.formatUnits(routerBalance, 6)}`); + + const exec = new ModularActionsBuilder(); + const routerBal = exec.staticCall(usdc, encodeBalanceOf(routerAddress)); + + exec + .call(usdc, encodeTransfer(signerAddress, 0n)) + .spliceArg(1, routerBal.ref().returnWord(0)); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const calldata = routerIface.encodeFunctionData('performModularExecution', [ + ZERO_BYTES32, + exec.toActions(), + ]); + + console.log( + 'Sending performModularExecution (balanceOf → transfer with spliced amount)...', + ); + const tx = await signer.sendTransaction({ + to: routerAddress, + data: calldata, + }); + console.log(`Tx hash: ${tx.hash}`); + const receipt = await tx.wait(); + + if (receipt == null || receipt.status !== 1) { + throw new Error('Transaction failed or missing receipt'); + } + + logTxnSummary( + 'Polygon — withdraw router USDC to caller via performModularExecution', + chainId, + receipt, + ); + + const { balance: signerAfter } = await getWalletErc20Balance( + usdc, + signerAddress, + provider, + ); + console.log(`Signer USDC after: ${ethers.formatUnits(signerAfter, 6)}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts b/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts new file mode 100644 index 0000000..cc4ccb4 --- /dev/null +++ b/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts @@ -0,0 +1,232 @@ +/** + * Route: Polygon AAVE → USDC (KyberSwap) — standalone swap, no bridge + * Flags: post-fee (fee taken from USDC output after swap), output measured as USDC balanceOf delta + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDC, deducted from swap output. + * BalanceOf (bit1=1): final USDC amount is measured as router USDC balance change (not returndata). + * + * returnData mode is not available for KyberSwap — it routes output directly to recipient. + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... KYBERSWAP_API_KEY=... ts-node scripts/e2e/swap/kyberswap.postFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + KYBERSWAP_API_KEY, + SWAP_SLIPPAGE_BPS, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | balance-of (0x02) +const FLAGS = 0x03n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const KYBERSWAP_BASE_URL = "https://aggregator-api.kyberswap.com"; +const KYBERSWAP_CHAIN = "polygon"; + +interface KyberRouteData { + routeSummary: object; + routerAddress: string; +} + +interface KyberBuildData { + routerAddress: string; + amountOut: string; + data: string; + transactionValue: string; +} + +/** + * Fetches a KyberSwap route quote then builds executable calldata. + * Sets sender and recipient both to the router so output lands in the router + * for balanceOf delta measurement and post-fee deduction. + */ +async function fetchKyberSwapQuote( + amountIn: bigint, + routerAddress: string, +): Promise<{ + ksRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; + value: bigint; +}> { + const headers: Record = {}; + if (KYBERSWAP_API_KEY) { + headers["x-client-id"] = KYBERSWAP_API_KEY; + } + + const routeUrl = + `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/routes` + + `?tokenIn=${TOKENS.AAVE_POLYGON}&tokenOut=${TOKENS.USDC_POLYGON_CIRCLE}` + + `&amountIn=${amountIn.toString()}&excludedSources=bebop&gasInclude=true`; + + const routeResp = await axios.get<{ code: number; data: KyberRouteData }>( + routeUrl, + { headers }, + ); + if (!routeResp.data?.data?.routeSummary) { + throw new Error( + `KyberSwap routes call failed: ${JSON.stringify(routeResp.data)}`, + ); + } + const { routeSummary } = routeResp.data.data; + + const buildUrl = `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/route/build`; + const buildResp = await axios.post<{ code: number; data: KyberBuildData }>( + buildUrl, + { + routeSummary, + sender: routerAddress, + recipient: routerAddress, + slippageTolerance: SWAP_SLIPPAGE_BPS, + }, + { headers }, + ); + if (!buildResp.data?.data?.data) { + throw new Error( + `KyberSwap build call failed: ${JSON.stringify(buildResp.data)}`, + ); + } + + const { routerAddress: ksRouter, amountOut, data, transactionValue } = + buildResp.data.data; + const estimatedOut = BigInt(amountOut); + + return { + ksRouter, + swapData: data, + estimatedOut, + minAmountOut: + (estimatedOut * (10000n - BigInt(SWAP_SLIPPAGE_BPS))) / 10000n, + value: BigInt(transactionValue ?? "0"), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + if (!KYBERSWAP_API_KEY) { + console.warn( + "KYBERSWAP_API_KEY not set — unauthenticated requests may be rate-limited", + ); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (post-fee, balanceOf)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching KyberSwap quote (AAVE → USDC)..."); + const { ksRouter, swapData, estimatedOut, minAmountOut, value } = + await fetchKyberSwapQuote(inputAmount, ROUTER_POLYGON); + + // post-fee: deduct from estimated USDC output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` KS router: ${ksRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`, + ); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON, + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter, + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + signerAddress, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: ksRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount, + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + ); + + logTxnSummary( + `Polygon AAVE → USDC — kyberswap postFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt, + ); + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/kyberswap.postFee.returndata.ts b/scripts/e2e/swap/kyberswap.postFee.returndata.ts new file mode 100644 index 0000000..06e35dd --- /dev/null +++ b/scripts/e2e/swap/kyberswap.postFee.returndata.ts @@ -0,0 +1,227 @@ +/** + * Route: Polygon AAVE → USDC (KyberSwap) — standalone swap, no bridge + * Flags: post-fee (fee taken from USDC output after swap), output read from swap returndata word 0 + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDC, deducted from swap output. + * Returndata (bit1=0): final USDC amount is read from word 0 of the swap call returndata. + * + * Build uses sender = recipient = router so gross USDC stays on the router until post-fee forward + * (same shape as the balanceOf post-fee script; only the output-measurement flag differs). + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... KYBERSWAP_API_KEY=... ts-node scripts/e2e/swap/kyberswap.postFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + KYBERSWAP_API_KEY, + SWAP_SLIPPAGE_BPS, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | returndata (bit1=0 ⇒ no 0x02) +const FLAGS = 0x01n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const KYBERSWAP_BASE_URL = "https://aggregator-api.kyberswap.com"; +const KYBERSWAP_CHAIN = "polygon"; + +interface KyberRouteData { + routeSummary: object; + routerAddress: string; +} + +interface KyberBuildData { + routerAddress: string; + amountOut: string; + data: string; + transactionValue: string; +} + +/** + * Fetches a KyberSwap route quote then builds executable calldata. + * Post-fee path: both sender and recipient are the router so output settles on-contract before fee. + */ +async function fetchKyberSwapQuote( + amountIn: bigint, + routerAddress: string, +): Promise<{ + ksRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; + value: bigint; +}> { + const headers: Record = {}; + if (KYBERSWAP_API_KEY) { + headers["x-client-id"] = KYBERSWAP_API_KEY; + } + + const routeUrl = + `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/routes` + + `?tokenIn=${TOKENS.AAVE_POLYGON}&tokenOut=${TOKENS.USDC_POLYGON_CIRCLE}` + + `&amountIn=${amountIn.toString()}&excludedSources=bebop&gasInclude=true`; + + const routeResp = await axios.get<{ code: number; data: KyberRouteData }>( + routeUrl, + { headers }, + ); + if (!routeResp.data?.data?.routeSummary) { + throw new Error( + `KyberSwap routes call failed: ${JSON.stringify(routeResp.data)}`, + ); + } + const { routeSummary } = routeResp.data.data; + + const buildUrl = `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/route/build`; + const buildResp = await axios.post<{ code: number; data: KyberBuildData }>( + buildUrl, + { + routeSummary, + sender: routerAddress, + recipient: routerAddress, + slippageTolerance: SWAP_SLIPPAGE_BPS, + }, + { headers }, + ); + if (!buildResp.data?.data?.data) { + throw new Error( + `KyberSwap build call failed: ${JSON.stringify(buildResp.data)}`, + ); + } + + const { routerAddress: ksRouter, amountOut, data, transactionValue } = + buildResp.data.data; + const estimatedOut = BigInt(amountOut); + + return { + ksRouter, + swapData: data, + estimatedOut, + minAmountOut: + (estimatedOut * (10000n - BigInt(SWAP_SLIPPAGE_BPS))) / 10000n, + value: BigInt(transactionValue ?? "0"), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + if (!KYBERSWAP_API_KEY) { + console.warn( + "KYBERSWAP_API_KEY not set — unauthenticated requests may be rate-limited", + ); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (post-fee, returndata)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching KyberSwap quote (AAVE → USDC)..."); + const { ksRouter, swapData, estimatedOut, minAmountOut, value } = + await fetchKyberSwapQuote(inputAmount, ROUTER_POLYGON); + + // post-fee: deduct from estimated USDC output after swap + const feeAmount = bpsOf(estimatedOut, FEE_BPS); + console.log(` KS router: ${ksRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`, + ); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter, + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + signerAddress, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: ksRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount, + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + ); + + logTxnSummary( + `Polygon AAVE → USDC — kyberswap postFee/returndata`, + CHAIN_IDS.POLYGON, + receipt, + ); + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts b/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts new file mode 100644 index 0000000..1043d72 --- /dev/null +++ b/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts @@ -0,0 +1,238 @@ +/** + * Route: Polygon AAVE → USDC (KyberSwap) — standalone swap, no bridge + * Flags: pre-fee (fee taken from AAVE input before swap), output measured as USDC balanceOf delta + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount AAVE, deducted before the swap. + * BalanceOf (bit1=1): final USDC amount is measured as router USDC balance change (not returndata). + * + * KyberSwap build calldata encodes exact input amounts, so the quote is for swapInput + * (inputAmount − preFeeAmount) to match the router's approval amount at execution time. + * returnData mode is not available for KyberSwap — it routes output directly to recipient. + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... KYBERSWAP_API_KEY=... ts-node scripts/e2e/swap/kyberswap.preFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + KYBERSWAP_API_KEY, + SWAP_SLIPPAGE_BPS, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | balance-of (0x02) +const FLAGS = 0x02n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const KYBERSWAP_BASE_URL = "https://aggregator-api.kyberswap.com"; +const KYBERSWAP_CHAIN = "polygon"; + +interface KyberRouteData { + routeSummary: object; + routerAddress: string; +} + +interface KyberBuildData { + routerAddress: string; + amountOut: string; + data: string; + transactionValue: string; +} + +/** + * Fetches a KyberSwap route quote then builds executable calldata. + * Sets sender and recipient both to the router so output lands in the router + * for balanceOf delta measurement. + */ +async function fetchKyberSwapQuote( + amountIn: bigint, + routerAddress: string, +): Promise<{ + ksRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; + value: bigint; +}> { + const headers: Record = {}; + if (KYBERSWAP_API_KEY) { + headers["x-client-id"] = KYBERSWAP_API_KEY; + } + + const routeUrl = + `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/routes` + + `?tokenIn=${TOKENS.AAVE_POLYGON}&tokenOut=${TOKENS.USDC_POLYGON_CIRCLE}` + + `&amountIn=${amountIn.toString()}&excludedSources=bebop&gasInclude=true`; + + const routeResp = await axios.get<{ code: number; data: KyberRouteData }>( + routeUrl, + { headers }, + ); + if (!routeResp.data?.data?.routeSummary) { + throw new Error( + `KyberSwap routes call failed: ${JSON.stringify(routeResp.data)}`, + ); + } + const { routeSummary } = routeResp.data.data; + + const buildUrl = `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/route/build`; + const buildResp = await axios.post<{ code: number; data: KyberBuildData }>( + buildUrl, + { + routeSummary, + sender: routerAddress, + recipient: routerAddress, + slippageTolerance: SWAP_SLIPPAGE_BPS, + }, + { headers }, + ); + if (!buildResp.data?.data?.data) { + throw new Error( + `KyberSwap build call failed: ${JSON.stringify(buildResp.data)}`, + ); + } + + const { routerAddress: ksRouter, amountOut, data, transactionValue } = + buildResp.data.data; + const estimatedOut = BigInt(amountOut); + + return { + ksRouter, + swapData: data, + estimatedOut, + minAmountOut: + (estimatedOut * (10000n - BigInt(SWAP_SLIPPAGE_BPS))) / 10000n, + value: BigInt(transactionValue ?? "0"), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + if (!KYBERSWAP_API_KEY) { + console.warn( + "KYBERSWAP_API_KEY not set — unauthenticated requests may be rate-limited", + ); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + // quote for the reduced swap input so KyberSwap calldata encodes the correct amount + const swapInput = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (pre-fee, balanceOf)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + console.log( + `Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)`, + ); + console.log(`Swap input: ${ethers.formatUnits(swapInput, 18)} AAVE`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching KyberSwap quote (AAVE → USDC)..."); + const { ksRouter, swapData, estimatedOut, minAmountOut, value } = + await fetchKyberSwapQuote(swapInput, ROUTER_POLYGON); + + console.log(` KS router: ${ksRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON, + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter, + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + signerAddress, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: ksRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount, + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + ); + + logTxnSummary( + `Polygon AAVE → USDC — kyberswap preFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt, + ); + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/kyberswap.preFee.returndata.ts b/scripts/e2e/swap/kyberswap.preFee.returndata.ts new file mode 100644 index 0000000..95df823 --- /dev/null +++ b/scripts/e2e/swap/kyberswap.preFee.returndata.ts @@ -0,0 +1,234 @@ +/** + * Route: Polygon AAVE → USDC (KyberSwap) — standalone swap, no bridge + * Flags: pre-fee (fee taken from AAVE input before swap), output read from swap returndata word 0 + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount AAVE, deducted before the swap. + * Returndata (bit1=0): final USDC amount is read from word 0 of the swap call returndata. + * + * Pre-fee + returndata: swap output must go to `receiver` (signer); the router decodes amount from + * returndata. Quote uses swapInput = inputAmount − fee so calldata matches the post-fee swap size. + * + * Kyber build: sender = router (executor), recipient = user (net output destination). + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... KYBERSWAP_API_KEY=... ts-node scripts/e2e/swap/kyberswap.preFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + KYBERSWAP_API_KEY, + SWAP_SLIPPAGE_BPS, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | returndata (bit1=0 ⇒ no 0x02) +const FLAGS = 0x00n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const KYBERSWAP_BASE_URL = "https://aggregator-api.kyberswap.com"; +const KYBERSWAP_CHAIN = "polygon"; + +interface KyberRouteData { + routeSummary: object; + routerAddress: string; +} + +interface KyberBuildData { + routerAddress: string; + amountOut: string; + data: string; + transactionValue: string; +} + +/** + * Fetches a KyberSwap route quote then builds executable calldata. + * Pre-fee returndata: router executes; tokens are sent to `outputRecipient` (user). + */ +async function fetchKyberSwapQuote( + amountIn: bigint, + routerAddress: string, + outputRecipient: string, +): Promise<{ + ksRouter: string; + swapData: string; + estimatedOut: bigint; + minAmountOut: bigint; + value: bigint; +}> { + const headers: Record = {}; + if (KYBERSWAP_API_KEY) { + headers["x-client-id"] = KYBERSWAP_API_KEY; + } + + const routeUrl = + `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/routes` + + `?tokenIn=${TOKENS.AAVE_POLYGON}&tokenOut=${TOKENS.USDC_POLYGON_CIRCLE}` + + `&amountIn=${amountIn.toString()}&excludedSources=bebop&gasInclude=true`; + + const routeResp = await axios.get<{ code: number; data: KyberRouteData }>( + routeUrl, + { headers }, + ); + if (!routeResp.data?.data?.routeSummary) { + throw new Error( + `KyberSwap routes call failed: ${JSON.stringify(routeResp.data)}`, + ); + } + const { routeSummary } = routeResp.data.data; + + const buildUrl = `${KYBERSWAP_BASE_URL}/${KYBERSWAP_CHAIN}/api/v1/route/build`; + const buildResp = await axios.post<{ code: number; data: KyberBuildData }>( + buildUrl, + { + routeSummary, + sender: routerAddress, + recipient: outputRecipient, + slippageTolerance: SWAP_SLIPPAGE_BPS, + }, + { headers }, + ); + if (!buildResp.data?.data?.data) { + throw new Error( + `KyberSwap build call failed: ${JSON.stringify(buildResp.data)}`, + ); + } + + const { routerAddress: ksRouter, amountOut, data, transactionValue } = + buildResp.data.data; + const estimatedOut = BigInt(amountOut); + + return { + ksRouter, + swapData: data, + estimatedOut, + minAmountOut: + (estimatedOut * (10000n - BigInt(SWAP_SLIPPAGE_BPS))) / 10000n, + value: BigInt(transactionValue ?? "0"), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + if (!KYBERSWAP_API_KEY) { + console.warn( + "KYBERSWAP_API_KEY not set — unauthenticated requests may be rate-limited", + ); + } + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + // quote for the reduced swap input so KyberSwap calldata encodes the correct amount + const swapInput = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (pre-fee, returndata)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + console.log( + `Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)`, + ); + console.log(`Swap input: ${ethers.formatUnits(swapInput, 18)} AAVE`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching KyberSwap quote (AAVE → USDC)..."); + const { ksRouter, swapData, estimatedOut, minAmountOut, value } = + await fetchKyberSwapQuote(swapInput, ROUTER_POLYGON, signerAddress); + + console.log(` KS router: ${ksRouter}`); + console.log(` Est. USDC: ${ethers.formatUnits(estimatedOut, 6)}`); + console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter, + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + signerAddress, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: ksRouter, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount, + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + ); + + logTxnSummary( + `Polygon AAVE → USDC — kyberswap preFee/returndata`, + CHAIN_IDS.POLYGON, + receipt, + ); + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/swap.postFee.balanceOf.ts b/scripts/e2e/swap/swap.postFee.balanceOf.ts index 50e518c..7faf26d 100644 --- a/scripts/e2e/swap/swap.postFee.balanceOf.ts +++ b/scripts/e2e/swap/swap.postFee.balanceOf.ts @@ -132,6 +132,7 @@ async function main() { inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, + signerAddress, FLAGS, { receiver: signerAddress, amount: feeAmount }, { diff --git a/scripts/e2e/swap/swap.postFee.returndata.ts b/scripts/e2e/swap/swap.postFee.returndata.ts index 7280e6f..01eb4b7 100644 --- a/scripts/e2e/swap/swap.postFee.returndata.ts +++ b/scripts/e2e/swap/swap.postFee.returndata.ts @@ -127,6 +127,7 @@ async function main() { inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, + signerAddress, FLAGS, { receiver: signerAddress, amount: feeAmount }, { diff --git a/scripts/e2e/swap/swap.preFee.balanceOf.ts b/scripts/e2e/swap/swap.preFee.balanceOf.ts index 7bac539..d21c98b 100644 --- a/scripts/e2e/swap/swap.preFee.balanceOf.ts +++ b/scripts/e2e/swap/swap.preFee.balanceOf.ts @@ -132,6 +132,7 @@ async function main() { inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, + signerAddress, FLAGS, { receiver: signerAddress, amount: feeAmount }, { diff --git a/scripts/e2e/swap/swap.preFee.returndata.ts b/scripts/e2e/swap/swap.preFee.returndata.ts index 61c7d76..7de1155 100644 --- a/scripts/e2e/swap/swap.preFee.returndata.ts +++ b/scripts/e2e/swap/swap.preFee.returndata.ts @@ -127,6 +127,7 @@ async function main() { inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, + signerAddress, FLAGS, { receiver: signerAddress, amount: feeAmount }, { diff --git a/scripts/e2e/swap/zerox.postFee.balanceOf.ts b/scripts/e2e/swap/zerox.postFee.balanceOf.ts new file mode 100644 index 0000000..593ef82 --- /dev/null +++ b/scripts/e2e/swap/zerox.postFee.balanceOf.ts @@ -0,0 +1,228 @@ +/** + * Route: Polygon AAVE → USDC (0x v2 AllowanceHolder) — standalone swap, no bridge + * Flags: post-fee (fee taken from USDC output after swap), output measured as USDC balanceOf delta + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDC, deducted from swap output. + * BalanceOf (bit1=1): final USDC amount is measured as router USDC balance change (not returndata). + * + * 0x v2 uses the AllowanceHolder contract (0x000…1fF3) as both the approval target and the swap + * call target. taker=router (router holds the tokens and makes the AH call), recipient=router + * (output lands in router for post-fee deduction and balanceOf delta measurement). + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... ZEROX_API_KEY=... ts-node scripts/e2e/swap/zerox.postFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ZEROX_API_KEY, + SWAP_SLIPPAGE_BPS, + ALLOWANCE_HOLDER, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | balance-of (0x02) +const FLAGS = 0x03n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const ZEROX_BASE_URL = "https://api.0x.org"; + +interface ZeroXTransaction { + to: string; + data: string; + value: string; + gas: string; + gasPrice: string; +} + +interface ZeroXQuoteResponse { + transaction: ZeroXTransaction; + buyAmount: string; + minBuyAmount: string; + issues?: { + balance?: { token: string; actual: string; expected: string }; + allowance?: { actual: string; spender: string }; + simulationIncomplete?: boolean; + }; +} + +/** + * Fetches a 0x v2 AllowanceHolder swap quote. + * taker and recipient are both set to the router so the router executes the call + * and receives the output for post-fee deduction and balanceOf delta measurement. + * + * Balance/allowance issues in the response are expected at quote time (the router + * will have the tokens at execution time) and are ignored. + */ +async function fetchZeroXQuote( + sellAmount: bigint, + taker: string, + recipient: string, + txOrigin: string, +): Promise<{ + swapTarget: string; + swapData: string; + buyAmount: bigint; + minBuyAmount: bigint; + value: bigint; +}> { + if (!ZEROX_API_KEY) { + throw new Error("ZEROX_API_KEY env var required"); + } + + const url = + `${ZEROX_BASE_URL}/swap/allowance-holder/quote` + + `?chainId=${CHAIN_IDS.POLYGON}` + + `&buyToken=${TOKENS.USDC_POLYGON_CIRCLE}` + + `&sellToken=${TOKENS.AAVE_POLYGON}` + + `&sellAmount=${sellAmount.toString()}` + + `&taker=${taker}` + + `&recipient=${recipient}` + + `&txOrigin=${txOrigin}` + + `&slippageBps=${SWAP_SLIPPAGE_BPS}`; + + const resp = await axios.get(url, { + headers: { + "0x-api-key": ZEROX_API_KEY, + "0x-version": "v2", + }, + }); + + if (!resp.data?.transaction) { + throw new Error(`0x quote call failed: ${JSON.stringify(resp.data)}`); + } + + const { transaction, buyAmount, minBuyAmount } = resp.data; + return { + swapTarget: transaction.to, + swapData: transaction.data, + buyAmount: BigInt(buyAmount), + minBuyAmount: BigInt(minBuyAmount), + value: BigInt(transaction.value ?? "0"), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (post-fee, balanceOf)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching 0x quote (AAVE → USDC)..."); + const { swapTarget, swapData, buyAmount, minBuyAmount, value } = + await fetchZeroXQuote(inputAmount, ROUTER_POLYGON, ROUTER_POLYGON, signerAddress); + + // post-fee: deduct from estimated USDC output after swap + const feeAmount = bpsOf(buyAmount, FEE_BPS); + console.log(` 0x target: ${swapTarget}`); + console.log(` Est. USDC: ${ethers.formatUnits(buyAmount, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`, + ); + console.log(` Min USDC: ${ethers.formatUnits(minBuyAmount, 6)}`); + + // The 0x AllowanceHolder is the approval spender; swapTarget should equal ALLOWANCE_HOLDER + const approvalSpender = ALLOWANCE_HOLDER; + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON, + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + approvalSpender, + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + signerAddress, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: swapTarget, + approvalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minBuyAmount, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount, + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + ); + + logTxnSummary( + `Polygon AAVE → USDC — 0x postFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt, + ); + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/zerox.postFee.returndata.ts b/scripts/e2e/swap/zerox.postFee.returndata.ts new file mode 100644 index 0000000..3f88890 --- /dev/null +++ b/scripts/e2e/swap/zerox.postFee.returndata.ts @@ -0,0 +1,217 @@ +/** + * Route: Polygon AAVE → USDC (0x v2 AllowanceHolder) — standalone swap, no bridge + * Flags: post-fee (fee taken from USDC output after swap), output read from swap returndata word 0 + * + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDC, deducted from swap output. + * Returndata (bit1=0): final USDC amount is read from word 0 of the swap call returndata. + * + * 0x: taker=router, recipient=router so gross USDC stays on the router for post-fee settle + * (same quote shape as balanceOf post-fee; only the output-measurement flag differs). + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... ZEROX_API_KEY=... ts-node scripts/e2e/swap/zerox.postFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ZEROX_API_KEY, + SWAP_SLIPPAGE_BPS, + ALLOWANCE_HOLDER, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// post-fee (0x01) | returndata (no 0x02) +const FLAGS = 0x01n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const ZEROX_BASE_URL = "https://api.0x.org"; + +interface ZeroXTransaction { + to: string; + data: string; + value: string; + gas: string; + gasPrice: string; +} + +interface ZeroXQuoteResponse { + transaction: ZeroXTransaction; + buyAmount: string; + minBuyAmount: string; + issues?: { + balance?: { token: string; actual: string; expected: string }; + allowance?: { actual: string; spender: string }; + simulationIncomplete?: boolean; + }; +} + +/** + * Fetches a 0x v2 AllowanceHolder swap quote. + * taker and recipient are the router so execution and settlement stay on-contract for post-fee. + */ +async function fetchZeroXQuote( + sellAmount: bigint, + taker: string, + recipient: string, + txOrigin: string, +): Promise<{ + swapTarget: string; + swapData: string; + buyAmount: bigint; + minBuyAmount: bigint; + value: bigint; +}> { + if (!ZEROX_API_KEY) { + throw new Error("ZEROX_API_KEY env var required"); + } + + const url = + `${ZEROX_BASE_URL}/swap/allowance-holder/quote` + + `?chainId=${CHAIN_IDS.POLYGON}` + + `&buyToken=${TOKENS.USDC_POLYGON_CIRCLE}` + + `&sellToken=${TOKENS.AAVE_POLYGON}` + + `&sellAmount=${sellAmount.toString()}` + + `&taker=${taker}` + + `&recipient=${recipient}` + + `&txOrigin=${txOrigin}` + + `&slippageBps=${SWAP_SLIPPAGE_BPS}`; + + const resp = await axios.get(url, { + headers: { + "0x-api-key": ZEROX_API_KEY, + "0x-version": "v2", + }, + }); + + if (!resp.data?.transaction) { + throw new Error(`0x quote call failed: ${JSON.stringify(resp.data)}`); + } + + const { transaction, buyAmount, minBuyAmount } = resp.data; + return { + swapTarget: transaction.to, + swapData: transaction.data, + buyAmount: BigInt(buyAmount), + minBuyAmount: BigInt(minBuyAmount), + value: BigInt(transaction.value ?? "0"), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (post-fee, returndata)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching 0x quote (AAVE → USDC)..."); + const { swapTarget, swapData, buyAmount, minBuyAmount, value } = + await fetchZeroXQuote(inputAmount, ROUTER_POLYGON, ROUTER_POLYGON, signerAddress); + + // post-fee: deduct from estimated USDC output after swap + const feeAmount = bpsOf(buyAmount, FEE_BPS); + console.log(` 0x target: ${swapTarget}`); + console.log(` Est. USDC: ${ethers.formatUnits(buyAmount, 6)}`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`, + ); + console.log(` Min USDC: ${ethers.formatUnits(minBuyAmount, 6)}`); + + const approvalSpender = ALLOWANCE_HOLDER; + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + approvalSpender, + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + signerAddress, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: swapTarget, + approvalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minBuyAmount, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount, + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + ); + + logTxnSummary( + `Polygon AAVE → USDC — 0x postFee/returndata`, + CHAIN_IDS.POLYGON, + receipt, + ); + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/zerox.preFee.balanceOf.ts b/scripts/e2e/swap/zerox.preFee.balanceOf.ts new file mode 100644 index 0000000..ca37956 --- /dev/null +++ b/scripts/e2e/swap/zerox.preFee.balanceOf.ts @@ -0,0 +1,235 @@ +/** + * Route: Polygon AAVE → USDC (0x v2 AllowanceHolder) — standalone swap, no bridge + * Flags: pre-fee (fee taken from AAVE input before swap), output measured as USDC balanceOf delta + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount AAVE, deducted before the swap. + * BalanceOf (bit1=1): final USDC amount is measured as router USDC balance change (not returndata). + * + * 0x v2 uses the AllowanceHolder contract (0x000…1fF3) as both the approval target and the swap + * call target. taker=router (router holds the tokens and makes the AH call), recipient=router + * (output lands in router for balanceOf delta measurement). + * + * The quote is for swapInput (inputAmount − preFeeAmount) so the 0x calldata matches the + * router's approval amount at execution time. + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... ZEROX_API_KEY=... ts-node scripts/e2e/swap/zerox.preFee.balanceOf.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ZEROX_API_KEY, + SWAP_SLIPPAGE_BPS, + ALLOWANCE_HOLDER, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | balance-of (0x02) +const FLAGS = 0x02n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const ZEROX_BASE_URL = "https://api.0x.org"; + +interface ZeroXTransaction { + to: string; + data: string; + value: string; + gas: string; + gasPrice: string; +} + +interface ZeroXQuoteResponse { + transaction: ZeroXTransaction; + buyAmount: string; + minBuyAmount: string; + issues?: { + balance?: { token: string; actual: string; expected: string }; + allowance?: { actual: string; spender: string }; + simulationIncomplete?: boolean; + }; +} + +/** + * Fetches a 0x v2 AllowanceHolder swap quote. + * taker and recipient are both set to the router so the router executes the call + * and receives the output for balanceOf delta measurement. + * + * Balance/allowance issues in the response are expected at quote time (the router + * will have the tokens at execution time) and are ignored. + */ +async function fetchZeroXQuote( + sellAmount: bigint, + taker: string, + recipient: string, + txOrigin: string, +): Promise<{ + swapTarget: string; + swapData: string; + buyAmount: bigint; + minBuyAmount: bigint; + value: bigint; +}> { + if (!ZEROX_API_KEY) { + throw new Error("ZEROX_API_KEY env var required"); + } + + const url = + `${ZEROX_BASE_URL}/swap/allowance-holder/quote` + + `?chainId=${CHAIN_IDS.POLYGON}` + + `&buyToken=${TOKENS.USDC_POLYGON_CIRCLE}` + + `&sellToken=${TOKENS.AAVE_POLYGON}` + + `&sellAmount=${sellAmount.toString()}` + + `&taker=${taker}` + + `&recipient=${recipient}` + + `&txOrigin=${txOrigin}` + + `&slippageBps=${SWAP_SLIPPAGE_BPS}`; + + const resp = await axios.get(url, { + headers: { + "0x-api-key": ZEROX_API_KEY, + "0x-version": "v2", + }, + }); + + if (!resp.data?.transaction) { + throw new Error(`0x quote call failed: ${JSON.stringify(resp.data)}`); + } + + const { transaction, buyAmount, minBuyAmount } = resp.data; + return { + swapTarget: transaction.to, + swapData: transaction.data, + buyAmount: BigInt(buyAmount), + minBuyAmount: BigInt(minBuyAmount), + value: BigInt(transaction.value ?? "0"), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + // quote for the reduced swap input so 0x calldata encodes the correct sell amount + const swapInput = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (pre-fee, balanceOf)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + console.log( + `Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)`, + ); + console.log(`Swap input: ${ethers.formatUnits(swapInput, 18)} AAVE`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching 0x quote (AAVE → USDC)..."); + const { swapTarget, swapData, buyAmount, minBuyAmount, value } = + await fetchZeroXQuote(swapInput, ROUTER_POLYGON, ROUTER_POLYGON, signerAddress); + + console.log(` 0x target: ${swapTarget}`); + console.log(` Est. USDC: ${ethers.formatUnits(buyAmount, 6)}`); + console.log(` Min USDC: ${ethers.formatUnits(minBuyAmount, 6)}`); + + // The 0x AllowanceHolder is the approval spender; swapTarget should equal ALLOWANCE_HOLDER + const approvalSpender = ALLOWANCE_HOLDER; + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterErc20Balance( + signer, + TOKENS.USDC_POLYGON_CIRCLE, + ROUTER_POLYGON, + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + approvalSpender, + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + signerAddress, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: swapTarget, + approvalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minBuyAmount, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount, + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + ); + + logTxnSummary( + `Polygon AAVE → USDC — 0x preFee/balanceOf`, + CHAIN_IDS.POLYGON, + receipt, + ); + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/swap/zerox.preFee.returndata.ts b/scripts/e2e/swap/zerox.preFee.returndata.ts new file mode 100644 index 0000000..2ca1b56 --- /dev/null +++ b/scripts/e2e/swap/zerox.preFee.returndata.ts @@ -0,0 +1,227 @@ +/** + * Route: Polygon AAVE → USDC (0x v2 AllowanceHolder) — standalone swap, no bridge + * Flags: pre-fee (fee taken from AAVE input before swap), output read from swap returndata word 0 + * + * Pre-fee (bit0=0): feeAmount = FEE_BPS of inputAmount AAVE, deducted before the swap. + * Returndata (bit1=0): final USDC amount is read from word 0 of the swap call returndata. + * + * 0x: taker=router (AllowanceHolder entry), recipient=signer so output USDC goes to the user + * while the router decodes `filledAmount` / return data per `returnDataWordOffset`. + * + * Quote uses swapInput (inputAmount − preFeeAmount) so calldata matches execution. + * + * USDC lands in signer's wallet on Polygon. + * + * Usage: + * PRIVATE_KEY=0x... ZEROX_API_KEY=... ts-node scripts/e2e/swap/zerox.preFee.returndata.ts + */ +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + FEE_BPS, + bpsOf, + RPC, + ZEROX_API_KEY, + SWAP_SLIPPAGE_BPS, + ALLOWANCE_HOLDER, +} from "../config"; +import { + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; + +// pre-fee (0x00) | returndata (no 0x02) +const FLAGS = 0x00n; +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const ZEROX_BASE_URL = "https://api.0x.org"; + +interface ZeroXTransaction { + to: string; + data: string; + value: string; + gas: string; + gasPrice: string; +} + +interface ZeroXQuoteResponse { + transaction: ZeroXTransaction; + buyAmount: string; + minBuyAmount: string; + issues?: { + balance?: { token: string; actual: string; expected: string }; + allowance?: { actual: string; spender: string }; + simulationIncomplete?: boolean; + }; +} + +/** + * Fetches a 0x v2 AllowanceHolder swap quote. + * taker=router, recipient=user so bought USDC is delivered to the user (pre-fee + returndata). + */ +async function fetchZeroXQuote( + sellAmount: bigint, + taker: string, + recipient: string, + txOrigin: string, +): Promise<{ + swapTarget: string; + swapData: string; + buyAmount: bigint; + minBuyAmount: bigint; + value: bigint; +}> { + if (!ZEROX_API_KEY) { + throw new Error("ZEROX_API_KEY env var required"); + } + + const url = + `${ZEROX_BASE_URL}/swap/allowance-holder/quote` + + `?chainId=${CHAIN_IDS.POLYGON}` + + `&buyToken=${TOKENS.USDC_POLYGON_CIRCLE}` + + `&sellToken=${TOKENS.AAVE_POLYGON}` + + `&sellAmount=${sellAmount.toString()}` + + `&taker=${taker}` + + `&recipient=${recipient}` + + `&txOrigin=${txOrigin}` + + `&slippageBps=${SWAP_SLIPPAGE_BPS}`; + + const resp = await axios.get(url, { + headers: { + "0x-api-key": ZEROX_API_KEY, + "0x-version": "v2", + }, + }); + + if (!resp.data?.transaction) { + throw new Error(`0x quote call failed: ${JSON.stringify(resp.data)}`); + } + + const { transaction, buyAmount, minBuyAmount } = resp.data; + return { + swapTarget: transaction.to, + swapData: transaction.data, + buyAmount: BigInt(buyAmount), + minBuyAmount: BigInt(minBuyAmount), + value: BigInt(transaction.value ?? "0"), + }; +} + +async function main() { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); + + const provider = new ethers.JsonRpcProvider(RPC.POLYGON); + const signer = new ethers.Wallet(privateKey, provider); + const signerAddress = await signer.getAddress(); + + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider, + ); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + } + + const inputAmount = walletBalance - 20n; + if (inputAmount === 0n) throw new Error("Balance too small"); + + // pre-fee: deduct from input AAVE before the swap + const feeAmount = bpsOf(inputAmount, FEE_BPS); + const swapInput = inputAmount - feeAmount; + + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`Flags: 0x${FLAGS.toString(16)} (pre-fee, returndata)`); + console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); + console.log( + `Pre-fee: ${ethers.formatUnits(feeAmount, 18)} AAVE (${FEE_BPS} bps)`, + ); + console.log(`Swap input: ${ethers.formatUnits(swapInput, 18)} AAVE`); + + const routerIface = new ethers.Interface(ROUTER_ABI); + + console.log("Fetching 0x quote (AAVE → USDC)..."); + const { swapTarget, swapData, buyAmount, minBuyAmount, value } = + await fetchZeroXQuote( + swapInput, + ROUTER_POLYGON, + signerAddress, + signerAddress, + ); + + console.log(` 0x target: ${swapTarget}`); + console.log(` Est. USDC: ${ethers.formatUnits(buyAmount, 6)}`); + console.log(` Min USDC: ${ethers.formatUnits(minBuyAmount, 6)}`); + + const approvalSpender = ALLOWANCE_HOLDER; + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + approvalSpender, + ); + + const callData = routerIface.encodeFunctionData("swap", [ + ZERO_BYTES32, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + signerAddress, + FLAGS, + { receiver: signerAddress, amount: feeAmount }, + { + target: swapTarget, + approvalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minBuyAmount, + returnDataWordOffset: 0n, + }, + swapData, + ]); + + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount, + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + ); + + logTxnSummary( + `Polygon AAVE → USDC — 0x preFee/returndata`, + CHAIN_IDS.POLYGON, + receipt, + ); + console.log(`\nUSDC is now in signer's wallet on Polygon.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/utils/routerAbi.ts b/scripts/e2e/utils/routerAbi.ts index 206584a..2caa0e0 100644 --- a/scripts/e2e/utils/routerAbi.ts +++ b/scripts/e2e/utils/routerAbi.ts @@ -28,6 +28,7 @@ export const ROUTER_ABI = [ `function swap( bytes32 requestHash, (address user, address inputToken, uint256 inputAmount) input, + address receiver, uint256 flags, (address receiver, uint256 amount) fee, (address target, address approvalSpender, address outputToken, uint256 value, uint256 minOutput, uint256 returnDataWordOffset) swapData, From 1313cc4750931db5e05a9e77cfe887c611ead80b Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 18 May 2026 20:24:03 +0530 Subject: [PATCH 16/42] feat: rescueFunds --- src/combined/BungeeOpenRouterV2Unchecked.sol | 12 ++++++++ src/common/lib/RescueFundsLib.sol | 32 ++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/common/lib/RescueFundsLib.sol diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index 6445c6b..5d55f84 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -8,6 +8,7 @@ import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext import {ALLOWANCE_HOLDER} from "../common/interfaces/IAllowanceHolder.sol"; import {BytesSpliceLib} from "../common/lib/BytesSpliceLib.sol"; import {CurrencyLib} from "../common/lib/CurrencyLib.sol"; +import {RescueFundsLib} from "../common/lib/RescueFundsLib.sol"; /// @title BungeeOpenRouterV2Unchecked /// @notice Identical execution logic to `BungeeOpenRouterV2` with all backend @@ -762,4 +763,15 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { word := mload(add(add(ret, 0x20), offset)) } } + + /** + * @notice Rescues funds from the contract if they are locked by mistake. + * @param token_ The address of the token contract. + * @param rescueTo_ The address where rescued tokens need to be sent. + * @param amount_ The amount of tokens to be rescued. + */ + function rescueFunds(address token_, address rescueTo_, uint256 amount_) external onlyOwner { + RescueFundsLib.rescueFunds(token_, rescueTo_, amount_); + } } + diff --git a/src/common/lib/RescueFundsLib.sol b/src/common/lib/RescueFundsLib.sol new file mode 100644 index 0000000..a18b950 --- /dev/null +++ b/src/common/lib/RescueFundsLib.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity =0.8.25; + +import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; + +error ZeroAddress(); + +/// @title RescueFundsLib +/// @notice Pull tokens or native ETH from the calling contract to a recipient. +library RescueFundsLib { + address public constant ETH_ADDRESS = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + + error InvalidTokenAddress(); + + /// @param token_ ERC20 token or `ETH_ADDRESS` for native balance. + /// @param rescueTo_ Recipient; must not be zero. + /// @param amount_ Amount to transfer out of `address(this)`. + function rescueFunds(address token_, address rescueTo_, uint256 amount_) internal { + if (rescueTo_ == address(0)) { + revert ZeroAddress(); + } + + if (token_ == ETH_ADDRESS) { + SafeTransferLib.safeTransferETH(rescueTo_, amount_); + } else { + if (token_.code.length == 0) { + revert InvalidTokenAddress(); + } + SafeTransferLib.safeTransfer(token_, rescueTo_, amount_); + } + } +} From c58077b0804104796d4e9247431759999e8d16cd Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 12:56:15 +0530 Subject: [PATCH 17/42] refactor: remove monolithic exec code --- src/combined/BungeeOpenRouterV2Unchecked.sol | 126 +------------------ 1 file changed, 2 insertions(+), 124 deletions(-) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index 5d55f84..d0d4cce 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -33,10 +33,6 @@ import {RescueFundsLib} from "../common/lib/RescueFundsLib.sol"; contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { using SafeTransferLib for address; - // ========================================================================= - // Monolithic execution types - // ========================================================================= - struct InputData { address user; address inputToken; @@ -63,18 +59,6 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { uint256 value; } - struct MonolithicExecution { - InputData input; - FeeData preFee; - SwapData swap; - FeeData postFee; - BridgeData bridge; - /// Packed flags; monolithic pipeline tests `BALANCE_FLAG_BIT_MASK` in `_execSwap`, - /// `BRIDGE_VALUE_FLAG_BIT_MASK` and `BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK` in `_doBridge`. - /// Fee timing uses `preFee` / `postFee` structs — `FEE_FLAG_BIT_MASK` (bit 0) is ignored here. - uint256 flags; - } - // ========================================================================= // Modular execution types // ========================================================================= @@ -92,7 +76,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } // ========================================================================= - // Flags (swap / swapAndBridge / monolithic swap step) + // Flags (swap / swapAndBridge) // ========================================================================= // // Instead of bool parameters, one uint256 packs independent switches without adding @@ -133,7 +117,6 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // Cleared — no runtime amount splice. // Set — splice finalAmount at uint16(flags >> BRIDGE_AMOUNT_POSITION_SHIFT). // - // Monolithic `performExecution` ignores `FEE_FLAG_BIT_MASK`; fee timing is `preFee`/`postFee` structs. /// @dev Bit mask 0x01: post-swap fee path when `(flags & mask) != 0`; clear = pre-swap fee from input token. uint256 internal constant FEE_FLAG_BIT_MASK = 0x01; @@ -182,32 +165,6 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { receive() external payable {} - // ========================================================================= - // External: monolithic path - // ========================================================================= - - /** - * @notice Executes the monolithic pipeline without signature verification: - * pull via AH, optional pre-swap fee, optional swap, optional - * post-swap fee, bridge call with optional single-position amount splicing. - * @dev The caller MUST route through `AllowanceHolder.exec` so that - * `_msgSender()` resolves to `exec.input.user`. There is no nonce or - * deadline; replay protection is the caller's responsibility. - * Bit 0 (`FEE_FLAG_BIT_MASK`) is unused in monolithic runs; fee placement is `preFee` / `postFee` structs. - * `exec.flags` contributes `BALANCE_FLAG_BIT_MASK` to `_execSwap` and - * `BRIDGE_VALUE_FLAG_BIT_MASK` to bridge msg.value selection. - * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. - */ - function performExecution( - bytes32 requestHash, - MonolithicExecution calldata exec, - bytes calldata swapCallData, - bytes calldata bridgeCallData - ) external payable { - _runMonolithic(exec, swapCallData, bridgeCallData); - emit RequestExecuted(requestHash); - } - // ========================================================================= // External: standalone swap // ========================================================================= @@ -452,85 +409,6 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { emit RequestExecuted(requestHash); } - // ========================================================================= - // Internal: monolithic pipeline - // ========================================================================= - - function _runMonolithic( - MonolithicExecution calldata exec, - bytes calldata swapCallData, - bytes calldata bridgeCallData - ) internal { - if (exec.bridge.target == address(0) || exec.input.user == address(0) || exec.input.inputToken == address(0)) { - revert InvalidExecution(); - } - - // 1. pull funds from user via AllowanceHolder - _pullFromUser(exec.input.inputToken, exec.input.user, exec.input.inputAmount); - - // 2. optional pre-swap fee in input token - if (exec.preFee.amount != 0) { - CurrencyLib.transfer(exec.input.inputToken, exec.preFee.receiver, exec.preFee.amount); - } - - // 3. optional swap, accounted via decoded returndata - address finalToken; - uint256 finalAmount; - if (exec.swap.target != address(0)) { - (finalToken, finalAmount) = _performSwap(exec, swapCallData); - } else { - if (exec.preFee.amount > exec.input.inputAmount) { - revert InsufficientFunds(); - } - finalToken = exec.input.inputToken; - unchecked { - finalAmount = exec.input.inputAmount - exec.preFee.amount; - } - } - - // 4. optional post-swap fee in final token - if (exec.postFee.amount != 0) { - if (exec.postFee.amount > finalAmount) { - revert InsufficientFunds(); - } - CurrencyLib.transfer(finalToken, exec.postFee.receiver, exec.postFee.amount); - unchecked { - finalAmount -= exec.postFee.amount; - } - } - - // 5. bridge: splice, approve, call - _finishMonolithicBridge(exec, finalToken, finalAmount, bridgeCallData); - } - - function _finishMonolithicBridge( - MonolithicExecution calldata exec, - address finalToken, - uint256 finalAmount, - bytes calldata bridgeCallData - ) internal { - _doBridge(finalToken, finalAmount, exec.bridge, bridgeCallData, exec.flags); - } - - function _performSwap(MonolithicExecution calldata exec, bytes calldata swapCallData) - internal - returns (address finalToken, uint256 finalAmount) - { - if (exec.swap.approvalSpender != address(0) && exec.input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - uint256 swapInput; - unchecked { - swapInput = exec.input.inputAmount - exec.preFee.amount; - } - SafeTransferLib.safeApproveWithRetry(exec.input.inputToken, exec.swap.approvalSpender, swapInput); - } - - // Monolithic path: only `BALANCE_FLAG_BIT_MASK` is read for `_execSwap`; fee uses `preFee` / `postFee`, not bit 0. - // Swap output always lands at this contract; it feeds directly into the bridge step. - finalAmount = _execSwap(exec.swap, swapCallData, exec.flags & BALANCE_FLAG_BIT_MASK != 0, address(this)); - if (finalAmount < exec.swap.minOutput) revert SwapOutputInsufficient(); - finalToken = exec.swap.outputToken; - } - // ========================================================================= // Internal: swap / fee / bridge helpers // ========================================================================= @@ -705,7 +583,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } // ========================================================================= - // Internal: simple call dispatcher (used by monolithic path) + // Internal: simple call dispatcher // ========================================================================= function _doCall(address target, uint256 value, bytes memory data) internal { From 85a7d2b01ec1251e742d2f985411b073e8526e19 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 15:35:16 +0530 Subject: [PATCH 18/42] feat: slither --- package.json | 3 ++- scripts/docker-slither.sh | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 scripts/docker-slither.sh diff --git a/package.json b/package.json index feeaa09..4756cca 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "compile": "hardhat compile", "deploy:v2": "hardhat run scripts/deploy/deployBungeeOpenRouterV2.ts --network", - "typechain": "hardhat typechain" + "typechain": "hardhat typechain", + "slither": "bash scripts/docker-slither.sh" }, "devDependencies": { "@arbitrum/sdk": "^4.0.5", diff --git a/scripts/docker-slither.sh b/scripts/docker-slither.sh new file mode 100644 index 0000000..b9e25d5 --- /dev/null +++ b/scripts/docker-slither.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Run Slither inside trailofbits/eth-security-toolbox with Foundry compilation. +# Uses forge in the container instead of solc-select (avoids solc-select 403s on binary list fetch). +# Remappings are read from remappings.txt so npm does not need a multiline tr(1) in package.json. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ ! -f remappings.txt ]]; then + echo "docker-slither.sh: remappings.txt not found in ${ROOT}" >&2 + exit 1 +fi + +REMAPS="" +while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ -n "${line}" ]]; then + if [[ -n "${REMAPS}" ]]; then + REMAPS+=" " + fi + REMAPS+="${line}" + fi +done < remappings.txt + +SLITHER_ARGS=("$@") +if [[ ${#SLITHER_ARGS[@]} -eq 0 ]]; then + SLITHER_ARGS=(.) +fi +if [[ ${#SLITHER_ARGS[@]} -eq 1 && "${SLITHER_ARGS[0]}" == *.sol ]]; then + sol_file="${SLITHER_ARGS[0]}" + base="$(basename "${sol_file}")" + # --include-paths takes a regex; escape dots so ".sol" is literal. + include_regex="${base//./\\.}" + SLITHER_ARGS=(. --include-paths "${include_regex}") +fi + +DOCKER_FLAGS=( + -t + --rm + -v "${ROOT}:/poc-openrouter" + -w /poc-openrouter + --platform linux/amd64 + --entrypoint slither +) + +# Do not mount ~/.foundry: host macOS forge/solc binaries break Linux exec (126 / Exec format error). + +exec docker run "${DOCKER_FLAGS[@]}" trailofbits/eth-security-toolbox "${SLITHER_ARGS[@]}" \ + --compile-force-framework forge \ + --solc-remaps "${REMAPS}" \ + --solc-args '--allow-paths /' From 9775932ae1924969d14402530fde8e4f8acd739e Mon Sep 17 00:00:00 2001 From: arthcp Date: Tue, 19 May 2026 14:40:56 +0400 Subject: [PATCH 19/42] fix: fork tests --- foundry.toml | 5 +++ src/combined/BungeeOpenRouterV2Unchecked.sol | 1 - test/poc/OneInchCctpOpenRouterPoC.t.sol | 39 ++++++++----------- test/poc/OpenOceanAcrossOpenRouterPoC.t.sol | 2 +- ...OpenOceanStargateNativeOpenRouterPoC.t.sol | 3 +- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/foundry.toml b/foundry.toml index 34b3732..b73e29d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,10 +7,15 @@ evm_version = "cancun" optimizer = true optimizer_runs = 2_000 via_ir = false +no_match_path = "test/poc/**" remappings = [ "solady/=lib/solady/", "forge-std/=lib/forge-std/src/", ] +[profile.poc] +match_path = "test/poc/*.t.sol" +no_match_path = "NO_MATCHING_TEST_PATH" + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index d0d4cce..370139d 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -652,4 +652,3 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { RescueFundsLib.rescueFunds(token_, rescueTo_, amount_); } } - diff --git a/test/poc/OneInchCctpOpenRouterPoC.t.sol b/test/poc/OneInchCctpOpenRouterPoC.t.sol index 6d5ee09..7a31481 100644 --- a/test/poc/OneInchCctpOpenRouterPoC.t.sol +++ b/test/poc/OneInchCctpOpenRouterPoC.t.sol @@ -84,7 +84,7 @@ contract OneInchCctpOpenRouterPoCTest is Test { POLYGON_AAVE, inputAmount, payable(address(router)), - abi.encodeCall(router.performModularExecution, (actions)) + abi.encodeCall(router.performModularExecution, (keccak256("one-inch-cctp-modular"), actions)) ); uint256 executeGasUsed = gasBeforeExecute - gasleft(); emit log_named_uint("AllowanceHolder.exec -> router.performModularExecution gas used", executeGasUsed); @@ -119,20 +119,15 @@ contract OneInchCctpOpenRouterPoCTest is Test { uint256 feeRecipientUsdcBefore = ERC20(POLYGON_USDC).balanceOf(FEE_RECIPIENT); uint256 usdcSupplyBefore = ERC20(POLYGON_USDC).totalSupply(); - (Router.MonolithicExecution memory exec, bytes memory swapCallData, bytes memory bridgeCallData) = - _buildMonolithicExecution(inputAmount, vm.parseBytes(ONEINCH_SWAP_CALLDATA)); + bytes memory routerCallData = _swapAndBridgeCallData(inputAmount, vm.parseBytes(ONEINCH_SWAP_CALLDATA)); vm.prank(FIXTURE_RECIPIENT); uint256 gasBeforeExecute = gasleft(); IAllowanceHolder(address(ALLOWANCE_HOLDER)).exec( - address(router), - POLYGON_AAVE, - inputAmount, - payable(address(router)), - abi.encodeCall(router.performExecution, (exec, swapCallData, bridgeCallData)) + address(router), POLYGON_AAVE, inputAmount, payable(address(router)), routerCallData ); uint256 executeGasUsed = gasBeforeExecute - gasleft(); - emit log_named_uint("AllowanceHolder.exec -> router.performExecution gas used", executeGasUsed); + emit log_named_uint("AllowanceHolder.exec -> router.swapAndBridge gas used", executeGasUsed); _assertMonolithicPocResult(router, feeRecipientUsdcBefore, usdcSupplyBefore); } @@ -236,18 +231,18 @@ contract OneInchCctpOpenRouterPoCTest is Test { ); } - function _buildMonolithicExecution(uint256 inputAmount, bytes memory swapCalldata) + function _swapAndBridgeCallData(uint256 inputAmount, bytes memory swapCalldata) internal pure - returns (Router.MonolithicExecution memory exec, bytes memory swapCallData, bytes memory bridgeCallData) + returns (bytes memory) { - swapCallData = swapCalldata; - bridgeCallData = _emptyDepositForBurnCalldata(); - - exec = Router.MonolithicExecution({ - input: Router.InputData({user: FIXTURE_RECIPIENT, inputToken: POLYGON_AAVE, inputAmount: inputAmount}), - preFee: Router.FeeData({receiver: address(0), amount: 0}), - swap: Router.SwapData({ + return abi.encodeWithSelector( + Router.swapAndBridge.selector, + keccak256("one-inch-cctp-swap-and-bridge"), + Router.InputData({user: FIXTURE_RECIPIENT, inputToken: POLYGON_AAVE, inputAmount: inputAmount}), + uint256(0x01 | 0x08 | (uint256(4) << 16)), + Router.FeeData({receiver: FEE_RECIPIENT, amount: ROUTE_FEE_USDC}), + Router.SwapData({ target: ONEINCH_SWAP_TARGET, approvalSpender: ONEINCH_SWAP_TARGET, outputToken: POLYGON_USDC, @@ -255,10 +250,10 @@ contract OneInchCctpOpenRouterPoCTest is Test { minOutput: EXPECTED_SWAP_OUTPUT_USDC, returnDataWordOffset: 0 }), - postFee: Router.FeeData({receiver: FEE_RECIPIENT, amount: ROUTE_FEE_USDC}), - bridge: Router.BridgeData({target: CCTP_TOKEN_MESSENGER_V2, approvalSpender: CCTP_TOKEN_MESSENGER_V2, value: 0}), - flags: 0x08 | (uint256(4) << 16) - }); + swapCalldata, + Router.BridgeData({target: CCTP_TOKEN_MESSENGER_V2, approvalSpender: CCTP_TOKEN_MESSENGER_V2, value: 0}), + _emptyDepositForBurnCalldata() + ); } function _assertPocResult( diff --git a/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol b/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol index 537f5fa..34c9db1 100644 --- a/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol +++ b/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol @@ -67,7 +67,7 @@ contract OpenOceanAcrossOpenRouterPoCTest is Test { uint256 spokePoolWethBefore = ERC20(ARBITRUM_WETH).balanceOf(ACROSS_ARBITRUM_SPOKE_POOL); uint256 gasBeforeExecute = gasleft(); - bytes[] memory results = router.performModularExecution(actions); + bytes[] memory results = router.performModularExecution(keccak256("open-ocean-across-modular"), actions); uint256 executeGasUsed = gasBeforeExecute - gasleft(); emit log_named_uint("router.performModularExecution gas used", executeGasUsed); diff --git a/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol b/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol index b653b75..272a76e 100644 --- a/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol +++ b/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol @@ -101,7 +101,8 @@ contract OpenOceanStargateNativeOpenRouterPoCTest is Test { ); uint256 gasBeforeExecute = gasleft(); - bytes[] memory results = router.performModularExecution(actions); + bytes[] memory results = + router.performModularExecution(keccak256("open-ocean-stargate-native-modular"), actions); uint256 executeGasUsed = gasBeforeExecute - gasleft(); emit log_named_uint("router.performModularExecution gas used", executeGasUsed); From 3da9283dbcc36449ac352f1a898739ee5bd3f91a Mon Sep 17 00:00:00 2001 From: arthcp Date: Tue, 19 May 2026 14:47:59 +0400 Subject: [PATCH 20/42] feat: mock tests --- .../BungeeOpenRouterV2UncheckedBridge.t.sol | 193 +++++++++ .../BungeeOpenRouterV2UncheckedSwap.t.sol | 303 ++++++++++++++ ...eeOpenRouterV2UncheckedSwapAndBridge.t.sol | 162 ++++++++ .../BungeeOpenRouterV2UncheckedTestBase.sol | 376 ++++++++++++++++++ 4 files changed, 1034 insertions(+) create mode 100644 test/combined/BungeeOpenRouterV2UncheckedBridge.t.sol create mode 100644 test/combined/BungeeOpenRouterV2UncheckedSwap.t.sol create mode 100644 test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol create mode 100644 test/combined/BungeeOpenRouterV2UncheckedTestBase.sol diff --git a/test/combined/BungeeOpenRouterV2UncheckedBridge.t.sol b/test/combined/BungeeOpenRouterV2UncheckedBridge.t.sol new file mode 100644 index 0000000..2e9010e --- /dev/null +++ b/test/combined/BungeeOpenRouterV2UncheckedBridge.t.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.25; + +import {BungeeOpenRouterV2Unchecked as Router} from "../../src/combined/BungeeOpenRouterV2Unchecked.sol"; +import {BungeeOpenRouterV2UncheckedTestBase} from "./BungeeOpenRouterV2UncheckedTestBase.sol"; + +contract BungeeOpenRouterV2UncheckedBridgeTest is BungeeOpenRouterV2UncheckedTestBase { + function test_bridge_erc20() public { + _deal(address(inputToken), USER, INPUT_AMOUNT); + _approveInputToken(INPUT_AMOUNT); + + _assertTokenBalances( + address(inputToken), + Balances({ + user: INPUT_AMOUNT, + router: 0, + swapTarget: 0, + bridgeTarget: 0, + receiver: 0, + feeRecipient: 0, + allowanceHolder: 0, + testContract: 0 + }), + "input token initial" + ); + + _execBridge( + address(inputToken), + INPUT_AMOUNT, + 0, + Router.FeeData({receiver: address(0), amount: 0}), + _bridgeData(address(inputToken), 0), + _bridgeCallData(address(inputToken), INPUT_AMOUNT) + ); + + _assertTokenBalances( + address(inputToken), + Balances({ + user: 0, + router: 0, + swapTarget: 0, + bridgeTarget: INPUT_AMOUNT, + receiver: 0, + feeRecipient: 0, + allowanceHolder: 0, + testContract: 0 + }), + "input token final" + ); + assertEq(bridgeTarget.receivedToken(), address(inputToken)); + assertEq(bridgeTarget.receivedAmount(), INPUT_AMOUNT); + } + + function test_bridge_native() public { + vm.deal(USER, INPUT_AMOUNT); + uint256 testContractBalance = address(this).balance; + + _assertTokenBalances( + NATIVE_TOKEN, + Balances({ + user: INPUT_AMOUNT, + router: 0, + swapTarget: 0, + bridgeTarget: 0, + receiver: 0, + feeRecipient: 0, + allowanceHolder: 0, + testContract: testContractBalance + }), + "native initial" + ); + + _execBridge( + NATIVE_TOKEN, + INPUT_AMOUNT, + INPUT_AMOUNT, + Router.FeeData({receiver: address(0), amount: 0}), + _bridgeData(NATIVE_TOKEN, INPUT_AMOUNT), + _bridgeCallData(NATIVE_TOKEN, INPUT_AMOUNT) + ); + + _assertTokenBalances( + NATIVE_TOKEN, + Balances({ + user: 0, + router: 0, + swapTarget: 0, + bridgeTarget: INPUT_AMOUNT, + receiver: 0, + feeRecipient: 0, + allowanceHolder: 0, + testContract: testContractBalance + }), + "native final" + ); + assertEq(bridgeTarget.receivedToken(), NATIVE_TOKEN); + assertEq(bridgeTarget.receivedAmount(), INPUT_AMOUNT); + } + + function test_bridge_withErc20Fee() public { + uint256 bridgeAmount = INPUT_AMOUNT - FEE_AMOUNT; + _deal(address(inputToken), USER, INPUT_AMOUNT); + _approveInputToken(INPUT_AMOUNT); + + _assertTokenBalances( + address(inputToken), + Balances({ + user: INPUT_AMOUNT, + router: 0, + swapTarget: 0, + bridgeTarget: 0, + receiver: 0, + feeRecipient: 0, + allowanceHolder: 0, + testContract: 0 + }), + "input token initial" + ); + + _execBridge( + address(inputToken), + INPUT_AMOUNT, + 0, + Router.FeeData({receiver: FEE_RECIPIENT, amount: FEE_AMOUNT}), + _bridgeData(address(inputToken), 0), + _bridgeCallData(address(inputToken), bridgeAmount) + ); + + _assertTokenBalances( + address(inputToken), + Balances({ + user: 0, + router: 0, + swapTarget: 0, + bridgeTarget: bridgeAmount, + receiver: 0, + feeRecipient: FEE_AMOUNT, + allowanceHolder: 0, + testContract: 0 + }), + "input token final" + ); + assertEq(bridgeTarget.receivedToken(), address(inputToken)); + assertEq(bridgeTarget.receivedAmount(), bridgeAmount); + } + + function test_bridge_withNativeFee() public { + uint256 bridgeAmount = INPUT_AMOUNT - FEE_AMOUNT; + vm.deal(USER, INPUT_AMOUNT); + uint256 testContractBalance = address(this).balance; + + _assertTokenBalances( + NATIVE_TOKEN, + Balances({ + user: INPUT_AMOUNT, + router: 0, + swapTarget: 0, + bridgeTarget: 0, + receiver: 0, + feeRecipient: 0, + allowanceHolder: 0, + testContract: testContractBalance + }), + "native initial" + ); + + _execBridge( + NATIVE_TOKEN, + INPUT_AMOUNT, + INPUT_AMOUNT, + Router.FeeData({receiver: FEE_RECIPIENT, amount: FEE_AMOUNT}), + _bridgeData(NATIVE_TOKEN, bridgeAmount), + _bridgeCallData(NATIVE_TOKEN, bridgeAmount) + ); + + _assertTokenBalances( + NATIVE_TOKEN, + Balances({ + user: 0, + router: 0, + swapTarget: 0, + bridgeTarget: bridgeAmount, + receiver: 0, + feeRecipient: FEE_AMOUNT, + allowanceHolder: 0, + testContract: testContractBalance + }), + "native final" + ); + assertEq(bridgeTarget.receivedToken(), NATIVE_TOKEN); + assertEq(bridgeTarget.receivedAmount(), bridgeAmount); + } +} diff --git a/test/combined/BungeeOpenRouterV2UncheckedSwap.t.sol b/test/combined/BungeeOpenRouterV2UncheckedSwap.t.sol new file mode 100644 index 0000000..38a3244 --- /dev/null +++ b/test/combined/BungeeOpenRouterV2UncheckedSwap.t.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.25; + +import {BungeeOpenRouterV2Unchecked as Router} from "../../src/combined/BungeeOpenRouterV2Unchecked.sol"; +import {BungeeOpenRouterV2UncheckedTestBase} from "./BungeeOpenRouterV2UncheckedTestBase.sol"; + +contract BungeeOpenRouterV2UncheckedSwapTest is BungeeOpenRouterV2UncheckedTestBase { + function test_swapWithReturnData() public { + _deal(address(inputToken), USER, INPUT_AMOUNT); + _deal(address(outputToken), address(swapTarget), SWAP_OUTPUT_AMOUNT); + _approveInputToken(INPUT_AMOUNT); + + _assertERC20Balances(address(inputToken), INPUT_AMOUNT, 0, 0, 0, "before input token"); + _assertERC20Balances(address(outputToken), 0, SWAP_OUTPUT_AMOUNT, 0, 0, "before output token"); + _assertNativeBalances(0, 0, 0, 0, "before native"); + + uint256 finalAmount = _execSwap( + SwapParams({ + input: address(inputToken), + inputAmount: INPUT_AMOUNT, + value: 0, + receiver: RECEIVER, + flags: 0, + fee: _feeData(0), + swapData: _swapData(address(inputToken), address(outputToken), SWAP_OUTPUT_AMOUNT, true), + swapCallData: _swapCallData( + address(inputToken), address(outputToken), INPUT_AMOUNT, SWAP_OUTPUT_AMOUNT, RECEIVER + ) + }) + ); + + assertEq(finalAmount, SWAP_OUTPUT_AMOUNT, "final amount"); + + _assertERC20Balances(address(inputToken), 0, INPUT_AMOUNT, 0, 0, "after input token"); + _assertERC20Balances(address(outputToken), 0, 0, SWAP_OUTPUT_AMOUNT, 0, "after output token"); + _assertNativeBalances(0, 0, 0, 0, "after native"); + _assertSwapInput(address(inputToken), INPUT_AMOUNT); + } + + function test_swapWithoutReturnDataUsesBalanceDelta() public { + _deal(address(inputToken), USER, INPUT_AMOUNT); + _deal(address(outputToken), address(swapTarget), SWAP_OUTPUT_AMOUNT); + _approveInputToken(INPUT_AMOUNT); + + _assertERC20Balances(address(inputToken), INPUT_AMOUNT, 0, 0, 0, "before input token"); + _assertERC20Balances(address(outputToken), 0, SWAP_OUTPUT_AMOUNT, 0, 0, "before output token"); + _assertNativeBalances(0, 0, 0, 0, "before native"); + + uint256 finalAmount = _execSwap( + SwapParams({ + input: address(inputToken), + inputAmount: INPUT_AMOUNT, + value: 0, + receiver: RECEIVER, + flags: BALANCE_FLAG_BIT_MASK, + fee: _feeData(0), + swapData: _swapData(address(inputToken), address(outputToken), SWAP_OUTPUT_AMOUNT, false), + swapCallData: _swapNoReturnCallData( + address(inputToken), address(outputToken), INPUT_AMOUNT, SWAP_OUTPUT_AMOUNT, RECEIVER + ) + }) + ); + + assertEq(finalAmount, SWAP_OUTPUT_AMOUNT, "final amount"); + + _assertERC20Balances(address(inputToken), 0, INPUT_AMOUNT, 0, 0, "after input token"); + _assertERC20Balances(address(outputToken), 0, 0, SWAP_OUTPUT_AMOUNT, 0, "after output token"); + _assertNativeBalances(0, 0, 0, 0, "after native"); + _assertSwapInput(address(inputToken), INPUT_AMOUNT); + } + + function test_swapERC20ToNative() public { + _deal(address(inputToken), USER, INPUT_AMOUNT); + _deal(NATIVE_TOKEN, address(swapTarget), SWAP_OUTPUT_AMOUNT); + _approveInputToken(INPUT_AMOUNT); + + _assertERC20Balances(address(inputToken), INPUT_AMOUNT, 0, 0, 0, "before input token"); + _assertNativeBalances(0, SWAP_OUTPUT_AMOUNT, 0, 0, "before native"); + + uint256 finalAmount = _execSwap( + SwapParams({ + input: address(inputToken), + inputAmount: INPUT_AMOUNT, + value: 0, + receiver: RECEIVER, + flags: 0, + fee: _feeData(0), + swapData: _swapData(address(inputToken), NATIVE_TOKEN, SWAP_OUTPUT_AMOUNT, true), + swapCallData: _swapCallData(address(inputToken), NATIVE_TOKEN, INPUT_AMOUNT, SWAP_OUTPUT_AMOUNT, RECEIVER) + }) + ); + + assertEq(finalAmount, SWAP_OUTPUT_AMOUNT, "final amount"); + + _assertERC20Balances(address(inputToken), 0, INPUT_AMOUNT, 0, 0, "after input token"); + _assertNativeBalances(0, 0, SWAP_OUTPUT_AMOUNT, 0, "after native"); + + _assertSwapInput(address(inputToken), INPUT_AMOUNT); + } + + function test_swapNativeToERC20() public { + _deal(NATIVE_TOKEN, USER, INPUT_AMOUNT); + _deal(address(outputToken), address(swapTarget), SWAP_OUTPUT_AMOUNT); + + _assertNativeBalances(INPUT_AMOUNT, 0, 0, 0, "before native"); + _assertERC20Balances(address(outputToken), 0, SWAP_OUTPUT_AMOUNT, 0, 0, "before output token"); + + uint256 finalAmount = _execSwap( + SwapParams({ + input: NATIVE_TOKEN, + inputAmount: INPUT_AMOUNT, + value: INPUT_AMOUNT, + receiver: RECEIVER, + flags: 0, + fee: _feeData(0), + swapData: _swapData(NATIVE_TOKEN, address(outputToken), SWAP_OUTPUT_AMOUNT, true), + swapCallData: _swapCallData(NATIVE_TOKEN, address(outputToken), INPUT_AMOUNT, SWAP_OUTPUT_AMOUNT, RECEIVER) + }) + ); + + assertEq(finalAmount, SWAP_OUTPUT_AMOUNT, "final amount"); + + _assertNativeBalances(0, INPUT_AMOUNT, 0, 0, "after native"); + _assertERC20Balances(address(outputToken), 0, 0, SWAP_OUTPUT_AMOUNT, 0, "after output token"); + + _assertSwapInput(NATIVE_TOKEN, INPUT_AMOUNT); + } + + function test_swapERC20ToERC20() public { + test_swapWithReturnData(); + } + + function test_prefeeSwapWithNativeFee() public { + uint256 swapInput = INPUT_AMOUNT - FEE_AMOUNT; + + _deal(NATIVE_TOKEN, USER, INPUT_AMOUNT); + _deal(address(outputToken), address(swapTarget), SWAP_OUTPUT_AMOUNT); + + _assertNativeBalances(INPUT_AMOUNT, 0, 0, 0, "before native"); + _assertERC20Balances(address(outputToken), 0, SWAP_OUTPUT_AMOUNT, 0, 0, "before output token"); + + uint256 finalAmount = _execSwap( + SwapParams({ + input: NATIVE_TOKEN, + inputAmount: INPUT_AMOUNT, + value: INPUT_AMOUNT, + receiver: RECEIVER, + flags: 0, + fee: _feeData(FEE_AMOUNT), + swapData: _swapDataWithValue(NATIVE_TOKEN, address(outputToken), SWAP_OUTPUT_AMOUNT, swapInput), + swapCallData: _swapCallData(NATIVE_TOKEN, address(outputToken), swapInput, SWAP_OUTPUT_AMOUNT, RECEIVER) + }) + ); + + assertEq(finalAmount, SWAP_OUTPUT_AMOUNT, "final amount"); + + _assertNativeBalances(0, swapInput, 0, FEE_AMOUNT, "after native"); + _assertERC20Balances(address(outputToken), 0, 0, SWAP_OUTPUT_AMOUNT, 0, "after output token"); + + _assertSwapInput(NATIVE_TOKEN, swapInput); + } + + function test_prefeeSwapWithERC20Fee() public { + uint256 swapInput = INPUT_AMOUNT - FEE_AMOUNT; + + _deal(address(inputToken), USER, INPUT_AMOUNT); + _deal(address(outputToken), address(swapTarget), SWAP_OUTPUT_AMOUNT); + _approveInputToken(INPUT_AMOUNT); + + _assertERC20Balances(address(inputToken), INPUT_AMOUNT, 0, 0, 0, "before input token"); + _assertERC20Balances(address(outputToken), 0, SWAP_OUTPUT_AMOUNT, 0, 0, "before output token"); + _assertNativeBalances(0, 0, 0, 0, "before native"); + + uint256 finalAmount = _execSwap( + SwapParams({ + input: address(inputToken), + inputAmount: INPUT_AMOUNT, + value: 0, + receiver: RECEIVER, + flags: 0, + fee: _feeData(FEE_AMOUNT), + swapData: _swapData(address(inputToken), address(outputToken), SWAP_OUTPUT_AMOUNT, true), + swapCallData: _swapCallData( + address(inputToken), address(outputToken), swapInput, SWAP_OUTPUT_AMOUNT, RECEIVER + ) + }) + ); + + assertEq(finalAmount, SWAP_OUTPUT_AMOUNT, "final amount"); + + _assertERC20Balances(address(inputToken), 0, swapInput, 0, FEE_AMOUNT, "after input token"); + _assertERC20Balances(address(outputToken), 0, 0, SWAP_OUTPUT_AMOUNT, 0, "after output token"); + _assertNativeBalances(0, 0, 0, 0, "after native"); + _assertSwapInput(address(inputToken), swapInput); + } + + function test_postfeeSwapWithNativeFee() public { + _deal(address(inputToken), USER, INPUT_AMOUNT); + _deal(NATIVE_TOKEN, address(swapTarget), SWAP_OUTPUT_AMOUNT); + _approveInputToken(INPUT_AMOUNT); + + _assertERC20Balances(address(inputToken), INPUT_AMOUNT, 0, 0, 0, "before input token"); + _assertNativeBalances(0, SWAP_OUTPUT_AMOUNT, 0, 0, "before native"); + + uint256 finalAmount = _execSwap( + SwapParams({ + input: address(inputToken), + inputAmount: INPUT_AMOUNT, + value: 0, + receiver: RECEIVER, + flags: FEE_FLAG_BIT_MASK, + fee: _feeData(FEE_AMOUNT), + swapData: _swapData(address(inputToken), NATIVE_TOKEN, SWAP_OUTPUT_AMOUNT, true), + swapCallData: _swapCallData( + address(inputToken), NATIVE_TOKEN, INPUT_AMOUNT, SWAP_OUTPUT_AMOUNT, address(router) + ) + }) + ); + + assertEq(finalAmount, SWAP_OUTPUT_AMOUNT - FEE_AMOUNT, "final amount"); + + _assertERC20Balances(address(inputToken), 0, INPUT_AMOUNT, 0, 0, "after input token"); + _assertNativeBalances(0, 0, SWAP_OUTPUT_AMOUNT - FEE_AMOUNT, FEE_AMOUNT, "after native"); + + _assertSwapInput(address(inputToken), INPUT_AMOUNT); + } + + function test_postfeeSwapWithERC20Fee() public { + _deal(NATIVE_TOKEN, USER, INPUT_AMOUNT); + _deal(address(outputToken), address(swapTarget), SWAP_OUTPUT_AMOUNT); + + _assertNativeBalances(INPUT_AMOUNT, 0, 0, 0, "before native"); + _assertERC20Balances(address(outputToken), 0, SWAP_OUTPUT_AMOUNT, 0, 0, "before output token"); + + uint256 finalAmount = _execSwap( + SwapParams({ + input: NATIVE_TOKEN, + inputAmount: INPUT_AMOUNT, + value: INPUT_AMOUNT, + receiver: RECEIVER, + flags: FEE_FLAG_BIT_MASK, + fee: _feeData(FEE_AMOUNT), + swapData: _swapData(NATIVE_TOKEN, address(outputToken), SWAP_OUTPUT_AMOUNT, true), + swapCallData: _swapCallData( + NATIVE_TOKEN, address(outputToken), INPUT_AMOUNT, SWAP_OUTPUT_AMOUNT, address(router) + ) + }) + ); + + assertEq(finalAmount, SWAP_OUTPUT_AMOUNT - FEE_AMOUNT, "final amount"); + + _assertNativeBalances(0, INPUT_AMOUNT, 0, 0, "after native"); + _assertERC20Balances( + address(outputToken), 0, 0, SWAP_OUTPUT_AMOUNT - FEE_AMOUNT, FEE_AMOUNT, "after output token" + ); + + _assertSwapInput(NATIVE_TOKEN, INPUT_AMOUNT); + } + + function _feeData(uint256 amount) private pure returns (Router.FeeData memory) { + return Router.FeeData({receiver: FEE_RECIPIENT, amount: amount}); + } + + function _emptyNativeBalances() private view returns (Balances memory balances) { + balances.testContract = address(this).balance; + } + + function _assertERC20Balances( + address token, + uint256 user, + uint256 swapTargetBalance, + uint256 receiver, + uint256 feeRecipient, + string memory label + ) private view { + Balances memory balances = _emptyBalances(); + balances.user = user; + balances.swapTarget = swapTargetBalance; + balances.receiver = receiver; + balances.feeRecipient = feeRecipient; + _assertTokenBalances(token, balances, label); + } + + function _assertNativeBalances( + uint256 user, + uint256 swapTargetBalance, + uint256 receiver, + uint256 feeRecipient, + string memory label + ) private view { + Balances memory balances = _emptyNativeBalances(); + balances.user = user; + balances.swapTarget = swapTargetBalance; + balances.receiver = receiver; + balances.feeRecipient = feeRecipient; + _assertTokenBalances(NATIVE_TOKEN, balances, label); + } + + function _assertSwapInput(address input, uint256 amount) private view { + assertEq(swapTarget.storedInputToken(), input, "swap input token"); + assertEq(swapTarget.storedInputAmount(), amount, "swap input amount"); + } +} diff --git a/test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol b/test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol new file mode 100644 index 0000000..b3c0efd --- /dev/null +++ b/test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.25; + +import {BungeeOpenRouterV2Unchecked as Router} from "../../src/combined/BungeeOpenRouterV2Unchecked.sol"; +import {BungeeOpenRouterV2UncheckedTestBase} from "./BungeeOpenRouterV2UncheckedTestBase.sol"; + +contract BungeeOpenRouterV2UncheckedSwapAndBridgeTest is BungeeOpenRouterV2UncheckedTestBase { + enum FeeMode { + None, + Pre, + Post + } + + struct Scenario { + address input; + address output; + FeeMode feeMode; + uint256 swapInput; + uint256 bridgeAmount; + } + + function test_swapAndBridge_noFee_erc20ToNative() public { + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.None); + } + + function test_swapAndBridge_noFee_nativeToErc20() public { + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.None); + } + + function test_swapAndBridge_noFee_erc20ToErc20() public { + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.None); + } + + function test_swapAndBridge_prefee_erc20ToNative() public { + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Pre); + } + + function test_swapAndBridge_prefee_nativeToErc20() public { + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Pre); + } + + function test_swapAndBridge_prefee_erc20ToErc20() public { + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Pre); + } + + function test_swapAndBridge_postfee_erc20ToNative() public { + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Post); + } + + function test_swapAndBridge_postfee_nativeToErc20() public { + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Post); + } + + function test_swapAndBridge_postfee_erc20ToErc20() public { + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Post); + } + + function _runSwapAndBridge(address input, address output, FeeMode feeMode) internal { + Scenario memory scenario = _scenario(input, output, feeMode); + + _fundSwapAndBridge(scenario.input, scenario.output); + if (scenario.input != NATIVE_TOKEN) _approveInputToken(INPUT_AMOUNT); + + _assertSwapAndBridgeInitial(scenario.input, scenario.output); + _executeSwapAndBridge(scenario); + _assertSwapAndBridgeFinal(scenario); + + assertEq(swapTarget.storedInputToken(), scenario.input); + assertEq(swapTarget.storedInputAmount(), scenario.swapInput); + assertEq(bridgeTarget.receivedToken(), scenario.output); + assertEq(bridgeTarget.receivedAmount(), scenario.bridgeAmount); + } + + function _scenario(address input, address output, FeeMode feeMode) + internal + pure + returns (Scenario memory scenario) + { + scenario.input = input; + scenario.output = output; + scenario.feeMode = feeMode; + scenario.swapInput = _swapInput(feeMode); + scenario.bridgeAmount = _bridgeAmount(feeMode); + } + + function _executeSwapAndBridge(Scenario memory scenario) internal { + _execThroughAllowanceHolder( + scenario.input, + INPUT_AMOUNT, + scenario.input == NATIVE_TOKEN ? INPUT_AMOUNT : 0, + _swapAndBridgeCallData(scenario) + ); + } + + function _swapAndBridgeCallData(Scenario memory scenario) internal view returns (bytes memory) { + return abi.encodeCall( + router.swapAndBridge, + ( + keccak256("swap-and-bridge"), + Router.InputData({user: USER, inputToken: scenario.input, inputAmount: INPUT_AMOUNT}), + _flags(scenario.output, scenario.feeMode), + _fee(scenario.feeMode), + _swapDataWithValue( + scenario.input, + scenario.output, + SWAP_OUTPUT_AMOUNT, + scenario.input == NATIVE_TOKEN ? scenario.swapInput : 0 + ), + _swapNoReturnCallData( + scenario.input, scenario.output, scenario.swapInput, SWAP_OUTPUT_AMOUNT, address(router) + ), + _bridgeData(scenario.output, 0), + _bridgeCallData(scenario.output, 0) + ) + ); + } + + function _fundSwapAndBridge(address input, address output) internal { + _deal(input, USER, INPUT_AMOUNT); + _deal(output, address(swapTarget), SWAP_OUTPUT_AMOUNT); + } + + function _assertSwapAndBridgeInitial(address input, address output) internal view { + Balances memory inputBalances = _emptyBalancesFor(input); + inputBalances.user = INPUT_AMOUNT; + _assertTokenBalances(input, inputBalances, "input initial"); + Balances memory outputBalances = _emptyBalancesFor(output); + outputBalances.swapTarget = SWAP_OUTPUT_AMOUNT; + _assertTokenBalances(output, outputBalances, "output initial"); + } + + function _assertSwapAndBridgeFinal(Scenario memory scenario) internal view { + Balances memory inputBalances = _emptyBalancesFor(scenario.input); + inputBalances.swapTarget = scenario.swapInput; + inputBalances.feeRecipient = scenario.feeMode == FeeMode.Pre ? FEE_AMOUNT : 0; + _assertTokenBalances(scenario.input, inputBalances, "input final"); + Balances memory outputBalances = _emptyBalancesFor(scenario.output); + outputBalances.bridgeTarget = scenario.bridgeAmount; + outputBalances.feeRecipient = scenario.feeMode == FeeMode.Post ? FEE_AMOUNT : 0; + _assertTokenBalances(scenario.output, outputBalances, "output final"); + } + + function _swapInput(FeeMode feeMode) internal pure returns (uint256) { + return feeMode == FeeMode.Pre ? INPUT_AMOUNT - FEE_AMOUNT : INPUT_AMOUNT; + } + + function _bridgeAmount(FeeMode feeMode) internal pure returns (uint256) { + return feeMode == FeeMode.Post ? SWAP_OUTPUT_AMOUNT - FEE_AMOUNT : SWAP_OUTPUT_AMOUNT; + } + + function _flags(address output, FeeMode feeMode) internal pure returns (uint256) { + uint256 flags = BALANCE_FLAG_BIT_MASK; + if (output == NATIVE_TOKEN) flags |= BRIDGE_VALUE_FLAG_BIT_MASK; + if (feeMode == FeeMode.Post) flags |= FEE_FLAG_BIT_MASK; + return _bridgeAmountSpliceFlags(flags); + } + + function _fee(FeeMode feeMode) internal pure returns (Router.FeeData memory) { + if (feeMode == FeeMode.None) return Router.FeeData({receiver: address(0), amount: 0}); + return Router.FeeData({receiver: FEE_RECIPIENT, amount: FEE_AMOUNT}); + } +} diff --git a/test/combined/BungeeOpenRouterV2UncheckedTestBase.sol b/test/combined/BungeeOpenRouterV2UncheckedTestBase.sol new file mode 100644 index 0000000..3b33b75 --- /dev/null +++ b/test/combined/BungeeOpenRouterV2UncheckedTestBase.sol @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity =0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "solady/src/tokens/ERC20.sol"; + +import {BungeeOpenRouterV2Unchecked as Router} from "../../src/combined/BungeeOpenRouterV2Unchecked.sol"; +import {ALLOWANCE_HOLDER, IAllowanceHolder} from "../../src/common/interfaces/IAllowanceHolder.sol"; + +abstract contract BungeeOpenRouterV2UncheckedTestBase is Test { + uint256 internal constant FEE_FLAG_BIT_MASK = 0x01; + uint256 internal constant BALANCE_FLAG_BIT_MASK = 0x02; + uint256 internal constant BRIDGE_VALUE_FLAG_BIT_MASK = 0x04; + uint256 internal constant BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK = 0x08; + uint256 internal constant BRIDGE_AMOUNT_POSITION_SHIFT = 16; + uint256 internal constant BRIDGE_AMOUNT_CALLDATA_OFFSET = 36; + + address internal constant NATIVE_TOKEN = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + address internal constant USER = address(0xA11CE); + address internal constant RECEIVER = address(0xB0B); + address internal constant FEE_RECIPIENT = address(0xFEE); + + uint256 internal constant INPUT_AMOUNT = 100 ether; + uint256 internal constant SWAP_OUTPUT_AMOUNT = 175 ether; + uint256 internal constant FEE_AMOUNT = 7 ether; + + Router internal router; + MockERC20 internal inputToken; + MockERC20 internal outputToken; + MockSwap internal swapTarget; + MockBridge internal bridgeTarget; + + struct Balances { + uint256 user; + uint256 router; + uint256 swapTarget; + uint256 bridgeTarget; + uint256 receiver; + uint256 feeRecipient; + uint256 allowanceHolder; + uint256 testContract; + } + + struct SwapParams { + address input; + uint256 inputAmount; + uint256 value; + address receiver; + uint256 flags; + Router.FeeData fee; + Router.SwapData swapData; + bytes swapCallData; + } + + struct SwapAndBridgeParams { + address input; + uint256 inputAmount; + uint256 value; + uint256 flags; + Router.FeeData fee; + Router.SwapData swapData; + bytes swapCallData; + Router.BridgeData bridgeData; + bytes bridgeCallData; + } + + function setUp() public virtual { + vm.etch(address(ALLOWANCE_HOLDER), address(new MockAllowanceHolder()).code); + + router = new Router(address(this)); + inputToken = new MockERC20("Input Token", "IN"); + outputToken = new MockERC20("Output Token", "OUT"); + swapTarget = new MockSwap(); + bridgeTarget = new MockBridge(); + + vm.label(address(router), "router"); + vm.label(address(inputToken), "inputToken"); + vm.label(address(outputToken), "outputToken"); + vm.label(address(swapTarget), "swapTarget"); + vm.label(address(bridgeTarget), "bridgeTarget"); + vm.label(address(ALLOWANCE_HOLDER), "allowanceHolder"); + vm.label(USER, "user"); + vm.label(RECEIVER, "receiver"); + vm.label(FEE_RECIPIENT, "feeRecipient"); + } + + function _approveInputToken(uint256 amount) internal { + vm.prank(USER); + inputToken.approve(address(ALLOWANCE_HOLDER), amount); + } + + function _execThroughAllowanceHolder(address token, uint256 amount, uint256 value, bytes memory data) + internal + returns (bytes memory result) + { + vm.prank(USER); + result = IAllowanceHolder(address(ALLOWANCE_HOLDER)).exec{value: value}( + address(router), token, amount, payable(address(router)), data + ); + } + + function _execSwap(SwapParams memory params) internal returns (uint256 finalAmount) { + bytes memory result = _execThroughAllowanceHolder( + params.input, + params.inputAmount, + params.value, + abi.encodeCall( + router.swap, + ( + keccak256("swap"), + Router.InputData({user: USER, inputToken: params.input, inputAmount: params.inputAmount}), + params.receiver, + params.flags, + params.fee, + params.swapData, + params.swapCallData + ) + ) + ); + finalAmount = abi.decode(result, (uint256)); + } + + function _execBridge( + address input, + uint256 inputAmount, + uint256 value, + Router.FeeData memory fee, + Router.BridgeData memory bridgeData, + bytes memory bridgeCallData + ) internal { + _execThroughAllowanceHolder( + input, + inputAmount, + value, + abi.encodeCall( + router.bridge, + ( + keccak256("bridge"), + Router.InputData({user: USER, inputToken: input, inputAmount: inputAmount}), + fee, + bridgeData, + bridgeCallData + ) + ) + ); + } + + function _execSwapAndBridge(SwapAndBridgeParams memory params) internal { + _execThroughAllowanceHolder( + params.input, + params.inputAmount, + params.value, + abi.encodeCall( + router.swapAndBridge, + ( + keccak256("swap-and-bridge"), + Router.InputData({user: USER, inputToken: params.input, inputAmount: params.inputAmount}), + params.flags, + params.fee, + params.swapData, + params.swapCallData, + params.bridgeData, + params.bridgeCallData + ) + ) + ); + } + + function _swapData(address input, address output, uint256 outputAmount, bool useReturnData) + internal + view + returns (Router.SwapData memory) + { + return Router.SwapData({ + target: address(swapTarget), + approvalSpender: input == NATIVE_TOKEN ? address(0) : address(swapTarget), + outputToken: output, + value: input == NATIVE_TOKEN ? INPUT_AMOUNT : 0, + minOutput: outputAmount, + returnDataWordOffset: useReturnData ? 0 : 0 + }); + } + + function _swapDataWithValue(address input, address output, uint256 outputAmount, uint256 value) + internal + view + returns (Router.SwapData memory) + { + return Router.SwapData({ + target: address(swapTarget), + approvalSpender: input == NATIVE_TOKEN ? address(0) : address(swapTarget), + outputToken: output, + value: value, + minOutput: outputAmount, + returnDataWordOffset: 0 + }); + } + + function _bridgeData(address token, uint256 value) internal view returns (Router.BridgeData memory) { + return Router.BridgeData({ + target: address(bridgeTarget), + approvalSpender: token == NATIVE_TOKEN ? address(0) : address(bridgeTarget), + value: value + }); + } + + function _swapCallData(address input, address output, uint256 inputAmount, uint256 outputAmount, address receiver) + internal + pure + returns (bytes memory) + { + return abi.encodeCall(MockSwap.swap, (input, output, inputAmount, outputAmount, receiver)); + } + + function _swapNoReturnCallData( + address input, + address output, + uint256 inputAmount, + uint256 outputAmount, + address receiver + ) internal pure returns (bytes memory) { + return abi.encodeCall(MockSwap.swapNoReturn, (input, output, inputAmount, outputAmount, receiver)); + } + + function _bridgeCallData(address token, uint256 amount) internal pure returns (bytes memory) { + return abi.encodeCall(MockBridge.bridge, (token, amount)); + } + + function _bridgeAmountSpliceFlags(uint256 baseFlags) internal pure returns (uint256) { + return baseFlags | BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK + | (BRIDGE_AMOUNT_CALLDATA_OFFSET << BRIDGE_AMOUNT_POSITION_SHIFT); + } + + function _assertTokenBalances(address token, Balances memory expected, string memory label) internal view { + assertEq(_balanceOf(token, USER), expected.user, string.concat(label, ": user")); + assertEq(_balanceOf(token, address(router)), expected.router, string.concat(label, ": router")); + assertEq(_balanceOf(token, address(swapTarget)), expected.swapTarget, string.concat(label, ": swap")); + assertEq(_balanceOf(token, address(bridgeTarget)), expected.bridgeTarget, string.concat(label, ": bridge")); + assertEq(_balanceOf(token, RECEIVER), expected.receiver, string.concat(label, ": receiver")); + assertEq(_balanceOf(token, FEE_RECIPIENT), expected.feeRecipient, string.concat(label, ": fee recipient")); + assertEq( + _balanceOf(token, address(ALLOWANCE_HOLDER)), + expected.allowanceHolder, + string.concat(label, ": allowance holder") + ); + assertEq(_balanceOf(token, address(this)), expected.testContract, string.concat(label, ": test contract")); + } + + function _balanceOf(address token, address account) internal view returns (uint256) { + if (token == NATIVE_TOKEN) return account.balance; + return ERC20(token).balanceOf(account); + } + + function _emptyBalances() internal pure returns (Balances memory balances) {} + + function _emptyBalancesFor(address token) internal view returns (Balances memory balances) { + if (token == NATIVE_TOKEN) balances.testContract = address(this).balance; + } + + function _deal(address token, address account, uint256 amount) internal { + if (token == NATIVE_TOKEN) { + vm.deal(account, amount); + } else { + MockERC20(token).mint(account, amount); + } + } +} + +contract MockAllowanceHolder { + function exec(address, address, uint256, address payable target, bytes calldata data) + external + payable + returns (bytes memory result) + { + (bool success, bytes memory returndata) = target.call{value: msg.value}(bytes.concat(data, bytes20(msg.sender))); + if (!success) { + assembly ("memory-safe") { + revert(add(returndata, 0x20), mload(returndata)) + } + } + return returndata; + } + + function transferFrom(address token, address owner, address recipient, uint256 amount) external returns (bool) { + require(ERC20(token).transferFrom(owner, recipient, amount), "MockAllowanceHolder: transfer failed"); + return true; + } +} + +contract MockSwap { + address public storedInputToken; + uint256 public storedInputAmount; + + receive() external payable {} + + function swap(address inputToken, address outputToken, uint256 inputAmount, uint256 outputAmount, address receiver) + external + payable + returns (uint256) + { + _swap(inputToken, outputToken, inputAmount, outputAmount, receiver); + return outputAmount; + } + + function swapNoReturn( + address inputToken, + address outputToken, + uint256 inputAmount, + uint256 outputAmount, + address receiver + ) external payable { + _swap(inputToken, outputToken, inputAmount, outputAmount, receiver); + } + + function _swap(address inputToken, address outputToken, uint256 inputAmount, uint256 outputAmount, address receiver) + internal + { + storedInputToken = inputToken; + storedInputAmount += inputAmount; + + if (inputToken == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + require(msg.value == inputAmount, "MockSwap: bad native input"); + } else { + require(msg.value == 0, "MockSwap: unexpected value"); + require(ERC20(inputToken).transferFrom(msg.sender, address(this), inputAmount), "MockSwap: input failed"); + } + + if (outputToken == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + (bool success,) = receiver.call{value: outputAmount}(""); + require(success, "MockSwap: native output failed"); + } else { + require(ERC20(outputToken).transfer(receiver, outputAmount), "MockSwap: output failed"); + } + } +} + +contract MockBridge { + address public receivedToken; + uint256 public receivedAmount; + + receive() external payable {} + + function bridge(address token, uint256 amount) external payable { + receivedToken = token; + receivedAmount += amount; + + if (token == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + require(msg.value == amount, "MockBridge: bad native amount"); + } else { + require(msg.value == 0, "MockBridge: unexpected value"); + require(ERC20(token).transferFrom(msg.sender, address(this), amount), "MockBridge: transfer failed"); + } + } +} + +contract MockERC20 is ERC20 { + string private _name; + string private _symbol; + + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + function name() public view override returns (string memory) { + return _name; + } + + function symbol() public view override returns (string memory) { + return _symbol; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} From 783c88567170a935d88fbb4bebe01c8ef2b91ebf Mon Sep 17 00:00:00 2001 From: arthcp Date: Tue, 19 May 2026 14:54:05 +0400 Subject: [PATCH 21/42] chore: format --- src/minimal/BungeeOpenRouterMinimalAH.sol | 9 +++++---- src/modular/BungeeOpenRouterModularAH.sol | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/minimal/BungeeOpenRouterMinimalAH.sol b/src/minimal/BungeeOpenRouterMinimalAH.sol index fbd0401..6f398a5 100644 --- a/src/minimal/BungeeOpenRouterMinimalAH.sol +++ b/src/minimal/BungeeOpenRouterMinimalAH.sol @@ -12,15 +12,16 @@ import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext contract BungeeOpenRouterMinimalAH is BungeeOpenRouterMinimal, AllowanceHolderContext { error CallerNotSignedUser(); - constructor(address _owner, address _openRouterSigner) - BungeeOpenRouterMinimal(_owner, _openRouterSigner) - {} + constructor(address _owner, address _openRouterSigner) BungeeOpenRouterMinimal(_owner, _openRouterSigner) {} /// @notice AllowanceHolder-aware entrypoint. Same role as /// `BungeeOpenRouterModularAH.performExecutionAH` - prevents a /// signed payload meant for user A from being submitted via user /// B's AllowanceHolder.exec to grief user A's nonce. - function performExecutionAH(Execution calldata exec, address signedUser, bytes calldata signature) external payable { + function performExecutionAH(Execution calldata exec, address signedUser, bytes calldata signature) + external + payable + { if (_msgSender() != signedUser) { revert CallerNotSignedUser(); } diff --git a/src/modular/BungeeOpenRouterModularAH.sol b/src/modular/BungeeOpenRouterModularAH.sol index e0f37cb..de860b2 100644 --- a/src/modular/BungeeOpenRouterModularAH.sol +++ b/src/modular/BungeeOpenRouterModularAH.sol @@ -27,14 +27,15 @@ import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext contract BungeeOpenRouterModularAH is BungeeOpenRouterModular, AllowanceHolderContext { error CallerNotSignedUser(); - constructor(address _owner, address _openRouterSigner) - BungeeOpenRouterModular(_owner, _openRouterSigner) - {} + constructor(address _owner, address _openRouterSigner) BungeeOpenRouterModular(_owner, _openRouterSigner) {} /// @notice AllowanceHolder-aware entrypoint. Bind the signed payload to a /// specific user so it can only be submitted via that user's /// `AllowanceHolder.exec` call. - function performExecutionAH(Execution calldata exec, address signedUser, bytes calldata signature) external payable { + function performExecutionAH(Execution calldata exec, address signedUser, bytes calldata signature) + external + payable + { if (_msgSender() != signedUser) { revert CallerNotSignedUser(); } From 01eb83b9b9b300841ae04c0d314ebb39419cf288 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 17:14:39 +0530 Subject: [PATCH 22/42] refactor: renames, refactors, reorders --- src/combined/BungeeOpenRouterV2Unchecked.sol | 474 ++++++++++--------- 1 file changed, 241 insertions(+), 233 deletions(-) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index d0d4cce..3237fc2 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -1,38 +1,28 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; -import {Ownable} from "../common/utils/Ownable.sol"; +import {AccessControl} from "../common/utils/AccessControl.sol"; import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext.sol"; import {ALLOWANCE_HOLDER} from "../common/interfaces/IAllowanceHolder.sol"; import {BytesSpliceLib} from "../common/lib/BytesSpliceLib.sol"; import {CurrencyLib} from "../common/lib/CurrencyLib.sol"; import {RescueFundsLib} from "../common/lib/RescueFundsLib.sol"; - -/// @title BungeeOpenRouterV2Unchecked -/// @notice Identical execution logic to `BungeeOpenRouterV2` with all backend -/// signature verification removed. There are no nonce or deadline -/// fields; either entrypoint can be called by anyone. -/// -/// Fund safety still rests on AllowanceHolder's transient allowance -/// scoping (operator + owner + token): only the user whose address was -/// passed to `AllowanceHolder.exec` can authorise a pull of their own -/// funds. The `_msgSender() == user` check in `_pullFromUser` enforces -/// this at the contract level. -/// -/// Intended for development / testing environments where spinning up a -/// backend signer is inconvenient, or for operational flows where the -/// operator calls through AllowanceHolder directly without a separate -/// signing step. Do NOT deploy to production without adding an access -/// control layer appropriate to your threat model. -/// -/// @dev Both struct types mirror their `BungeeOpenRouterV2` counterparts but -/// drop the `nonce` and `deadline` fields, which are only relevant for -/// signature-based replay protection. -contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { +import {RESCUE_ROLE} from "../common/AccessRoles.sol"; + +/// @title BungeeOpenRouter +/// @notice Pull → optional fee → swap/bridge execution without backend signature verification. +/// Fund safety rests on AllowanceHolder's transient allowance scoping (operator + owner + token): +/// only the user whose address was passed to `AllowanceHolder.exec` can authorise a pull of +/// their own funds. The `_msgSender() == user` check in `_pullFromUser` enforces this. +contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { using SafeTransferLib for address; + // ========================================================================= + // Structs + // ========================================================================= + struct InputData { address user; address inputToken; @@ -89,19 +79,19 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // bit 3 : BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK (0x08) — splice finalAmount into bridge calldata // bit 2 : BRIDGE_VALUE_FLAG_BIT_MASK (0x04) — bridge msg.value: bridge.value alone vs finalAmount + bridge.value // bit 1 : BALANCE_FLAG_BIT_MASK (0x02) — swap output: returndata vs balance delta - // bit 0 : FEE_FLAG_BIT_MASK (0x01) — swap fee: pre- vs post-swap (standalone paths only) + // bit 0 : POST_FEE_FLAG_BIT_MASK (0x01) — swap fee: pre- vs post-swap // // Combined values for swap()/swapAndBridge(): // - // flags binary (low byte) postFee? balance-of output? bridge value? - // ───── ────────────────── ──────── ────────────────── ───────────── - // 0x00 00000000 no returndata word bridge.value - // 0x01 00000001 yes returndata word bridge.value - // 0x02 00000010 no balance delta on outputToken bridge.value - // 0x03 00000011 yes balance delta on outputToken bridge.value - // 0x04 00000100 no returndata word finalAmount + bridge.value + // flags binary (low byte) postFee? balance-of output? bridge value? + // ───── ────────────────── ──────── ────────────────── ───────────── + // 0x00 00000000 no returndata word bridge.value + // 0x01 00000001 yes returndata word bridge.value + // 0x02 00000010 no balance delta on outputToken bridge.value + // 0x03 00000011 yes balance delta on outputToken bridge.value + // 0x04 00000100 no returndata word finalAmount + bridge.value // - // FEE_FLAG_BIT_MASK selects bit 0 — fee timing. + // POST_FEE_FLAG_BIT_MASK selects bit 0 — fee timing. // Cleared — pull → deduct fee from input token → swap remainder. // Set — pull → swap full input → deduct fee from output token (after minOutput check on swap result). // @@ -119,7 +109,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // /// @dev Bit mask 0x01: post-swap fee path when `(flags & mask) != 0`; clear = pre-swap fee from input token. - uint256 internal constant FEE_FLAG_BIT_MASK = 0x01; + uint256 internal constant POST_FEE_FLAG_BIT_MASK = 0x01; /// @dev Bit mask 0x02: measure swap output by balance delta when `(flags & mask) != 0`; clear = returndata word. uint256 internal constant BALANCE_FLAG_BIT_MASK = 0x02; @@ -141,7 +131,6 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // ========================================================================= error SwapOutputInsufficient(); - error InsufficientFunds(); error InvalidExecution(); error CallerNotSignedUser(); error InsufficientMsgValue(); @@ -155,38 +144,44 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { // Events // ========================================================================= - event RequestExecuted(bytes32 indexed requestHash); + event RequestExecuted(bytes32 indexed quoteId); // ========================================================================= // Constructor // ========================================================================= - constructor(address _owner) Ownable(_owner) {} + /// @notice Deploys the router and grants `RESCUE_ROLE` to `_owner`. + /// @param _owner Initial contract owner and rescue-role holder. + constructor(address _owner) AccessControl(_owner) { + _grantRole(RESCUE_ROLE, _owner); + } + /// @notice Accepts native ETH forwarded with bridge/swap calls. receive() external payable {} // ========================================================================= - // External: standalone swap + // External functions // ========================================================================= /** * @notice Pull → optional pre/post fee → swap. - * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. - * @param receiver Address that ultimately receives the swap output (net of any post-swap fee). - * For pre-fee / no-fee: the swap router must be instructed (via `swapCallData`) to send - * tokens directly to `receiver`; the contract never holds the output. - * For post-fee: tokens land at this contract, fee is deducted, net is forwarded to `receiver`. - * @param flags Packed flags; OR masks then test with `flags & MASK != 0`. Masks: `FEE_FLAG_BIT_MASK` (0x01), `BALANCE_FLAG_BIT_MASK` (0x02). - * Common values: `0` (pre-fee, returndata), `1` (post-fee, returndata), - * `2` (pre-fee, balance delta), `3` (post-fee, balance delta). - * @param fee Set `amount` to 0 to skip fee collection. - * @dev `minOutput` is the minimum gross amount coming out of the swap (before any output-token fee). - * It is enforced immediately after `_execSwap`, then post-swap fee (if any) is collected. - * Pre-fee paths take the input-side fee before the swap; `minOutput` still guards the swap outcome. - * Bits are read with bitwise AND against each mask; omitting both masks ⇒ pre-fee + returndata. + * @param quoteId Caller-defined correlation id logged in `RequestExecuted`. + * @param input User, input token, and pull amount. + * @param receiver Address that ultimately receives the swap output (net of any post-swap fee). For pre-fee / no-fee: the swap router must + * be instructed (via `swapCallData`) to send tokens directly to `receiver`; the contract never holds the output. For post-fee: tokens land + * at this contract, fee is deducted, net is forwarded to `receiver`. + * @param flags Packed flags; OR masks then test with `flags & MASK != 0`. Masks: `POST_FEE_FLAG_BIT_MASK` (0x01), `BALANCE_FLAG_BIT_MASK` + * (0x02). Common values: `0` (pre-fee, returndata), `1` (post-fee, returndata), `2` (pre-fee, balance delta), `3` (post-fee, balance delta). + * @param fee Set `amount` to 0 to skip fee collection. + * @param swapData Swap target, spender, output token, value, `minOutput`, and returndata offset. + * @param swapCallData Calldata forwarded to `swapData.target`. + * @return finalAmount Gross swap output before any post-swap fee; net delivered to `receiver` on post-fee paths. + * @dev `minOutput` is the minimum gross amount coming out of the swap (before any output-token fee). It is enforced immediately after + * `_execSwap`, then post-swap fee (if any) is collected. Pre-fee paths take the input-side fee before the swap; `minOutput` still guards the + * swap outcome. */ function swap( - bytes32 requestHash, + bytes32 quoteId, InputData calldata input, address receiver, uint256 flags, @@ -203,24 +198,19 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { _pullFromUser(input.inputToken, input.user, input.inputAmount); - // Check fee amount first: flag bit is only read when a fee is actually present. - // FEE_FLAG_BIT_MASK: unset ⇒ collect fee from input before swap; set ⇒ swap first, fee from output + // POST_FEE_FLAG_BIT_MASK: unset ⇒ collect fee from input before swap; set ⇒ swap first, fee from output bool hasFee = fee.amount != 0; - /// @dev if hasFee is false, we short-circuit and flag check wont execute at runtime saving gas - bool postFee = hasFee && flags & FEE_FLAG_BIT_MASK != 0; + bool postFee = hasFee && flags & POST_FEE_FLAG_BIT_MASK != 0; uint256 swapInput = input.inputAmount; - // collect pre-swap fee if (hasFee && !postFee) { uint256 feeAmount = fee.amount; - if (feeAmount > swapInput) revert InsufficientFunds(); CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); unchecked { swapInput -= feeAmount; } } - // approve swap router if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); } @@ -233,35 +223,33 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { finalAmount = _execSwap(swapData, swapCallData, flags & BALANCE_FLAG_BIT_MASK != 0, outputReceiver); if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); - // collect post-swap fee and forward net to receiver if (postFee) { uint256 feeAmount = fee.amount; - if (feeAmount > finalAmount) revert InsufficientFunds(); CurrencyLib.transfer(swapData.outputToken, fee.receiver, feeAmount); unchecked { finalAmount -= feeAmount; } - // Tokens are at this contract; transfer net output to receiver CurrencyLib.transfer(swapData.outputToken, receiver, finalAmount); } // Pre-fee / no-fee: tokens were sent directly to `receiver` by the swap router; nothing to transfer - emit RequestExecuted(requestHash); + emit RequestExecuted(quoteId); } - // ========================================================================= - // External: swap + bridge - // ========================================================================= - /** * @notice Pull → optional pre/post swap fee → swap → bridge with runtime amount splicing. - * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. - * @param flags Same packing as `swap`; additionally bit 2 forwards final amount as bridge msg.value. - * @param fee Set `amount` to 0 to skip fee collection. - * @dev Same `minOutput` rule as `swap`: validated on gross `_execSwap` output, then optional output fee applies. + * @param quoteId Caller-defined correlation id logged in `RequestExecuted`. + * @param input User, input token, and pull amount. + * @param flags Same packing as `swap`; bits 2–3 also control bridge `msg.value` and calldata splicing. + * @param fee Set `amount` to 0 to skip fee collection. + * @param swapData Swap target, spender, output token, value, `minOutput`, and returndata offset. + * @param swapCallData Calldata forwarded to `swapData.target`. + * @param bridgeData Bridge target, approval spender, and static `msg.value` addend. + * @param bridgeCallData Bridge calldata; optionally spliced with swap output per `flags`. + * @dev Same `minOutput` rule as `swap`: validated on gross `_execSwap` output, then optional output fee applies. */ function swapAndBridge( - bytes32 requestHash, + bytes32 quoteId, InputData calldata input, uint256 flags, FeeData calldata fee, @@ -277,85 +265,25 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { revert InvalidExecution(); } - uint256 finalAmount = _swapAndBridgeSwap(input, flags, fee, swapData, swapCallData); - - _finishSwapAndBridge(swapData.outputToken, finalAmount, bridgeData, bridgeCallData, flags); - emit RequestExecuted(requestHash); + uint256 finalAmount = _swapBeforeBridge(input, flags, fee, swapData, swapCallData); + _doBridge(swapData.outputToken, finalAmount, bridgeData, bridgeCallData, flags); + emit RequestExecuted(quoteId); } - function _swapAndBridgeSwap( - InputData calldata input, - uint256 flags, - FeeData calldata fee, - SwapData calldata swapData, - bytes calldata swapCallData - ) internal returns (uint256 finalAmount) { - _pullFromUser(input.inputToken, input.user, input.inputAmount); - bool postFee; - { - // Check fee amount first: flag bit is only read when a fee is actually present. - // FEE_FLAG_BIT_MASK: same semantics as standalone `swap` (fee from input vs output token) - uint256 feeAmount = fee.amount; - postFee = feeAmount != 0 && flags & FEE_FLAG_BIT_MASK != 0; - uint256 swapInput = input.inputAmount; - - if (feeAmount != 0 && !postFee) { - if (feeAmount > swapInput) revert InsufficientFunds(); - CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); - unchecked { - swapInput -= feeAmount; - } - } - - if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); - } - } - - // BALANCE_FLAG_BIT_MASK: same returndata vs balance-delta measurement as `swap` - // Swap output always lands at this contract regardless of fee timing — tokens must be here for bridging. - bool useBalanceOf = flags & BALANCE_FLAG_BIT_MASK != 0; - finalAmount = _execSwap(swapData, swapCallData, useBalanceOf, address(this)); - if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); - - if (postFee) { - uint256 feeAmount = fee.amount; - if (feeAmount > finalAmount) revert InsufficientFunds(); - CurrencyLib.transfer(swapData.outputToken, fee.receiver, feeAmount); - unchecked { - finalAmount -= feeAmount; - } - } - } - - function _finishSwapAndBridge( - address finalToken, - uint256 finalAmount, - BridgeData calldata bridgeData, - bytes calldata bridgeCallData, - uint256 flags - ) internal { - _doBridge(finalToken, finalAmount, bridgeData, bridgeCallData, flags); - } - - // ========================================================================= - // External: simple bridge path (no swap) - // ========================================================================= - /** * @notice Pull → optional pre-bridge fee → bridge, with no swap step. - * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. - * @dev Because no swap is involved, `finalAmount = inputAmount - feeAmount` is - * fully knowable by the caller before signing. The caller must therefore - * bake the correct amount directly into `bridgeCallData` and set - * `bridgeData.value` to the desired `msg.value` for the bridge call. - * No runtime calldata splicing is performed. - * - * The caller MUST route through `AllowanceHolder.exec` for ERC-20 - * inputs so that `_msgSender()` resolves to `input.user`. + * @param quoteId Caller-defined correlation id logged in `RequestExecuted`. + * @param input User, input token, and pull amount. + * @param fee Pre-bridge fee taken from the input token; set `amount` to 0 to skip. + * @param bridgeData Bridge target, approval spender, and `msg.value` for the bridge call. + * @param bridgeCallData Calldata forwarded to `bridgeData.target` (amount must be baked in by the caller). + * @dev Because no swap is involved, `finalAmount = inputAmount - feeAmount` is fully knowable by the caller before signing. The caller must + * therefore bake the correct amount directly into `bridgeCallData` and set `bridgeData.value` to the desired `msg.value` for the bridge + * call. No runtime calldata splicing is performed. The caller MUST route through `AllowanceHolder.exec` for ERC-20 inputs so that + * `_msgSender()` resolves to `input.user`. */ function bridge( - bytes32 requestHash, + bytes32 quoteId, InputData calldata input, FeeData calldata fee, BridgeData calldata bridgeData, @@ -365,19 +293,13 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { revert InvalidExecution(); } - // 1. pull funds from user via AllowanceHolder _pullFromUser(input.inputToken, input.user, input.inputAmount); - // 2. optional pre-bridge fee; track net amount for approval uint256 feeAmount = fee.amount; if (feeAmount != 0) { - if (feeAmount > input.inputAmount) { - revert InsufficientFunds(); - } CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); } - // 3. optional approval to bridge spender for the net amount (inputAmount - feeAmount) if (bridgeData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { uint256 netAmount; unchecked { @@ -386,58 +308,95 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { SafeTransferLib.safeApproveWithRetry(input.inputToken, bridgeData.approvalSpender, netAmount); } - // 4. bridge call — data and value are pre-encoded by the caller _doCallCalldata(bridgeData.target, bridgeData.value, bridgeCallData, false); - emit RequestExecuted(requestHash); + emit RequestExecuted(quoteId); } - // ========================================================================= - // External: modular path - // ========================================================================= - /** - * @notice Runs a sequence of generic actions with optional returndata - * splicing between steps. No signature verification. - * @param requestHash Caller-defined correlation id logged in `RequestExecuted`. + * @notice Runs a sequence of generic actions with optional returndata splicing between steps. + * @param quoteId Caller-defined correlation id logged in `RequestExecuted`. + * @param actions Ordered actions; each may splice bytes from a prior action's returndata into its calldata. + * @return results Per-action returndata when the action's `actionInfo` store-result bit is set. */ - function performModularExecution(bytes32 requestHash, Action[] calldata actions) + function performActions(bytes32 quoteId, Action[] calldata actions) external payable returns (bytes[] memory results) { results = _performActions(actions); - emit RequestExecuted(requestHash); + emit RequestExecuted(quoteId); } // ========================================================================= - // Internal: swap / fee / bridge helpers + // Internal functions // ========================================================================= + // + // swap / bridge — orchestration is inline in the external functions; they use + // _pullFromUser, _execSwap (swap), and _doCallCalldata (bridge) from common below. - /// @dev Execute swap; output measured via returndata word or output-token balance delta. - /// useBalanceOf=true: measure output as (balance after - balance before) at `outputReceiver`. - /// useBalanceOf=false: decode output from returndata at swapData.returnDataWordOffset. - /// `outputReceiver` must be `address(this)` when tokens are expected at the contract - /// (post-swap fee path, bridge path) or `user` when the swap router sends directly to them - /// (pre-swap fee / no-fee standalone swap). - function _execSwap( + // ------------------------------------- + // swapAndBridge internal functions + // ------------------------------------- + + /** + * @dev Pull, optional pre/post swap fee, and swap for `swapAndBridge`. Swap output always remains at `address(this)` for bridging. + * @param input User, input token, and pull amount. + * @param flags Fee timing and swap output measurement flags (same as `swap`). + * @param fee Fee receiver and amount; `amount == 0` skips fee collection. + * @param swapData Swap target, spender, output token, value, `minOutput`, and returndata offset. + * @param swapCallData Calldata forwarded to `swapData.target`. + * @return finalAmount Swap output net of any post-swap fee, ready for `_doBridge`. + */ + function _swapBeforeBridge( + InputData calldata input, + uint256 flags, + FeeData calldata fee, SwapData calldata swapData, - bytes calldata swapCallData, - bool useBalanceOf, - address outputReceiver + bytes calldata swapCallData ) internal returns (uint256 finalAmount) { - if (useBalanceOf) { - // Balance delta mode: snapshot before, call, measure delta at the expected recipient - uint256 before = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver); - _doCallCalldata(swapData.target, swapData.value, swapCallData, false); - finalAmount = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver) - before; - } else { - // Returndata mode: decode output from a specific word in returndata - bytes memory ret = _doCallCalldata(swapData.target, swapData.value, swapCallData, true); - finalAmount = _decodeReturnWord(ret, swapData.returnDataWordOffset); + _pullFromUser(input.inputToken, input.user, input.inputAmount); + bool postFee; + { + // POST_FEE_FLAG_BIT_MASK: same semantics as standalone `swap` (fee from input vs output token) + uint256 feeAmount = fee.amount; + postFee = feeAmount != 0 && flags & POST_FEE_FLAG_BIT_MASK != 0; + uint256 swapInput = input.inputAmount; + + if (feeAmount != 0 && !postFee) { + CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); + unchecked { + swapInput -= feeAmount; + } + } + + if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { + SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); + } + } + + // BALANCE_FLAG_BIT_MASK: same returndata vs balance-delta measurement as `swap` + // Swap output always lands at this contract regardless of fee timing — tokens must be here for bridging. + bool useBalanceOf = flags & BALANCE_FLAG_BIT_MASK != 0; + finalAmount = _execSwap(swapData, swapCallData, useBalanceOf, address(this)); + if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); + + if (postFee) { + uint256 feeAmount = fee.amount; + CurrencyLib.transfer(swapData.outputToken, fee.receiver, feeAmount); + unchecked { + finalAmount -= feeAmount; + } } } - /// @dev Splice finalAmount into bridge calldata, approve, and call bridge target. + /** + * @dev Splice `amount` into bridge calldata when flagged, approve the bridge spender, and call the bridge target. + * @param token ERC-20 bridged (or native sentinel); used for approval only. + * @param amount Post-swap token amount spliced into calldata and/or forwarded as `msg.value`. + * @param bd Bridge target, approval spender, and static `msg.value` addend. + * @param bridgeCallData Base bridge calldata; copied to memory when splicing is required. + * @param flags Bridge splice position, `msg.value` composition, and related bit flags. + */ function _doBridge( address token, uint256 amount, @@ -460,55 +419,16 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { _doCall(bd.target, bridgeValue, bData); } - // ========================================================================= - // Internal: AllowanceHolder pull - // ========================================================================= + // -------------------------------------- + // performActions internal functions + // -------------------------------------- /** - * @notice Pulls `amount` of `token` from `user` into this contract. - * @dev For ERC20: enforces `_msgSender() == user` (caller must have routed - * through `AllowanceHolder.exec`) and calls AH.transferFrom via assembly. - * AH selector: transferFrom(address,address,address,uint256) = 0x15dacbea - * For native ETH: ETH must already be present as msg.value; we simply - * verify sufficient value was forwarded. No AH call is needed. + * @dev Executes `actions` in order, applying returndata splices before each call. `actionInfo` layout: bits 0–7 call type (`CallType`), bit 8 + * store returndata, bits 16+ target address. `splices[j]` packs source index, src/dst byte offsets, and length. + * @param actions Ordered list of actions to run. + * @return results Stored returndata per action when the store-result bit is set. */ - function _pullFromUser(address token, address user, uint256 amount) internal { - if (token == CurrencyLib.NATIVE_TOKEN_ADDRESS) { - // ETH is already sent as msg.value directly to this contract. - if (msg.value < amount) { - revert InsufficientMsgValue(); - } - return; - } - - if (_msgSender() != user) { - revert CallerNotSignedUser(); - } - - address allowanceHolder = address(ALLOWANCE_HOLDER); - assembly ("memory-safe") { - let ptr := mload(0x40) - mstore(add(0x80, ptr), amount) - mstore(add(0x60, ptr), address()) - mstore(add(0x4c, ptr), shl(0x60, user)) // clears `recipient`'s padding - // `shl(0x60)` (96-bit), NOT `shl(0xa0)` (160-bit): 0xa0 here is literal 160, which - // shifts the 20-byte address out of place and corrupts the calldata token. Same as - // 0x-settler `Permit2Payment._allowanceHolderTransferFrom`. - mstore(add(0x2c, ptr), shl(0x60, token)) // clears `owner`'s padding (settler wording) - mstore(add(0x0c, ptr), 0x15dacbea000000000000000000000000) // selector + token padding - - if iszero(call(gas(), allowanceHolder, 0x00, add(0x1c, ptr), 0x84, 0x00, 0x00)) { - let p := mload(0x40) - returndatacopy(p, 0x00, returndatasize()) - revert(p, returndatasize()) - } - } - } - - // ========================================================================= - // Internal: modular action loop - // ========================================================================= - function _performActions(Action[] calldata actions) internal returns (bytes[] memory results) { uint256 actionsLength = actions.length; results = new bytes[](actionsLength); @@ -582,10 +502,84 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } } - // ========================================================================= - // Internal: simple call dispatcher - // ========================================================================= + // ------------------------------- + // Common internal functions + // ------------------------------- + + /** + * @dev Pulls `amount` of `token` from `user` into this contract. For ERC20: enforces `_msgSender() == user` (caller must have routed through + * `AllowanceHolder.exec`) and calls AH.transferFrom via assembly. AH selector: + * transferFrom(address,address,address,uint256) = 0x15dacbea. For native ETH: ETH must already be present as msg.value; verify sufficient + * value was forwarded. No AH call is needed. + * @param token Input token or `CurrencyLib.NATIVE_TOKEN_ADDRESS`. + * @param user Owner whose AllowanceHolder-scoped allowance is consumed. + * @param amount Tokens or wei to pull. + */ + function _pullFromUser(address token, address user, uint256 amount) internal { + if (token == CurrencyLib.NATIVE_TOKEN_ADDRESS) { + if (msg.value < amount) { + revert InsufficientMsgValue(); + } + return; + } + + if (_msgSender() != user) { + revert CallerNotSignedUser(); + } + + address allowanceHolder = address(ALLOWANCE_HOLDER); + assembly ("memory-safe") { + let ptr := mload(0x40) + mstore(add(0x80, ptr), amount) + mstore(add(0x60, ptr), address()) + mstore(add(0x4c, ptr), shl(0x60, user)) // clears `recipient`'s padding + // `shl(0x60)` (96-bit), NOT `shl(0xa0)` (160-bit): 0xa0 here is literal 160, which + // shifts the 20-byte address out of place and corrupts the calldata token. Same as + // 0x-settler `Permit2Payment._allowanceHolderTransferFrom`. + mstore(add(0x2c, ptr), shl(0x60, token)) // clears `owner`'s padding (settler wording) + mstore(add(0x0c, ptr), 0x15dacbea000000000000000000000000) // selector + token padding + + if iszero(call(gas(), allowanceHolder, 0x00, add(0x1c, ptr), 0x84, 0x00, 0x00)) { + let p := mload(0x40) + returndatacopy(p, 0x00, returndatasize()) + revert(p, returndatasize()) + } + } + } + + /** + * @dev Executes the swap call and returns the output amount. `useBalanceOf=true`: measure output as (balance after − balance before) at + * `outputReceiver`. `useBalanceOf=false`: decode output from returndata at `swapData.returnDataWordOffset`. `outputReceiver` must be + * `address(this)` when tokens are expected at the contract (post-swap fee path, bridge path) or the end user when the router sends directly + * to them. + * @param swapData Swap target, value, output token, and returndata layout. + * @param swapCallData Calldata forwarded to `swapData.target`. + * @param useBalanceOf When true, use balance delta instead of returndata decoding. + * @param outputReceiver Account whose output-token balance is measured or credited. + * @return finalAmount Gross swap output amount. + */ + function _execSwap( + SwapData calldata swapData, + bytes calldata swapCallData, + bool useBalanceOf, + address outputReceiver + ) internal returns (uint256 finalAmount) { + if (useBalanceOf) { + uint256 before = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver); + _doCallCalldata(swapData.target, swapData.value, swapCallData, false); + finalAmount = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver) - before; + } else { + bytes memory ret = _doCallCalldata(swapData.target, swapData.value, swapCallData, true); + finalAmount = _decodeReturnWord(ret, swapData.returnDataWordOffset); + } + } + /** + * @dev Low-level `call` with bubbled revert data on failure. + * @param target Call recipient. + * @param value Wei forwarded with the call. + * @param data ABI-encoded calldata in memory. + */ function _doCall(address target, uint256 value, bytes memory data) internal { bool success; assembly ("memory-safe") { @@ -605,6 +599,14 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } } + /** + * @dev Low-level `call` using calldata copied to memory; optionally captures returndata. + * @param target Call recipient. + * @param value Wei forwarded with the call. + * @param data Calldata slice forwarded to `target`. + * @param storeResult When true, copy returndata into memory even on success. + * @return ret Returndata when `storeResult` is true or the call reverts (revert bubbles). + */ function _doCallCalldata(address target, uint256 value, bytes calldata data, bool storeResult) internal returns (bytes memory ret) @@ -633,6 +635,12 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { } } + /** + * @dev Reads the 32-byte word at `wordOffset` from ABI-encoded `ret` (word index, not byte offset). + * @param ret Return blob from a prior call. + * @param wordOffset Zero-based index of the 32-byte word to load. + * @return word Decoded amount or value at that offset. + */ function _decodeReturnWord(bytes memory ret, uint256 wordOffset) internal pure returns (uint256 word) { uint256 offset = wordOffset * 32; if (offset + 32 > ret.length) revert ReturnDataOutOfBounds(); @@ -648,7 +656,7 @@ contract BungeeOpenRouterV2Unchecked is Ownable, AllowanceHolderContext { * @param rescueTo_ The address where rescued tokens need to be sent. * @param amount_ The amount of tokens to be rescued. */ - function rescueFunds(address token_, address rescueTo_, uint256 amount_) external onlyOwner { + function rescueFunds(address token_, address rescueTo_, uint256 amount_) external onlyRole(RESCUE_ROLE) { RescueFundsLib.rescueFunds(token_, rescueTo_, amount_); } } From 9a19a3c9a785cf1fedfd22acaf12ffe8f6c722e1 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 17:14:58 +0530 Subject: [PATCH 23/42] build: change solc version --- foundry.toml | 2 +- src/combined/BungeeOpenRouterV2.sol | 2 +- src/common/AccessRoles.sol | 4 ++ src/common/OpenRouterAuthBase.sol | 2 +- .../allowance/AllowanceHolderContext.sol | 2 +- src/common/interfaces/IAllowanceHolder.sol | 2 +- src/common/lib/AuthenticationLib.sol | 2 +- src/common/lib/BytesSpliceLib.sol | 2 +- src/common/lib/CurrencyLib.sol | 2 +- src/common/lib/RescueFundsLib.sol | 2 +- src/common/utils/AccessControl.sol | 46 +++++++++++++++++++ src/common/utils/Ownable.sol | 2 +- .../AcrossERC20AmountManipulator.sol | 2 +- src/manipulators/MathManipulator.sol | 2 +- src/minimal/BungeeOpenRouterMinimal.sol | 2 +- src/minimal/BungeeOpenRouterMinimalAH.sol | 2 +- src/modular/BungeeOpenRouterModular.sol | 2 +- src/modular/BungeeOpenRouterModularAH.sol | 2 +- src/monolithic/BungeeOpenRouter.sol | 2 +- src/monolithic/BungeeOpenRouterAH.sol | 2 +- 20 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 src/common/AccessRoles.sol create mode 100644 src/common/utils/AccessControl.sol diff --git a/foundry.toml b/foundry.toml index 34b3732..bb17d99 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,7 +2,7 @@ src = "src" out = "out" libs = ["lib"] -solc_version = "0.8.25" +solc_version = "0.8.34" evm_version = "cancun" optimizer = true optimizer_runs = 2_000 diff --git a/src/combined/BungeeOpenRouterV2.sol b/src/combined/BungeeOpenRouterV2.sol index 67fe166..f8c002d 100644 --- a/src/combined/BungeeOpenRouterV2.sol +++ b/src/combined/BungeeOpenRouterV2.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; diff --git a/src/common/AccessRoles.sol b/src/common/AccessRoles.sol new file mode 100644 index 0000000..0039d01 --- /dev/null +++ b/src/common/AccessRoles.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.34; + +bytes32 constant RESCUE_ROLE = keccak256("RESCUE_ROLE"); diff --git a/src/common/OpenRouterAuthBase.sol b/src/common/OpenRouterAuthBase.sol index dbf416b..db7316b 100644 --- a/src/common/OpenRouterAuthBase.sol +++ b/src/common/OpenRouterAuthBase.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {Ownable} from "./utils/Ownable.sol"; import {AuthenticationLib} from "./lib/AuthenticationLib.sol"; diff --git a/src/common/allowance/AllowanceHolderContext.sol b/src/common/allowance/AllowanceHolderContext.sol index 2ab3f2b..34ed2db 100644 --- a/src/common/allowance/AllowanceHolderContext.sol +++ b/src/common/allowance/AllowanceHolderContext.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {ALLOWANCE_HOLDER} from "../interfaces/IAllowanceHolder.sol"; diff --git a/src/common/interfaces/IAllowanceHolder.sol b/src/common/interfaces/IAllowanceHolder.sol index a941f77..1ec809f 100644 --- a/src/common/interfaces/IAllowanceHolder.sol +++ b/src/common/interfaces/IAllowanceHolder.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity =0.8.25; +pragma solidity 0.8.34; // @dev Mainnet AllowanceHolder address. Same address is used for every chain // on which 0x deploys it via the canonical CREATE2 deployer. See: diff --git a/src/common/lib/AuthenticationLib.sol b/src/common/lib/AuthenticationLib.sol index d1bfdde..0a65cbd 100644 --- a/src/common/lib/AuthenticationLib.sol +++ b/src/common/lib/AuthenticationLib.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; /// @title AuthenticationLib /// @notice Personal-sign style signature recovery, ported from diff --git a/src/common/lib/BytesSpliceLib.sol b/src/common/lib/BytesSpliceLib.sol index 8426d28..fc6a890 100644 --- a/src/common/lib/BytesSpliceLib.sol +++ b/src/common/lib/BytesSpliceLib.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; /// @title BytesSpliceLib /// @notice Generalisation of the in-place calldata patching used in diff --git a/src/common/lib/CurrencyLib.sol b/src/common/lib/CurrencyLib.sol index d6df584..7208f66 100644 --- a/src/common/lib/CurrencyLib.sol +++ b/src/common/lib/CurrencyLib.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; diff --git a/src/common/lib/RescueFundsLib.sol b/src/common/lib/RescueFundsLib.sol index a18b950..221c215 100644 --- a/src/common/lib/RescueFundsLib.sol +++ b/src/common/lib/RescueFundsLib.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; diff --git a/src/common/utils/AccessControl.sol b/src/common/utils/AccessControl.sol new file mode 100644 index 0000000..3faca7d --- /dev/null +++ b/src/common/utils/AccessControl.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.34; + +import {Ownable} from "./Ownable.sol"; + +abstract contract AccessControl is Ownable { + mapping(bytes32 => mapping(address => bool)) private _permits; + + event RoleGranted(bytes32 indexed role, address indexed grantee); + event RoleRevoked(bytes32 indexed role, address indexed revokee); + + error NoPermit(bytes32 role); + + constructor(address owner_) Ownable(owner_) {} + + modifier onlyRole(bytes32 role) { + if (!_permits[role][msg.sender]) revert NoPermit(role); + _; + } + + function grantRole(bytes32 role_, address grantee_) external virtual onlyOwner { + _grantRole(role_, grantee_); + } + + function revokeRole(bytes32 role_, address revokee_) external virtual onlyOwner { + _revokeRole(role_, revokee_); + } + + function hasRole(bytes32 role_, address address_) public view returns (bool) { + return _hasRole(role_, address_); + } + + function _grantRole(bytes32 role_, address grantee_) internal { + _permits[role_][grantee_] = true; + emit RoleGranted(role_, grantee_); + } + + function _revokeRole(bytes32 role_, address revokee_) internal { + _permits[role_][revokee_] = false; + emit RoleRevoked(role_, revokee_); + } + + function _hasRole(bytes32 role_, address address_) internal view returns (bool) { + return _permits[role_][address_]; + } +} diff --git a/src/common/utils/Ownable.sol b/src/common/utils/Ownable.sol index f03d76f..dd83c0b 100644 --- a/src/common/utils/Ownable.sol +++ b/src/common/utils/Ownable.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; /// @title Ownable /// @notice Two-step ownership transfer, ported from diff --git a/src/manipulators/AcrossERC20AmountManipulator.sol b/src/manipulators/AcrossERC20AmountManipulator.sol index d99d79d..9df80dc 100644 --- a/src/manipulators/AcrossERC20AmountManipulator.sol +++ b/src/manipulators/AcrossERC20AmountManipulator.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; /// @notice Computes the Across output amount that must be spliced into SpokePool.deposit calldata. contract AcrossERC20AmountManipulator { diff --git a/src/manipulators/MathManipulator.sol b/src/manipulators/MathManipulator.sol index 257b800..7879cd5 100644 --- a/src/manipulators/MathManipulator.sol +++ b/src/manipulators/MathManipulator.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; /// @notice Generic arithmetic helpers for router calldata splicing. contract MathManipulator { diff --git a/src/minimal/BungeeOpenRouterMinimal.sol b/src/minimal/BungeeOpenRouterMinimal.sol index 86c1bc5..78aad53 100644 --- a/src/minimal/BungeeOpenRouterMinimal.sol +++ b/src/minimal/BungeeOpenRouterMinimal.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {OpenRouterAuthBase} from "../common/OpenRouterAuthBase.sol"; diff --git a/src/minimal/BungeeOpenRouterMinimalAH.sol b/src/minimal/BungeeOpenRouterMinimalAH.sol index fbd0401..f62b112 100644 --- a/src/minimal/BungeeOpenRouterMinimalAH.sol +++ b/src/minimal/BungeeOpenRouterMinimalAH.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {BungeeOpenRouterMinimal} from "./BungeeOpenRouterMinimal.sol"; import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext.sol"; diff --git a/src/modular/BungeeOpenRouterModular.sol b/src/modular/BungeeOpenRouterModular.sol index 14983bc..806a538 100644 --- a/src/modular/BungeeOpenRouterModular.sol +++ b/src/modular/BungeeOpenRouterModular.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {OpenRouterAuthBase} from "../common/OpenRouterAuthBase.sol"; import {BytesSpliceLib} from "../common/lib/BytesSpliceLib.sol"; diff --git a/src/modular/BungeeOpenRouterModularAH.sol b/src/modular/BungeeOpenRouterModularAH.sol index e0f37cb..968aa68 100644 --- a/src/modular/BungeeOpenRouterModularAH.sol +++ b/src/modular/BungeeOpenRouterModularAH.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {BungeeOpenRouterModular} from "./BungeeOpenRouterModular.sol"; import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext.sol"; diff --git a/src/monolithic/BungeeOpenRouter.sol b/src/monolithic/BungeeOpenRouter.sol index df0ac22..4ed46bf 100644 --- a/src/monolithic/BungeeOpenRouter.sol +++ b/src/monolithic/BungeeOpenRouter.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; diff --git a/src/monolithic/BungeeOpenRouterAH.sol b/src/monolithic/BungeeOpenRouterAH.sol index 2f0f560..6a1805e 100644 --- a/src/monolithic/BungeeOpenRouterAH.sol +++ b/src/monolithic/BungeeOpenRouterAH.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; +pragma solidity 0.8.34; import {BungeeOpenRouter} from "./BungeeOpenRouter.sol"; import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext.sol"; From 419f396038794e2bb6ae46e2f640e88de8f54074 Mon Sep 17 00:00:00 2001 From: arthcp Date: Tue, 19 May 2026 15:51:26 +0400 Subject: [PATCH 24/42] feat: balance and return data variants --- ...eeOpenRouterV2UncheckedSwapAndBridge.t.sol | 81 ++++++++++++++----- 1 file changed, 63 insertions(+), 18 deletions(-) diff --git a/test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol b/test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol index b3c0efd..8a2a445 100644 --- a/test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol +++ b/test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol @@ -15,48 +15,85 @@ contract BungeeOpenRouterV2UncheckedSwapAndBridgeTest is BungeeOpenRouterV2Unche address input; address output; FeeMode feeMode; + bool balanceDelta; uint256 swapInput; uint256 bridgeAmount; } function test_swapAndBridge_noFee_erc20ToNative() public { - _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.None); + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.None, false); } function test_swapAndBridge_noFee_nativeToErc20() public { - _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.None); + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.None, false); } function test_swapAndBridge_noFee_erc20ToErc20() public { - _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.None); + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.None, false); } function test_swapAndBridge_prefee_erc20ToNative() public { - _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Pre); + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Pre, false); } function test_swapAndBridge_prefee_nativeToErc20() public { - _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Pre); + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Pre, false); } function test_swapAndBridge_prefee_erc20ToErc20() public { - _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Pre); + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Pre, false); } function test_swapAndBridge_postfee_erc20ToNative() public { - _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Post); + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Post, false); } function test_swapAndBridge_postfee_nativeToErc20() public { - _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Post); + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Post, false); } function test_swapAndBridge_postfee_erc20ToErc20() public { - _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Post); + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Post, false); } - function _runSwapAndBridge(address input, address output, FeeMode feeMode) internal { - Scenario memory scenario = _scenario(input, output, feeMode); + function test_swapAndBridge_balanceDelta_noFee_erc20ToNative() public { + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.None, true); + } + + function test_swapAndBridge_balanceDelta_noFee_nativeToErc20() public { + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.None, true); + } + + function test_swapAndBridge_balanceDelta_noFee_erc20ToErc20() public { + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.None, true); + } + + function test_swapAndBridge_balanceDelta_prefee_erc20ToNative() public { + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Pre, true); + } + + function test_swapAndBridge_balanceDelta_prefee_nativeToErc20() public { + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Pre, true); + } + + function test_swapAndBridge_balanceDelta_prefee_erc20ToErc20() public { + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Pre, true); + } + + function test_swapAndBridge_balanceDelta_postfee_erc20ToNative() public { + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Post, true); + } + + function test_swapAndBridge_balanceDelta_postfee_nativeToErc20() public { + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Post, true); + } + + function test_swapAndBridge_balanceDelta_postfee_erc20ToErc20() public { + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Post, true); + } + + function _runSwapAndBridge(address input, address output, FeeMode feeMode, bool balanceDelta) internal { + Scenario memory scenario = _scenario(input, output, feeMode, balanceDelta); _fundSwapAndBridge(scenario.input, scenario.output); if (scenario.input != NATIVE_TOKEN) _approveInputToken(INPUT_AMOUNT); @@ -71,7 +108,7 @@ contract BungeeOpenRouterV2UncheckedSwapAndBridgeTest is BungeeOpenRouterV2Unche assertEq(bridgeTarget.receivedAmount(), scenario.bridgeAmount); } - function _scenario(address input, address output, FeeMode feeMode) + function _scenario(address input, address output, FeeMode feeMode, bool balanceDelta) internal pure returns (Scenario memory scenario) @@ -79,6 +116,7 @@ contract BungeeOpenRouterV2UncheckedSwapAndBridgeTest is BungeeOpenRouterV2Unche scenario.input = input; scenario.output = output; scenario.feeMode = feeMode; + scenario.balanceDelta = balanceDelta; scenario.swapInput = _swapInput(feeMode); scenario.bridgeAmount = _bridgeAmount(feeMode); } @@ -98,7 +136,7 @@ contract BungeeOpenRouterV2UncheckedSwapAndBridgeTest is BungeeOpenRouterV2Unche ( keccak256("swap-and-bridge"), Router.InputData({user: USER, inputToken: scenario.input, inputAmount: INPUT_AMOUNT}), - _flags(scenario.output, scenario.feeMode), + _flags(scenario.output, scenario.feeMode, scenario.balanceDelta), _fee(scenario.feeMode), _swapDataWithValue( scenario.input, @@ -106,15 +144,22 @@ contract BungeeOpenRouterV2UncheckedSwapAndBridgeTest is BungeeOpenRouterV2Unche SWAP_OUTPUT_AMOUNT, scenario.input == NATIVE_TOKEN ? scenario.swapInput : 0 ), - _swapNoReturnCallData( - scenario.input, scenario.output, scenario.swapInput, SWAP_OUTPUT_AMOUNT, address(router) - ), + _swapCallData(scenario), _bridgeData(scenario.output, 0), _bridgeCallData(scenario.output, 0) ) ); } + function _swapCallData(Scenario memory scenario) internal view returns (bytes memory) { + if (scenario.balanceDelta) { + return _swapNoReturnCallData( + scenario.input, scenario.output, scenario.swapInput, SWAP_OUTPUT_AMOUNT, address(router) + ); + } + return _swapCallData(scenario.input, scenario.output, scenario.swapInput, SWAP_OUTPUT_AMOUNT, address(router)); + } + function _fundSwapAndBridge(address input, address output) internal { _deal(input, USER, INPUT_AMOUNT); _deal(output, address(swapTarget), SWAP_OUTPUT_AMOUNT); @@ -148,8 +193,8 @@ contract BungeeOpenRouterV2UncheckedSwapAndBridgeTest is BungeeOpenRouterV2Unche return feeMode == FeeMode.Post ? SWAP_OUTPUT_AMOUNT - FEE_AMOUNT : SWAP_OUTPUT_AMOUNT; } - function _flags(address output, FeeMode feeMode) internal pure returns (uint256) { - uint256 flags = BALANCE_FLAG_BIT_MASK; + function _flags(address output, FeeMode feeMode, bool balanceDelta) internal pure returns (uint256) { + uint256 flags = balanceDelta ? BALANCE_FLAG_BIT_MASK : 0; if (output == NATIVE_TOKEN) flags |= BRIDGE_VALUE_FLAG_BIT_MASK; if (feeMode == FeeMode.Post) flags |= FEE_FLAG_BIT_MASK; return _bridgeAmountSpliceFlags(flags); From 578ef446358050684829f76ea45f43e73b166be6 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 17:28:38 +0530 Subject: [PATCH 25/42] refactor: reorder function params --- src/combined/BungeeOpenRouterV2Unchecked.sol | 74 ++++++++++---------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol index 3237fc2..9481b25 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/combined/BungeeOpenRouterV2Unchecked.sol @@ -182,12 +182,12 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { */ function swap( bytes32 quoteId, - InputData calldata input, - address receiver, uint256 flags, + InputData calldata input, FeeData calldata fee, SwapData calldata swapData, - bytes calldata swapCallData + bytes calldata swapCallData, + address receiver ) external payable returns (uint256 finalAmount) { if ( input.user == address(0) || input.inputToken == address(0) || swapData.target == address(0) @@ -196,23 +196,25 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { revert InvalidExecution(); } - _pullFromUser(input.inputToken, input.user, input.inputAmount); + bool postFee = fee.amount != 0 && flags & POST_FEE_FLAG_BIT_MASK != 0; + bool useBalanceOf = flags & BALANCE_FLAG_BIT_MASK != 0; - // POST_FEE_FLAG_BIT_MASK: unset ⇒ collect fee from input before swap; set ⇒ swap first, fee from output - bool hasFee = fee.amount != 0; - bool postFee = hasFee && flags & POST_FEE_FLAG_BIT_MASK != 0; - uint256 swapInput = input.inputAmount; + { + _pullFromUser(input.inputToken, input.user, input.inputAmount); - if (hasFee && !postFee) { - uint256 feeAmount = fee.amount; - CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); - unchecked { - swapInput -= feeAmount; + // POST_FEE_FLAG_BIT_MASK: unset ⇒ collect fee from input before swap; set ⇒ swap first, fee from output + uint256 swapInput = input.inputAmount; + if (fee.amount != 0 && !postFee) { + uint256 feeAmount = fee.amount; + CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); + unchecked { + swapInput -= feeAmount; + } } - } - if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); + if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { + SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); + } } // Post-fee: swap output lands at this contract so the fee can be deducted before forwarding. @@ -220,7 +222,7 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { address outputReceiver = postFee ? address(this) : receiver; // BALANCE_FLAG_BIT_MASK: unset ⇒ decode output word from returndata; set ⇒ output = delta on outputToken - finalAmount = _execSwap(swapData, swapCallData, flags & BALANCE_FLAG_BIT_MASK != 0, outputReceiver); + finalAmount = _execSwap(swapData, swapCallData, useBalanceOf, outputReceiver); if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); if (postFee) { @@ -250,8 +252,8 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { */ function swapAndBridge( bytes32 quoteId, - InputData calldata input, uint256 flags, + InputData calldata input, FeeData calldata fee, SwapData calldata swapData, bytes calldata swapCallData, @@ -265,8 +267,8 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { revert InvalidExecution(); } - uint256 finalAmount = _swapBeforeBridge(input, flags, fee, swapData, swapCallData); - _doBridge(swapData.outputToken, finalAmount, bridgeData, bridgeCallData, flags); + uint256 finalAmount = _swapBeforeBridge(flags, input, fee, swapData, swapCallData); + _doBridge(swapData.outputToken, finalAmount, flags, bridgeData, bridgeCallData); emit RequestExecuted(quoteId); } @@ -348,8 +350,8 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { * @return finalAmount Swap output net of any post-swap fee, ready for `_doBridge`. */ function _swapBeforeBridge( - InputData calldata input, uint256 flags, + InputData calldata input, FeeData calldata fee, SwapData calldata swapData, bytes calldata swapCallData @@ -393,30 +395,30 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { * @dev Splice `amount` into bridge calldata when flagged, approve the bridge spender, and call the bridge target. * @param token ERC-20 bridged (or native sentinel); used for approval only. * @param amount Post-swap token amount spliced into calldata and/or forwarded as `msg.value`. - * @param bd Bridge target, approval spender, and static `msg.value` addend. + * @param bridgeData Bridge target, approval spender, and static `msg.value` addend. * @param bridgeCallData Base bridge calldata; copied to memory when splicing is required. * @param flags Bridge splice position, `msg.value` composition, and related bit flags. */ function _doBridge( address token, uint256 amount, - BridgeData calldata bd, - bytes calldata bridgeCallData, - uint256 flags + uint256 flags, + BridgeData calldata bridgeData, + bytes calldata bridgeCallData ) internal { - bytes memory bData = bridgeCallData; + bytes memory _bridgeCallData = bridgeCallData; if (flags & BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK != 0) { uint256 position = flags >> BRIDGE_AMOUNT_POSITION_SHIFT & BRIDGE_AMOUNT_POSITION_MASK; - BytesSpliceLib.spliceWord({data: bData, position: position, word: amount}); + BytesSpliceLib.spliceWord({data: _bridgeCallData, position: position, word: amount}); } - if (bd.approvalSpender != address(0) && token != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(token, bd.approvalSpender, amount); + if (bridgeData.approvalSpender != address(0) && token != CurrencyLib.NATIVE_TOKEN_ADDRESS) { + SafeTransferLib.safeApproveWithRetry(token, bridgeData.approvalSpender, amount); } // when set, forward amount as msg.value for native-token bridges - uint256 bridgeValue = flags & BRIDGE_VALUE_FLAG_BIT_MASK != 0 ? amount + bd.value : bd.value; - _doCall(bd.target, bridgeValue, bData); + uint256 bridgeValue = flags & BRIDGE_VALUE_FLAG_BIT_MASK != 0 ? amount + bridgeData.value : bridgeData.value; + _doCall(bridgeData.target, bridgeValue, _bridgeCallData); } // -------------------------------------- @@ -652,12 +654,12 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { /** * @notice Rescues funds from the contract if they are locked by mistake. - * @param token_ The address of the token contract. - * @param rescueTo_ The address where rescued tokens need to be sent. - * @param amount_ The amount of tokens to be rescued. + * @param token The address of the token contract. + * @param rescueTo The address where rescued tokens need to be sent. + * @param amount The amount of tokens to be rescued. */ - function rescueFunds(address token_, address rescueTo_, uint256 amount_) external onlyRole(RESCUE_ROLE) { - RescueFundsLib.rescueFunds(token_, rescueTo_, amount_); + function rescueFunds(address token, address rescueTo, uint256 amount) external onlyRole(RESCUE_ROLE) { + RescueFundsLib.rescueFunds(token, rescueTo, amount); } } From d84d648158ef10e384c026dc61b82966166db339 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 17:32:36 +0530 Subject: [PATCH 26/42] refactor: remove old contracts, rename router, move to src root --- ...erV2Unchecked.sol => BungeeOpenRouter.sol} | 14 +- src/combined/BungeeOpenRouterV2.sol | 420 ------------------ src/minimal/BungeeOpenRouterMinimal.sol | 99 ----- src/minimal/BungeeOpenRouterMinimalAH.sol | 31 -- src/modular/BungeeOpenRouterModular.sol | 144 ------ src/modular/BungeeOpenRouterModularAH.sol | 45 -- src/monolithic/BungeeOpenRouter.sol | 190 -------- src/monolithic/BungeeOpenRouterAH.sol | 69 --- 8 files changed, 7 insertions(+), 1005 deletions(-) rename src/{combined/BungeeOpenRouterV2Unchecked.sol => BungeeOpenRouter.sol} (98%) delete mode 100644 src/combined/BungeeOpenRouterV2.sol delete mode 100644 src/minimal/BungeeOpenRouterMinimal.sol delete mode 100644 src/minimal/BungeeOpenRouterMinimalAH.sol delete mode 100644 src/modular/BungeeOpenRouterModular.sol delete mode 100644 src/modular/BungeeOpenRouterModularAH.sol delete mode 100644 src/monolithic/BungeeOpenRouter.sol delete mode 100644 src/monolithic/BungeeOpenRouterAH.sol diff --git a/src/combined/BungeeOpenRouterV2Unchecked.sol b/src/BungeeOpenRouter.sol similarity index 98% rename from src/combined/BungeeOpenRouterV2Unchecked.sol rename to src/BungeeOpenRouter.sol index 9481b25..60abf98 100644 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ b/src/BungeeOpenRouter.sol @@ -3,13 +3,13 @@ pragma solidity 0.8.34; import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; -import {AccessControl} from "../common/utils/AccessControl.sol"; -import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext.sol"; -import {ALLOWANCE_HOLDER} from "../common/interfaces/IAllowanceHolder.sol"; -import {BytesSpliceLib} from "../common/lib/BytesSpliceLib.sol"; -import {CurrencyLib} from "../common/lib/CurrencyLib.sol"; -import {RescueFundsLib} from "../common/lib/RescueFundsLib.sol"; -import {RESCUE_ROLE} from "../common/AccessRoles.sol"; +import {AccessControl} from "./common/utils/AccessControl.sol"; +import {AllowanceHolderContext} from "./common/allowance/AllowanceHolderContext.sol"; +import {ALLOWANCE_HOLDER} from "./common/interfaces/IAllowanceHolder.sol"; +import {BytesSpliceLib} from "./common/lib/BytesSpliceLib.sol"; +import {CurrencyLib} from "./common/lib/CurrencyLib.sol"; +import {RescueFundsLib} from "./common/lib/RescueFundsLib.sol"; +import {RESCUE_ROLE} from "./common/AccessRoles.sol"; /// @title BungeeOpenRouter /// @notice Pull → optional fee → swap/bridge execution without backend signature verification. diff --git a/src/combined/BungeeOpenRouterV2.sol b/src/combined/BungeeOpenRouterV2.sol deleted file mode 100644 index f8c002d..0000000 --- a/src/combined/BungeeOpenRouterV2.sol +++ /dev/null @@ -1,420 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.34; - -import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; - -import {OpenRouterAuthBase} from "../common/OpenRouterAuthBase.sol"; -import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext.sol"; -import {ALLOWANCE_HOLDER} from "../common/interfaces/IAllowanceHolder.sol"; -import {BytesSpliceLib} from "../common/lib/BytesSpliceLib.sol"; -import {CurrencyLib} from "../common/lib/CurrencyLib.sol"; - -/// @title BungeeOpenRouterV2 -/// @notice Combined open-router that exposes two execution paths behind a -/// single signature-verified, AllowanceHolder-based fund pull: -/// -/// 1. `performExecution` — monolithic path. The signed payload describes -/// every step explicitly: pull, optional pre-swap fee, optional swap, -/// optional post-swap fee, bridge call with multi-position amount -/// splicing. Suitable for the vast majority of routes. -/// -/// 2. `performModularExecution` — generic action loop. Each `Action` -/// carries packed call metadata and packed splices that copy byte -/// ranges from any earlier stored action result into this action's -/// calldata before dispatch. -/// -/// Fund pulls always go through 0x AllowanceHolder (transient-storage -/// allowance). The `_msgSender() == user` guard ensures the AH -/// ephemeral allowance (keyed by operator + owner + token) belongs to -/// the user named in the signed payload. -/// -/// @dev Both entrypoints verify a personal_sign signature over -/// `keccak256(abi.encode(chainid, address(this), exec))` and consume a -/// single-use nonce, matching the `Solver` / `StakedRouterReceiver` -/// authentication model. -contract BungeeOpenRouterV2 is OpenRouterAuthBase, AllowanceHolderContext { - using SafeTransferLib for address; - - // ========================================================================= - // Monolithic execution types - // ========================================================================= - - /// @notice Who is sending funds and how much. - struct InputData { - address user; - address inputToken; - uint256 inputAmount; - } - - /// @notice Optional fee. Set `receiver` to address(0) and `amount` to 0 to skip. - struct FeeData { - address receiver; - uint256 amount; - } - - /// @notice Optional swap step. Set `target` to address(0) to skip entirely. - struct SwapData { - address target; - address approvalSpender; // 0 to skip ERC20 approval before swap - address outputToken; // token used for post-fee transfer / bridge approval - uint256 value; // ETH forwarded to the swap target - uint256 minOutput; // minimum decoded output; reverts if not met - bytes data; - uint256 returnDataWordOffset; - } - - /// @notice Mandatory bridge call. `amountPositions` lists every byte offset - /// in `data` where the final post-fee amount must be written. - struct BridgeData { - address target; - address approvalSpender; // 0 to skip ERC20 approval before bridge - uint256 value; // ETH forwarded to the bridge target - bytes data; - uint256[] amountPositions; - // when true, bridge.value is ignored and finalAmount is forwarded as - // msg.value instead — needed for native-token bridges (e.g. Arbitrum inbox) - // where the bridged amount is only known at runtime. - bool useFinalAmountAsValue; - } - - /// @notice Signed payload for the monolithic execution path. - /// @dev Digest: keccak256(abi.encode(block.chainid, address(this), exec)). - struct MonolithicExecution { - InputData input; - FeeData preFee; // taken in inputToken before swap - SwapData swap; - FeeData postFee; // taken in finalToken after swap - BridgeData bridge; - uint256 nonce; - uint256 deadline; - } - - // ========================================================================= - // Modular execution types - // ========================================================================= - - enum CallType { - CALL, - STATICCALL, - CALL_WITH_NATIVE - } - - /// @notice One step in the modular execution pipeline. - /// @dev `actionInfo` packs call type in bits [0:8), store-result flag in - /// bits [8:16), and target address in bits [16:176). - struct Action { - uint256 actionInfo; - bytes data; - uint256[] splices; - } - - /// @notice Signed payload for the modular execution path. - struct ModularExecution { - Action[] actions; - uint256 nonce; - uint256 deadline; - } - - // ========================================================================= - // Errors - // ========================================================================= - - error SwapOutputInsufficient(); - error InsufficientFunds(); - error InvalidExecution(); - error CallerNotSignedUser(); - error InsufficientMsgValue(); - error FutureSplice(uint256 actionIndex, uint256 sourceActionIndex); - error SpliceOutOfBounds(uint256 actionIndex, uint256 spliceIndex); - error CallFailed(uint256 actionIndex, bytes returndata); - error MissingNativeValue(uint256 actionIndex); - error ReturnDataOutOfBounds(); - - // ========================================================================= - // Constructor - // ========================================================================= - - constructor(address _owner, address _openRouterSigner) OpenRouterAuthBase(_owner, _openRouterSigner) {} - - receive() external payable {} - - // ========================================================================= - // External: monolithic path - // ========================================================================= - - /** - * @notice Executes a monolithic signed payload: pull funds via AH, optional - * pre-swap fee, optional swap, optional post-swap fee, bridge call - * with multi-position amount splicing. - * @dev Anyone may call; security is the backend signature + single-use nonce. - * The caller MUST route through `AllowanceHolder.exec` so that - * `_msgSender()` resolves to `exec.input.user`. - */ - function performExecution(MonolithicExecution calldata exec, bytes calldata signature) external payable { - bytes32 digest = keccak256(abi.encode(block.chainid, address(this), exec)); - _verifyAndConsume(digest, exec.nonce, exec.deadline, signature); - _runMonolithic(exec); - } - - // ========================================================================= - // External: modular path - // ========================================================================= - - /** - * @notice Executes a signed sequence of generic actions with optional - * returndata splicing between steps. - * @dev The signed digest covers the entire action set, so the caller cannot - * reorder, retarget, or strip splices from any action. - */ - function performModularExecution(ModularExecution calldata exec, bytes calldata signature) - external - payable - returns (bytes[] memory results) - { - bytes32 digest = keccak256(abi.encode(block.chainid, address(this), exec)); - _verifyAndConsume(digest, exec.nonce, exec.deadline, signature); - results = _performActions(exec.actions); - } - - // ========================================================================= - // Internal: monolithic pipeline - // ========================================================================= - - function _runMonolithic(MonolithicExecution calldata exec) internal { - if (exec.bridge.target == address(0) || exec.input.user == address(0) || exec.input.inputToken == address(0)) { - revert InvalidExecution(); - } - - // 1. pull funds from user via AllowanceHolder - _pullFromUser(exec.input.inputToken, exec.input.user, exec.input.inputAmount); - - // 2. optional pre-swap fee in input token - if (exec.preFee.amount != 0) { - CurrencyLib.transfer(exec.input.inputToken, exec.preFee.receiver, exec.preFee.amount); - } - - // 3. optional swap, accounted via decoded returndata - address finalToken; - uint256 finalAmount; - if (exec.swap.target != address(0)) { - (finalToken, finalAmount) = _performSwap(exec); - } else { - if (exec.preFee.amount > exec.input.inputAmount) { - revert InsufficientFunds(); - } - finalToken = exec.input.inputToken; - unchecked { - finalAmount = exec.input.inputAmount - exec.preFee.amount; - } - } - - // 4. optional post-swap fee in final token - if (exec.postFee.amount != 0) { - if (exec.postFee.amount > finalAmount) { - revert InsufficientFunds(); - } - CurrencyLib.transfer(finalToken, exec.postFee.receiver, exec.postFee.amount); - unchecked { - finalAmount -= exec.postFee.amount; - } - } - - // 5. splice finalAmount into bridge calldata at every signed offset - bytes memory bridgeData = exec.bridge.data; - BytesSpliceLib.spliceWords({data: bridgeData, positions: exec.bridge.amountPositions, word: finalAmount}); - - // 6. optional approval to bridge spender - if (exec.bridge.approvalSpender != address(0) && finalToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(finalToken, exec.bridge.approvalSpender, finalAmount); - } - - // 7. bridge call, bubbling any revert - // when useFinalAmountAsValue is set, forward finalAmount as msg.value so - // native-token bridges (e.g. Arbitrum inbox) receive the exact bridged amount. - uint256 bridgeValue = exec.bridge.useFinalAmountAsValue ? finalAmount : exec.bridge.value; - _doCall(exec.bridge.target, bridgeValue, bridgeData, false); - } - - /// @dev Swap helper; decodes final amount from a returndata word. - function _performSwap(MonolithicExecution calldata exec) - internal - returns (address finalToken, uint256 finalAmount) - { - if (exec.swap.approvalSpender != address(0) && exec.input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - uint256 swapInput; - unchecked { - swapInput = exec.input.inputAmount - exec.preFee.amount; - } - SafeTransferLib.safeApproveWithRetry(exec.input.inputToken, exec.swap.approvalSpender, swapInput); - } - - bytes memory ret = _doCall(exec.swap.target, exec.swap.value, exec.swap.data, true); - finalAmount = _decodeReturnWord(ret, exec.swap.returnDataWordOffset); - - if (finalAmount < exec.swap.minOutput) { - revert SwapOutputInsufficient(); - } - - finalToken = exec.swap.outputToken; - } - - // ========================================================================= - // Internal: AllowanceHolder pull - // ========================================================================= - - /** - * @notice Pulls `amount` of `token` from `user` into this contract. - * @dev For ERC20: enforces `_msgSender() == user` (caller must have routed - * through `AllowanceHolder.exec`) and calls AH.transferFrom via assembly. - * AH selector: transferFrom(address,address,address,uint256) = 0x15dacbea - * For native ETH: ETH must already be present as msg.value; we simply - * verify sufficient value was forwarded. No AH call is needed. - */ - function _pullFromUser(address token, address user, uint256 amount) internal { - if (token == CurrencyLib.NATIVE_TOKEN_ADDRESS) { - // ETH is already sent as msg.value directly to this contract. - if (msg.value < amount) { - revert InsufficientMsgValue(); - } - return; - } - - if (_msgSender() != user) { - revert CallerNotSignedUser(); - } - - address allowanceHolder = address(ALLOWANCE_HOLDER); - assembly ("memory-safe") { - let ptr := mload(0x40) - mstore(add(0x80, ptr), amount) - mstore(add(0x60, ptr), address()) - mstore(add(0x4c, ptr), shl(0x60, user)) // clears `recipient`'s padding - // `shl(0x60)` (96-bit), NOT `shl(0xa0)` (160-bit): 0xa0 here is literal 160, which - // shifts the 20-byte address out of place and corrupts the calldata token. Same as - // 0x-settler `Permit2Payment._allowanceHolderTransferFrom`. - mstore(add(0x2c, ptr), shl(0x60, token)) // clears `owner`'s padding (settler wording) - mstore(add(0x0c, ptr), 0x15dacbea000000000000000000000000) // selector + token padding - - if iszero(call(gas(), allowanceHolder, 0x00, add(0x1c, ptr), 0x84, 0x00, 0x00)) { - let p := mload(0x40) - returndatacopy(p, 0x00, returndatasize()) - revert(p, returndatasize()) - } - } - } - - // ========================================================================= - // Internal: modular action loop - // ========================================================================= - - function _performActions(Action[] calldata actions) internal returns (bytes[] memory results) { - uint256 actionsLength = actions.length; - results = new bytes[](actionsLength); - - for (uint256 i; i < actionsLength;) { - Action calldata action = actions[i]; - bytes memory callData = action.data; - - uint256 splicesLength = action.splices.length; - for (uint256 j; j < splicesLength;) { - uint256 spliceInfo = action.splices[j]; - uint256 sourceActionIndex = uint64(spliceInfo); - if (sourceActionIndex >= i) revert FutureSplice(i, sourceActionIndex); - - uint256 srcOffset = uint64(spliceInfo >> 64); - uint256 dstOffset = uint64(spliceInfo >> 128); - uint256 length = spliceInfo >> 192; - bytes memory source = results[sourceActionIndex]; - if (srcOffset + length > source.length || dstOffset + length > callData.length) { - revert SpliceOutOfBounds(i, j); - } - - assembly ("memory-safe") { - mcopy(add(add(callData, 0x20), dstOffset), add(add(source, 0x20), srcOffset), length) - } - - unchecked { - ++j; - } - } - - bool success; - uint256 actionInfo = action.actionInfo; - bool storeResult = (actionInfo & 0xff00) != 0; - uint256 callType = actionInfo & 0xff; - address target = address(uint160(actionInfo >> 16)); - - if (callType == uint256(CallType.STATICCALL)) { - assembly ("memory-safe") { - success := staticcall(gas(), target, add(callData, 0x20), mload(callData), 0, 0) - } - } else if (callType == uint256(CallType.CALL_WITH_NATIVE)) { - if (callData.length < 32) revert MissingNativeValue(i); - uint256 callValue; - uint256 payloadLength = callData.length - 32; - assembly ("memory-safe") { - callValue := mload(add(callData, 0x20)) - success := call(gas(), target, callValue, add(callData, 0x40), payloadLength, 0, 0) - } - } else { - assembly ("memory-safe") { - success := call(gas(), target, 0, add(callData, 0x20), mload(callData), 0, 0) - } - } - - if (!success || storeResult) { - bytes memory ret; - assembly ("memory-safe") { - let returnDataSize := returndatasize() - ret := mload(0x40) - mstore(ret, returnDataSize) - returndatacopy(add(ret, 0x20), 0, returnDataSize) - mstore(0x40, and(add(add(add(ret, 0x20), returnDataSize), 0x1f), not(0x1f))) - } - if (!success) revert CallFailed(i, ret); - results[i] = ret; - } - unchecked { - ++i; - } - } - } - - // ========================================================================= - // Internal: simple call dispatcher (used by monolithic path) - // ========================================================================= - - function _doCall(address target, uint256 value, bytes memory data, bool storeResult) - internal - returns (bytes memory ret) - { - bool success; - assembly ("memory-safe") { - success := call(gas(), target, value, add(data, 0x20), mload(data), 0, 0) - } - - if (!success || storeResult) { - assembly ("memory-safe") { - let returnDataSize := returndatasize() - ret := mload(0x40) - mstore(ret, returnDataSize) - returndatacopy(add(ret, 0x20), 0, returnDataSize) - mstore(0x40, and(add(add(add(ret, 0x20), returnDataSize), 0x1f), not(0x1f))) - } - if (!success) { - assembly ("memory-safe") { - revert(add(ret, 0x20), mload(ret)) - } - } - } - } - - function _decodeReturnWord(bytes memory ret, uint256 wordOffset) internal pure returns (uint256 word) { - uint256 offset = wordOffset * 32; - if (offset + 32 > ret.length) revert ReturnDataOutOfBounds(); - - assembly ("memory-safe") { - word := mload(add(add(ret, 0x20), offset)) - } - } -} diff --git a/src/minimal/BungeeOpenRouterMinimal.sol b/src/minimal/BungeeOpenRouterMinimal.sol deleted file mode 100644 index 78aad53..0000000 --- a/src/minimal/BungeeOpenRouterMinimal.sol +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.34; - -import {OpenRouterAuthBase} from "../common/OpenRouterAuthBase.sol"; - -/// @title BungeeOpenRouterMinimal (v3, modular w/o splicing) -/// @notice Smallest possible signed-action runner. Identical surface to -/// `BungeeOpenRouterModular` minus the splice mechanism: each -/// `Action` is dispatched standalone via `CALL`, `DELEGATECALL`, or -/// `STATICCALL`, and there is no plumbing of returndata into the -/// next action's calldata. -/// -/// This relies on the assumption that whenever a step needs the -/// "real" amount produced by a previous step (typical for swap-then- -/// bridge flows), the next step's target can re-read that amount -/// itself - usually by calling `balanceOf(this)` at runtime, which -/// is exactly what `BaseRouterSingleOutput`-style pre/post balance -/// deltas do already. -/// -/// @dev Same signing scheme as the other variants: personal_sign over -/// keccak256(abi.encode(chainid, this, exec)). Caller cannot reorder -/// or retarget actions; only re-submission patterns are restricted. -contract BungeeOpenRouterMinimal is OpenRouterAuthBase { - enum CallType { - CALL, - DELEGATECALL, - STATICCALL - } - - struct Action { - CallType callType; - address target; - uint256 value; // forwarded ETH; must be zero for non-CALL types - bytes data; - } - - struct Execution { - Action[] actions; - uint256 nonce; - uint256 deadline; - } - - error ValueOnNonCall(); - error EmptyExecution(); - error UnknownCallType(); - - constructor(address _owner, address _openRouterSigner) OpenRouterAuthBase(_owner, _openRouterSigner) {} - - receive() external payable {} - - function performExecution(Execution calldata exec, bytes calldata signature) external payable virtual { - bytes32 digest = keccak256(abi.encode(block.chainid, address(this), exec)); - _verifyAndConsume(digest, exec.nonce, exec.deadline, signature); - _performActions(exec.actions); - } - - /// @notice Internal action loop, exposed to subclasses. - function _performActions(Action[] calldata actions) internal { - uint256 actionsLen = actions.length; - if (actionsLen == 0) { - revert EmptyExecution(); - } - - for (uint256 i = 0; i < actionsLen;) { - Action calldata a = actions[i]; - _performAction(a.callType, a.target, a.value, a.data); - unchecked { - ++i; - } - } - } - - /// @notice Dispatches a single action; bubbles any revert. - function _performAction(CallType callType, address target, uint256 value, bytes memory data) internal virtual { - bool ok; - bytes memory ret; - if (callType == CallType.CALL) { - (ok, ret) = target.call{value: value}(data); - } else if (callType == CallType.DELEGATECALL) { - if (value != 0) { - revert ValueOnNonCall(); - } - (ok, ret) = target.delegatecall(data); - } else if (callType == CallType.STATICCALL) { - if (value != 0) { - revert ValueOnNonCall(); - } - (ok, ret) = target.staticcall(data); - } else { - revert UnknownCallType(); - } - - if (!ok) { - assembly ("memory-safe") { - revert(add(ret, 0x20), mload(ret)) - } - } - } -} diff --git a/src/minimal/BungeeOpenRouterMinimalAH.sol b/src/minimal/BungeeOpenRouterMinimalAH.sol deleted file mode 100644 index f62b112..0000000 --- a/src/minimal/BungeeOpenRouterMinimalAH.sol +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.34; - -import {BungeeOpenRouterMinimal} from "./BungeeOpenRouterMinimal.sol"; -import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext.sol"; - -/// @title BungeeOpenRouterMinimalAH -/// @notice AllowanceHolder variant of `BungeeOpenRouterMinimal`. Adds the -/// confused-deputy `balanceOf` shim and a user-bound entrypoint that -/// pins the signed payload to a specific `signedUser` (the AH.exec -/// caller). Apart from that, the action loop is identical to v3. -contract BungeeOpenRouterMinimalAH is BungeeOpenRouterMinimal, AllowanceHolderContext { - error CallerNotSignedUser(); - - constructor(address _owner, address _openRouterSigner) - BungeeOpenRouterMinimal(_owner, _openRouterSigner) - {} - - /// @notice AllowanceHolder-aware entrypoint. Same role as - /// `BungeeOpenRouterModularAH.performExecutionAH` - prevents a - /// signed payload meant for user A from being submitted via user - /// B's AllowanceHolder.exec to grief user A's nonce. - function performExecutionAH(Execution calldata exec, address signedUser, bytes calldata signature) external payable { - if (_msgSender() != signedUser) { - revert CallerNotSignedUser(); - } - bytes32 digest = keccak256(abi.encode(block.chainid, address(this), signedUser, exec)); - _verifyAndConsume(digest, exec.nonce, exec.deadline, signature); - _performActions(exec.actions); - } -} diff --git a/src/modular/BungeeOpenRouterModular.sol b/src/modular/BungeeOpenRouterModular.sol deleted file mode 100644 index 806a538..0000000 --- a/src/modular/BungeeOpenRouterModular.sol +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.34; - -import {OpenRouterAuthBase} from "../common/OpenRouterAuthBase.sol"; -import {BytesSpliceLib} from "../common/lib/BytesSpliceLib.sol"; - -/// @title BungeeOpenRouterModular (v2, modular + returndata splicing) -/// @notice Lightweight, generic open-router. Only signature verification is -/// hard-wired into the contract; every other step (token pull, pre- -/// swap fee, swap, post-swap fee, bridge call) is just an `Action` -/// executed via `CALL`, `DELEGATECALL`, or `STATICCALL`. -/// -/// To plumb the *output of a previous step into the input calldata -/// of the next*, each `Action` carries a list of `Splice`s. Each -/// splice copies a slice of the previous action's returndata into a -/// specific byte offset of this action's calldata. This generalises -/// the single-position `mstore` patching used in `GenericStakedRoute` -/// and `BungeeApproveAndBridge` to multiple positions of any length. -/// -/// @dev The base calldata for every action comes from the caller (and is -/// therefore covered by the signature). Splices only mutate parts of -/// that base calldata - they cannot replace it wholesale, so even if -/// one of the actions returns adversarial bytes, an attacker can only -/// move signed amount-shaped data, not redirect the call target or -/// alter unrelated fields. -contract BungeeOpenRouterModular is OpenRouterAuthBase { - enum CallType { - CALL, - DELEGATECALL, - STATICCALL - } - - /// @notice Describes a single byte-range copy from the previous action's - /// returndata into this action's calldata. - struct Splice { - uint256 srcOffset; // offset within the previous returndata - uint256 dstOffset; // offset within this action's `data` - uint256 length; // number of bytes to copy - } - - /// @notice One step in the execution pipeline. - struct Action { - CallType callType; - address target; - uint256 value; // forwarded ETH; must be zero for non-CALL types - bytes data; // mutable in memory: splices may patch parts of it - Splice[] splices; // applied BEFORE this action runs - } - - struct Execution { - Action[] actions; - uint256 nonce; - uint256 deadline; - } - - error ValueOnNonCall(); - error EmptyExecution(); - error UnknownCallType(); - - constructor(address _owner, address _openRouterSigner) OpenRouterAuthBase(_owner, _openRouterSigner) {} - - receive() external payable {} - - /// @notice Executes a signed sequence of actions. - /// @dev The signed digest binds chainId, this contract, and the entire - /// action set, so the caller cannot reorder, retarget, or strip - /// splices from any action. - function performExecution(Execution calldata exec, bytes calldata signature) external payable virtual { - bytes32 digest = keccak256(abi.encode(block.chainid, address(this), exec)); - _verifyAndConsume(digest, exec.nonce, exec.deadline, signature); - _performActions(exec.actions); - } - - /// @notice Internal executor for the action loop. Split out so variants - /// (e.g. the AllowanceHolder variant) can add bindings on top of - /// the base signature check without duplicating the loop. - function _performActions(Action[] calldata actions) internal { - uint256 actionsLen = actions.length; - if (actionsLen == 0) { - revert EmptyExecution(); - } - - bytes memory prevReturn; // empty for the first action; splices on action 0 are illegal - for (uint256 i = 0; i < actionsLen;) { - Action calldata a = actions[i]; - - // Copy the action's data into memory so we can splice it in-place. - bytes memory data = a.data; - - // Apply splices: copy slices from prevReturn into data. - uint256 spLen = a.splices.length; - for (uint256 j = 0; j < spLen;) { - Splice calldata sp = a.splices[j]; - BytesSpliceLib.spliceBytes({ - dst: data, // this action's calldata (base is signed; patched before dispatch) - dstOffset: sp.dstOffset, // write `length` bytes into `dst` starting here - src: prevReturn, // read from the previous action's returndata - srcOffset: sp.srcOffset, // copy slice starting at this offset in `src` - length: sp.length // number of bytes to copy (overwrites same span in `dst`) - }); - unchecked { - ++j; - } - } - - prevReturn = _performAction(a.callType, a.target, a.value, data); - - unchecked { - ++i; - } - } - } - - /// @notice Dispatches a single action and returns its returndata. Reverts - /// are bubbled with the underlying revert data. - function _performAction(CallType callType, address target, uint256 value, bytes memory data) - internal - virtual - returns (bytes memory ret) - { - bool ok; - if (callType == CallType.CALL) { - (ok, ret) = target.call{value: value}(data); - } else if (callType == CallType.DELEGATECALL) { - if (value != 0) { - revert ValueOnNonCall(); - } - (ok, ret) = target.delegatecall(data); - } else if (callType == CallType.STATICCALL) { - if (value != 0) { - revert ValueOnNonCall(); - } - (ok, ret) = target.staticcall(data); - } else { - revert UnknownCallType(); - } - - if (!ok) { - assembly ("memory-safe") { - revert(add(ret, 0x20), mload(ret)) - } - } - } -} diff --git a/src/modular/BungeeOpenRouterModularAH.sol b/src/modular/BungeeOpenRouterModularAH.sol deleted file mode 100644 index 968aa68..0000000 --- a/src/modular/BungeeOpenRouterModularAH.sol +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.34; - -import {BungeeOpenRouterModular} from "./BungeeOpenRouterModular.sol"; -import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext.sol"; - -/// @title BungeeOpenRouterModularAH -/// @notice AllowanceHolder variant of `BungeeOpenRouterModular`. The actual -/// AllowanceHolder pull is just one of the modular `Action`s (a -/// `CALL` to `ALLOWANCE_HOLDER` with `transferFrom(token, user, this, -/// amount)` calldata), so this contract adds very little on top of -/// the base modular contract: -/// -/// - `AllowanceHolderContext` for the dummy `balanceOf` shim that -/// passes AllowanceHolder's confused-deputy probe. -/// - A new `performExecutionAH` entrypoint that takes an explicit -/// `signedUser` argument, includes it in the signed digest, and -/// enforces `_msgSender() == signedUser`. This stops a malicious -/// actor from wrapping someone else's signed payload inside their -/// own `AllowanceHolder.exec` to grief their nonce. -/// -/// @dev Even without the explicit `signedUser` check the AllowanceHolder -/// allowance scoping (`operator + owner + token`) prevents actual -/// fund theft - any pull whose `owner` differs from the AH.exec -/// caller will revert. The `signedUser` binding is purely to avoid -/// someone else burning a signed-but-unsubmitted payload. -contract BungeeOpenRouterModularAH is BungeeOpenRouterModular, AllowanceHolderContext { - error CallerNotSignedUser(); - - constructor(address _owner, address _openRouterSigner) - BungeeOpenRouterModular(_owner, _openRouterSigner) - {} - - /// @notice AllowanceHolder-aware entrypoint. Bind the signed payload to a - /// specific user so it can only be submitted via that user's - /// `AllowanceHolder.exec` call. - function performExecutionAH(Execution calldata exec, address signedUser, bytes calldata signature) external payable { - if (_msgSender() != signedUser) { - revert CallerNotSignedUser(); - } - bytes32 digest = keccak256(abi.encode(block.chainid, address(this), signedUser, exec)); - _verifyAndConsume(digest, exec.nonce, exec.deadline, signature); - _performActions(exec.actions); - } -} diff --git a/src/monolithic/BungeeOpenRouter.sol b/src/monolithic/BungeeOpenRouter.sol deleted file mode 100644 index 4ed46bf..0000000 --- a/src/monolithic/BungeeOpenRouter.sol +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.34; - -import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; - -import {OpenRouterAuthBase} from "../common/OpenRouterAuthBase.sol"; -import {BytesSpliceLib} from "../common/lib/BytesSpliceLib.sol"; -import {CurrencyLib} from "../common/lib/CurrencyLib.sol"; - -/// @title BungeeOpenRouter (v1, monolithic) -/// @notice Monolithic, opinionated open-router: pulls ERC20 funds from a user -/// via standard ERC20 `transferFrom`, optionally takes a pre-swap fee, -/// optionally performs a swap, optionally takes a post-swap fee, then -/// executes a single arbitrary bridge call where the final amount is -/// spliced into the bridge calldata at a list of byte positions. -/// -/// This version is the easiest to reason about because every step is -/// laid out explicitly. The trade-off is rigidity - if a route needs -/// a different ordering or a multi-call bridge interaction, see the -/// modular variants (`BungeeOpenRouterModular`, `BungeeOpenRouterMinimal`). -/// -/// @dev Authentication is matched to `Solver` / `StakedRouterReceiver`: -/// - personal_sign + ecrecover via `AuthenticationLib` -/// - single-use nonces marked with the same assembly pattern -/// - signed digest binds `block.chainid` and `address(this)` so that a -/// payload meant for one deployment cannot be replayed elsewhere. -/// - the user, input token + amount, both fee transfers, the swap, -/// and the bridge calldata are ALL part of the signed payload, so a -/// malicious caller cannot redirect funds. -contract BungeeOpenRouter is OpenRouterAuthBase { - // marked virtual so AllowanceHolder variants can override the pull step - // without duplicating the rest of the body. - using SafeTransferLib for address; - - /// @notice Who is sending funds and how much. - struct InputData { - address user; - address inputToken; - uint256 inputAmount; - } - - /// @notice Optional fee taken in the input token before a swap, or in the - /// bridge token when there is no swap. Set `receiver` to address(0) - /// and `amount` to 0 to skip. - struct FeeData { - address receiver; - uint256 amount; - } - - /// @notice Optional swap step. Set `target` to address(0) to skip entirely. - struct SwapData { - address target; - address approvalSpender; // 0 to skip ERC20 approval - address outputToken; // token measured for balance delta - uint256 value; // ETH forwarded to the swap target - uint256 minOutput; // minimum balance delta; reverts if not met - bytes data; - } - - /// @notice Mandatory bridge call. `amountPositions` lists every byte offset - /// in `data` where the final amount (post-fees) must be written - /// before dispatching the call. - struct BridgeData { - address target; - address approvalSpender; // 0 to skip ERC20 approval - uint256 value; // ETH forwarded to the bridge target - bytes data; - uint256[] amountPositions; - } - - /// @notice Full signed payload for one execution. - /// @dev Signed via personal_sign over keccak256(abi.encode(chainid, this, exec)). - struct Execution { - InputData input; - FeeData preFee; // taken in inputToken before swap - SwapData swap; - FeeData postFee; // taken in finalToken after swap - BridgeData bridge; - uint256 nonce; - uint256 deadline; - } - - error SwapOutputInsufficient(); - error InsufficientFunds(); - error InvalidExecution(); - - constructor(address _owner, address _openRouterSigner) OpenRouterAuthBase(_owner, _openRouterSigner) {} - - receive() external payable {} - - /// @notice Executes the signed payload end-to-end. - /// @dev Anyone can call this; the security boundary is the signature. - function performExecution(Execution calldata exec, bytes calldata signature) external payable { - bytes32 digest = keccak256(abi.encode(block.chainid, address(this), exec)); - _verifyAndConsume(digest, exec.nonce, exec.deadline, signature); - - if (exec.bridge.target == address(0) || exec.input.user == address(0) || exec.input.inputToken == address(0)) { - revert InvalidExecution(); - } - - // 1. pull funds from user; ERC20 transferFrom on the base contract, - // AllowanceHolder transferFrom on the AH variant. - _pullFromUser(exec.input.inputToken, exec.input.user, exec.input.inputAmount); - - // 2. optional pre-swap fee in input token - if (exec.preFee.amount != 0) { - CurrencyLib.transfer(exec.input.inputToken, exec.preFee.receiver, exec.preFee.amount); - } - - // 3. optional swap, accounted via balance delta - address finalToken; - uint256 finalAmount; - if (exec.swap.target != address(0)) { - (finalToken, finalAmount) = _performSwap(exec); - } else { - // no swap path: input minus pre-fee is what we have on-hand - if (exec.preFee.amount > exec.input.inputAmount) { - revert InsufficientFunds(); - } - finalToken = exec.input.inputToken; - unchecked { - finalAmount = exec.input.inputAmount - exec.preFee.amount; - } - } - - // 4. optional post-swap fee in final token - if (exec.postFee.amount != 0) { - if (exec.postFee.amount > finalAmount) { - revert InsufficientFunds(); - } - CurrencyLib.transfer(finalToken, exec.postFee.receiver, exec.postFee.amount); - unchecked { - finalAmount -= exec.postFee.amount; - } - } - - // 5. patch bridge calldata with final amount at every signed position - bytes memory bridgeData = exec.bridge.data; - BytesSpliceLib.spliceWords({data: bridgeData, positions: exec.bridge.amountPositions, word: finalAmount}); - - // 6. optional approval to the bridge spender (no-op if same as target via permit / native) - if (exec.bridge.approvalSpender != address(0) && finalToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(finalToken, exec.bridge.approvalSpender, finalAmount); - } - - // 7. dispatch the bridge call, bubbling any revert - _performAction(exec.bridge.target, exec.bridge.value, bridgeData); - } - - /// @notice Hook for pulling `amount` of `token` from `user` into this - /// contract. Default uses ERC20 transferFrom; the AllowanceHolder - /// variant overrides this to call AllowanceHolder. - function _pullFromUser(address token, address user, uint256 amount) internal virtual { - SafeTransferLib.safeTransferFrom(token, user, address(this), amount); - } - - /// @dev Split out so the main `performExecution` body stays under the - /// marketplace "≤ 100 lines / SRP" guideline. - function _performSwap(Execution calldata exec) internal returns (address finalToken, uint256 finalAmount) { - // Snapshot pre-swap balance of the swap output token on this contract. - uint256 preBalance = CurrencyLib.balanceOf(exec.swap.outputToken, address(this)); - - // Approve swap router to pull the input token if it expects an allowance. - if (exec.swap.approvalSpender != address(0) && exec.input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - // amount available for swap = inputAmount - preFee - uint256 swapInput; - unchecked { - swapInput = exec.input.inputAmount - exec.preFee.amount; - } - SafeTransferLib.safeApproveWithRetry(exec.input.inputToken, exec.swap.approvalSpender, swapInput); - } - - _performAction(exec.swap.target, exec.swap.value, exec.swap.data); - - uint256 postBalance = CurrencyLib.balanceOf(exec.swap.outputToken, address(this)); - if (postBalance < preBalance) { - revert SwapOutputInsufficient(); - } - uint256 delta; - unchecked { - delta = postBalance - preBalance; - } - if (delta < exec.swap.minOutput) { - revert SwapOutputInsufficient(); - } - - finalToken = exec.swap.outputToken; - finalAmount = delta; - } -} diff --git a/src/monolithic/BungeeOpenRouterAH.sol b/src/monolithic/BungeeOpenRouterAH.sol deleted file mode 100644 index 6a1805e..0000000 --- a/src/monolithic/BungeeOpenRouterAH.sol +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity 0.8.34; - -import {BungeeOpenRouter} from "./BungeeOpenRouter.sol"; -import {AllowanceHolderContext} from "../common/allowance/AllowanceHolderContext.sol"; -import {ALLOWANCE_HOLDER} from "../common/interfaces/IAllowanceHolder.sol"; - -/// @title BungeeOpenRouterAH -/// @notice AllowanceHolder variant of `BungeeOpenRouter`. Identical flow, -/// except that user funds are pulled via 0x's AllowanceHolder -/// (transient-storage allowance) rather than a persistent ERC20 -/// allowance to this contract. -/// -/// Expected flow: -/// 1. user (off-chain) approves AllowanceHolder for `inputToken`. -/// 2. backend signer signs the same `Execution` payload as v1. -/// 3. user calls `AllowanceHolder.exec(operator=this, inputToken, -/// inputAmount, target=this, callData=this.execute(...))`. -/// 4. AllowanceHolder writes a transient allowance and forwards the -/// call to this contract with the user's address appended to -/// calldata (ERC-2771 style). -/// 5. this contract verifies the signature, then calls -/// `AllowanceHolder.transferFrom(inputToken, user, address(this), -/// inputAmount)` to pull the funds. -/// 6. remaining steps are identical to v1. -/// -/// @dev We enforce `_msgSender() == exec.user` so the AllowanceHolder -/// ephemeral allowance (keyed by `operator + owner + token`) actually -/// belongs to the user named in the signed payload. -contract BungeeOpenRouterAH is BungeeOpenRouter, AllowanceHolderContext { - error CallerNotSignedUser(); - - constructor(address _owner, address _openRouterSigner) BungeeOpenRouter(_owner, _openRouterSigner) {} - - /// @notice Override the v1 fund-pull hook to use AllowanceHolder. - /// @dev Assembly path mirrors `0x-settler/src/core/Permit2Payment.sol` - /// `_allowanceHolderTransferFrom`. AllowanceHolder's `transferFrom` - /// either reverts or returns true, so we don't bother decoding the - /// return value. - function _pullFromUser(address token, address user, uint256 amount) internal override { - // The signed user MUST equal the original AllowanceHolder.exec caller, - // because AllowanceHolder writes the transient allowance for - // (operator=this, owner=msg.sender_to_AH, token). - if (_msgSender() != user) { - revert CallerNotSignedUser(); - } - - address allowanceHolder = address(ALLOWANCE_HOLDER); - // Build calldata for: AllowanceHolder.transferFrom(token, user, address(this), amount) - // Selector: 0x15dacbea = bytes4(keccak256("transferFrom(address,address,address,uint256)")) - assembly ("memory-safe") { - let ptr := mload(0x40) - mstore(add(0x80, ptr), amount) - mstore(add(0x60, ptr), address()) - mstore(add(0x4c, ptr), shl(0x60, user)) // clears `recipient`'s padding - // `shl(0x60)` (96-bit), NOT `shl(0xa0)` (160-bit): 0xa0 here is literal 160, which - // shifts the 20-byte address out of place and corrupts the calldata token. Same as - // 0x-settler `Permit2Payment._allowanceHolderTransferFrom`. - mstore(add(0x2c, ptr), shl(0x60, token)) // clears `owner`'s padding (settler wording) - mstore(add(0x0c, ptr), 0x15dacbea000000000000000000000000) // selector + token padding - - if iszero(call(gas(), allowanceHolder, 0x00, add(0x1c, ptr), 0x84, 0x00, 0x00)) { - let p := mload(0x40) - returndatacopy(p, 0x00, returndatasize()) - revert(p, returndatasize()) - } - } - } -} From b3cf26884cccebe8b52f7bdd42001cc8fbd0cc97 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 18:29:45 +0530 Subject: [PATCH 27/42] refactor: comments --- src/BungeeOpenRouter.sol | 166 +++++++++++++++++++++++---------------- 1 file changed, 97 insertions(+), 69 deletions(-) diff --git a/src/BungeeOpenRouter.sol b/src/BungeeOpenRouter.sol index 60abf98..b4d171c 100644 --- a/src/BungeeOpenRouter.sol +++ b/src/BungeeOpenRouter.sol @@ -49,10 +49,6 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { uint256 value; } - // ========================================================================= - // Modular execution types - // ========================================================================= - enum CallType { CALL, STATICCALL, @@ -81,7 +77,7 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { // bit 1 : BALANCE_FLAG_BIT_MASK (0x02) — swap output: returndata vs balance delta // bit 0 : POST_FEE_FLAG_BIT_MASK (0x01) — swap fee: pre- vs post-swap // - // Combined values for swap()/swapAndBridge(): + // Combined values for flags: // // flags binary (low byte) postFee? balance-of output? bridge value? // ───── ────────────────── ──────── ────────────────── ───────────── @@ -91,21 +87,21 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { // 0x03 00000011 yes balance delta on outputToken bridge.value // 0x04 00000100 no returndata word finalAmount + bridge.value // - // POST_FEE_FLAG_BIT_MASK selects bit 0 — fee timing. - // Cleared — pull → deduct fee from input token → swap remainder. - // Set — pull → swap full input → deduct fee from output token (after minOutput check on swap result). + // POST_FEE_FLAG_BIT_MASK selects bit 0 — fee timing + // 0000 — pre-swap fee: pull → deduct fee from input token → swap remainder + // 0001 — post-swap fee: pull → swap full input → deduct fee from output token (after minOutput check on swap result) // - // BALANCE_FLAG_BIT_MASK selects bit 1 — swap output sizing. - // Cleared — decode returned amount from call returndata at `swapData.returnDataWordOffset`. - // Set — snapshot outputToken balance before call, measure (after − before) as output. + // BALANCE_FLAG_BIT_MASK selects bit 1 — swap output sizing + // 0000 — returnData as swap output: decode returned amount from call returndata at `swapData.returnDataWordOffset` + // 0010 — balanceOf() delta as swap output: snapshot outputToken balance before call, measure (after − before) as output // - // BRIDGE_VALUE_FLAG_BIT_MASK selects bit 2 — bridge native value source. - // Cleared — forward `bridge.value` as msg.value. - // Set — forward `finalAmount + bridge.value` as msg.value (bridge.value carries static addend, e.g. LZ nativeFee). + // BRIDGE_VALUE_FLAG_BIT_MASK selects bit 2 — bridge native value source + // 0000 — bridge.value as msg.value: forward `bridge.value` as msg.value + // 0100 — finalAmount + bridge.value as msg.value: forward `finalAmount + bridge.value` as msg.value (bridge.value carries static addend, e.g. LZ nativeFee) // // BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK selects bit 3 — bridge calldata amount splicing. - // Cleared — no runtime amount splice. - // Set — splice finalAmount at uint16(flags >> BRIDGE_AMOUNT_POSITION_SHIFT). + // 0000 — no bridge calldata modification + // 1000 — bridge calldata modification: splice finalAmount at uint16(flags >> BRIDGE_AMOUNT_POSITION_SHIFT) // /// @dev Bit mask 0x01: post-swap fee path when `(flags & mask) != 0`; clear = pre-swap fee from input token. @@ -150,8 +146,10 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { // Constructor // ========================================================================= - /// @notice Deploys the router and grants `RESCUE_ROLE` to `_owner`. - /// @param _owner Initial contract owner and rescue-role holder. + /** + * @notice Deploys the router and grants `RESCUE_ROLE` to `_owner`. + * @param _owner Initial contract owner and rescue-role holder. + */ constructor(address _owner) AccessControl(_owner) { _grantRole(RESCUE_ROLE, _owner); } @@ -164,21 +162,20 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { // ========================================================================= /** - * @notice Pull → optional pre/post fee → swap. + * @notice Perform swap with optional pre/post fee. * @param quoteId Caller-defined correlation id logged in `RequestExecuted`. + * @param flags Packed flags * @param input User, input token, and pull amount. - * @param receiver Address that ultimately receives the swap output (net of any post-swap fee). For pre-fee / no-fee: the swap router must - * be instructed (via `swapCallData`) to send tokens directly to `receiver`; the contract never holds the output. For post-fee: tokens land - * at this contract, fee is deducted, net is forwarded to `receiver`. - * @param flags Packed flags; OR masks then test with `flags & MASK != 0`. Masks: `POST_FEE_FLAG_BIT_MASK` (0x01), `BALANCE_FLAG_BIT_MASK` - * (0x02). Common values: `0` (pre-fee, returndata), `1` (post-fee, returndata), `2` (pre-fee, balance delta), `3` (post-fee, balance delta). - * @param fee Set `amount` to 0 to skip fee collection. + * @dev For pre-fee / no-fee: the swap router must + * be instructed (via `swapCallData`) to send tokens directly to `receiver`; the contract never holds the output. + * For post-fee: tokens land at this contract, fee is deducted, net is forwarded to `receiver`. + * @param fee Fee collection info: receiver and amount. Set `amount` to 0 to skip fee collection. * @param swapData Swap target, spender, output token, value, `minOutput`, and returndata offset. * @param swapCallData Calldata forwarded to `swapData.target`. - * @return finalAmount Gross swap output before any post-swap fee; net delivered to `receiver` on post-fee paths. - * @dev `minOutput` is the minimum gross amount coming out of the swap (before any output-token fee). It is enforced immediately after - * `_execSwap`, then post-swap fee (if any) is collected. Pre-fee paths take the input-side fee before the swap; `minOutput` still guards the - * swap outcome. + * @param receiver Address that ultimately receives the swap output (net of any post-swap fee). + * @return finalAmount Gross swap output sent to receiver after any post-swap fee + * @dev `minOutput` is the minimum gross amount coming out of the swap (before any output-token fee). It is enforced immediately after `_execSwap`, then post-swap fee (if any) is collected. + * Pre-fee paths take the input-side fee before the swap; `minOutput` still guards the swap outcome. */ function swap( bytes32 quoteId, @@ -196,13 +193,15 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { revert InvalidExecution(); } - bool postFee = fee.amount != 0 && flags & POST_FEE_FLAG_BIT_MASK != 0; - bool useBalanceOf = flags & BALANCE_FLAG_BIT_MASK != 0; + // Parse flags + bool postFee = fee.amount != 0 && ((flags & POST_FEE_FLAG_BIT_MASK) != 0); + bool useBalanceOf = ((flags & BALANCE_FLAG_BIT_MASK) != 0); { + // Pull funds from user via AllowanceHolder _pullFromUser(input.inputToken, input.user, input.inputAmount); - // POST_FEE_FLAG_BIT_MASK: unset ⇒ collect fee from input before swap; set ⇒ swap first, fee from output + // Collect pre-swap fee uint256 swapInput = input.inputAmount; if (fee.amount != 0 && !postFee) { uint256 feeAmount = fee.amount; @@ -212,38 +211,43 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { } } + // Approve swap spender if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); } } - // Post-fee: swap output lands at this contract so the fee can be deducted before forwarding. - // Pre-fee / no-fee: swap calldata encodes `receiver` as the output recipient; tokens never touch this contract. + /// @dev Pre-fee / no-fee: swap calldata encodes `receiver` as the output recipient; tokens never touch this contract. + /// @dev Post-fee: swap output lands at this contract so the fee can be deducted before forwarding. address outputReceiver = postFee ? address(this) : receiver; - // BALANCE_FLAG_BIT_MASK: unset ⇒ decode output word from returndata; set ⇒ output = delta on outputToken + // Execute swap finalAmount = _execSwap(swapData, swapCallData, useBalanceOf, outputReceiver); if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); if (postFee) { + // Collect post-swap fee uint256 feeAmount = fee.amount; CurrencyLib.transfer(swapData.outputToken, fee.receiver, feeAmount); unchecked { finalAmount -= feeAmount; } + + // Transfer net output to receiver CurrencyLib.transfer(swapData.outputToken, receiver, finalAmount); } + // Pre-fee / no-fee: tokens were sent directly to `receiver` by the swap router; nothing to transfer emit RequestExecuted(quoteId); } /** - * @notice Pull → optional pre/post swap fee → swap → bridge with runtime amount splicing. + * @notice Perform swap and bridge with optional pre/post swap fee. * @param quoteId Caller-defined correlation id logged in `RequestExecuted`. + * @param flags Packed flags * @param input User, input token, and pull amount. - * @param flags Same packing as `swap`; bits 2–3 also control bridge `msg.value` and calldata splicing. - * @param fee Set `amount` to 0 to skip fee collection. + * @param fee Fee collection info: receiver and amount. Set `amount` to 0 to skip fee collection. * @param swapData Swap target, spender, output token, value, `minOutput`, and returndata offset. * @param swapCallData Calldata forwarded to `swapData.target`. * @param bridgeData Bridge target, approval spender, and static `msg.value` addend. @@ -267,22 +271,25 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { revert InvalidExecution(); } + // Execute swap before bridge uint256 finalAmount = _swapBeforeBridge(flags, input, fee, swapData, swapCallData); + + // Execute bridge _doBridge(swapData.outputToken, finalAmount, flags, bridgeData, bridgeCallData); + emit RequestExecuted(quoteId); } /** - * @notice Pull → optional pre-bridge fee → bridge, with no swap step. + * @notice Perform bridge with optional pre-bridge fee. * @param quoteId Caller-defined correlation id logged in `RequestExecuted`. * @param input User, input token, and pull amount. - * @param fee Pre-bridge fee taken from the input token; set `amount` to 0 to skip. + * @param fee Fee collection info: receiver and amount. Set `amount` to 0 to skip fee collection. * @param bridgeData Bridge target, approval spender, and `msg.value` for the bridge call. * @param bridgeCallData Calldata forwarded to `bridgeData.target` (amount must be baked in by the caller). - * @dev Because no swap is involved, `finalAmount = inputAmount - feeAmount` is fully knowable by the caller before signing. The caller must - * therefore bake the correct amount directly into `bridgeCallData` and set `bridgeData.value` to the desired `msg.value` for the bridge - * call. No runtime calldata splicing is performed. The caller MUST route through `AllowanceHolder.exec` for ERC-20 inputs so that - * `_msgSender()` resolves to `input.user`. + * @dev Because no swap is involved, `finalAmount = inputAmount - feeAmount` is fully knowable by the caller before signing. + * The caller must therefore bake the correct amount directly into `bridgeCallData` and set `bridgeData.value` to the desired `msg.value` for the bridge call. + * No runtime calldata splicing is performed. The caller MUST route through `AllowanceHolder.exec` for ERC-20 inputs so that `_msgSender()` resolves to `input.user`. */ function bridge( bytes32 quoteId, @@ -295,13 +302,16 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { revert InvalidExecution(); } + // Pull funds from user via AllowanceHolder _pullFromUser(input.inputToken, input.user, input.inputAmount); + // Collect pre-bridge fee uint256 feeAmount = fee.amount; if (feeAmount != 0) { CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); } + // Approve bridge spender if (bridgeData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { uint256 netAmount; unchecked { @@ -310,7 +320,9 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { SafeTransferLib.safeApproveWithRetry(input.inputToken, bridgeData.approvalSpender, netAmount); } + // Execute bridge _doCallCalldata(bridgeData.target, bridgeData.value, bridgeCallData, false); + emit RequestExecuted(quoteId); } @@ -326,15 +338,13 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { returns (bytes[] memory results) { results = _performActions(actions); + emit RequestExecuted(quoteId); } // ========================================================================= // Internal functions // ========================================================================= - // - // swap / bridge — orchestration is inline in the external functions; they use - // _pullFromUser, _execSwap (swap), and _doCallCalldata (bridge) from common below. // ------------------------------------- // swapAndBridge internal functions @@ -342,8 +352,8 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { /** * @dev Pull, optional pre/post swap fee, and swap for `swapAndBridge`. Swap output always remains at `address(this)` for bridging. - * @param input User, input token, and pull amount. * @param flags Fee timing and swap output measurement flags (same as `swap`). + * @param input User, input token, and pull amount. * @param fee Fee receiver and amount; `amount == 0` skips fee collection. * @param swapData Swap target, spender, output token, value, `minOutput`, and returndata offset. * @param swapCallData Calldata forwarded to `swapData.target`. @@ -356,12 +366,14 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { SwapData calldata swapData, bytes calldata swapCallData ) internal returns (uint256 finalAmount) { + // Pull funds from user via AllowanceHolder _pullFromUser(input.inputToken, input.user, input.inputAmount); + bool postFee; { - // POST_FEE_FLAG_BIT_MASK: same semantics as standalone `swap` (fee from input vs output token) + // Collect pre-swap fee uint256 feeAmount = fee.amount; - postFee = feeAmount != 0 && flags & POST_FEE_FLAG_BIT_MASK != 0; + postFee = feeAmount != 0 && ((flags & POST_FEE_FLAG_BIT_MASK) != 0); uint256 swapInput = input.inputAmount; if (feeAmount != 0 && !postFee) { @@ -371,17 +383,19 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { } } + // Approve swap spender if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); } } - // BALANCE_FLAG_BIT_MASK: same returndata vs balance-delta measurement as `swap` - // Swap output always lands at this contract regardless of fee timing — tokens must be here for bridging. - bool useBalanceOf = flags & BALANCE_FLAG_BIT_MASK != 0; + // Execute swap + /// @dev Swap output always lands at this contract regardless of fee timing — tokens must be here for bridging. + bool useBalanceOf = ((flags & BALANCE_FLAG_BIT_MASK) != 0); finalAmount = _execSwap(swapData, swapCallData, useBalanceOf, address(this)); if (finalAmount < swapData.minOutput) revert SwapOutputInsufficient(); + // Collect post-swap fee if (postFee) { uint256 feeAmount = fee.amount; CurrencyLib.transfer(swapData.outputToken, fee.receiver, feeAmount); @@ -395,9 +409,9 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { * @dev Splice `amount` into bridge calldata when flagged, approve the bridge spender, and call the bridge target. * @param token ERC-20 bridged (or native sentinel); used for approval only. * @param amount Post-swap token amount spliced into calldata and/or forwarded as `msg.value`. + * @param flags Bridge splice position, `msg.value` composition, and related bit flags. * @param bridgeData Bridge target, approval spender, and static `msg.value` addend. * @param bridgeCallData Base bridge calldata; copied to memory when splicing is required. - * @param flags Bridge splice position, `msg.value` composition, and related bit flags. */ function _doBridge( address token, @@ -407,17 +421,22 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { bytes calldata bridgeCallData ) internal { bytes memory _bridgeCallData = bridgeCallData; + + // Modify bridge calldata if splicing is required if (flags & BRIDGE_AMOUNT_POSITION_FLAG_BIT_MASK != 0) { uint256 position = flags >> BRIDGE_AMOUNT_POSITION_SHIFT & BRIDGE_AMOUNT_POSITION_MASK; BytesSpliceLib.spliceWord({data: _bridgeCallData, position: position, word: amount}); } + // Approve bridge spender if (bridgeData.approvalSpender != address(0) && token != CurrencyLib.NATIVE_TOKEN_ADDRESS) { SafeTransferLib.safeApproveWithRetry(token, bridgeData.approvalSpender, amount); } - // when set, forward amount as msg.value for native-token bridges - uint256 bridgeValue = flags & BRIDGE_VALUE_FLAG_BIT_MASK != 0 ? amount + bridgeData.value : bridgeData.value; + // Parse and set bridge value flag + uint256 bridgeValue = ((flags & BRIDGE_VALUE_FLAG_BIT_MASK) != 0) ? amount + bridgeData.value : bridgeData.value; + + // Execute bridge call _doCall(bridgeData.target, bridgeValue, _bridgeCallData); } @@ -426,8 +445,12 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { // -------------------------------------- /** - * @dev Executes `actions` in order, applying returndata splices before each call. `actionInfo` layout: bits 0–7 call type (`CallType`), bit 8 - * store returndata, bits 16+ target address. `splices[j]` packs source index, src/dst byte offsets, and length. + * @dev Executes `actions` in order, applying returndata splices before each call. + * @dev actionInfo layout: + * - bits 0–7: call type (`CallType`) + * - bit 8: store returndata + * - bits 16+: target address + * splices[j` packs source index, src/dst byte offsets, and length. * @param actions Ordered list of actions to run. * @return results Stored returndata per action when the store-result bit is set. */ @@ -509,15 +532,16 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { // ------------------------------- /** - * @dev Pulls `amount` of `token` from `user` into this contract. For ERC20: enforces `_msgSender() == user` (caller must have routed through - * `AllowanceHolder.exec`) and calls AH.transferFrom via assembly. AH selector: - * transferFrom(address,address,address,uint256) = 0x15dacbea. For native ETH: ETH must already be present as msg.value; verify sufficient - * value was forwarded. No AH call is needed. + * @dev Pulls `amount` of `token` from `user` into this contract. + * For ERC20: enforces `_msgSender() == user` (caller must have routed through `AllowanceHolder.exec`) and calls AH.transferFrom via assembly. + * AH selector: transferFrom(address,address,address,uint256) = 0x15dacbea. + * For native ETH: ETH must already be present as msg.value; verify sufficient value was forwarded. * @param token Input token or `CurrencyLib.NATIVE_TOKEN_ADDRESS`. * @param user Owner whose AllowanceHolder-scoped allowance is consumed. * @param amount Tokens or wei to pull. */ function _pullFromUser(address token, address user, uint256 amount) internal { + // Check input value if native token if (token == CurrencyLib.NATIVE_TOKEN_ADDRESS) { if (msg.value < amount) { revert InsufficientMsgValue(); @@ -525,10 +549,10 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { return; } - if (_msgSender() != user) { - revert CallerNotSignedUser(); - } + // Check caller is user + if (_msgSender() != user) revert CallerNotSignedUser(); + // Call AllowanceHolder.transferFrom() address allowanceHolder = address(ALLOWANCE_HOLDER); assembly ("memory-safe") { let ptr := mload(0x40) @@ -550,10 +574,11 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { } /** - * @dev Executes the swap call and returns the output amount. `useBalanceOf=true`: measure output as (balance after − balance before) at - * `outputReceiver`. `useBalanceOf=false`: decode output from returndata at `swapData.returnDataWordOffset`. `outputReceiver` must be - * `address(this)` when tokens are expected at the contract (post-swap fee path, bridge path) or the end user when the router sends directly - * to them. + * @dev Executes the swap call and returns the output amount. + * `useBalanceOf=true`: measure output as (balance after − balance before) at `outputReceiver`. + * `useBalanceOf=false`: decode output from returndata at `swapData.returnDataWordOffset`. + * `outputReceiver` must be `address(this)` when tokens are expected at the contract (post-swap fee path, bridge path) + * or the end user when the router sends directly to them. * @param swapData Swap target, value, output token, and returndata layout. * @param swapCallData Calldata forwarded to `swapData.target`. * @param useBalanceOf When true, use balance delta instead of returndata decoding. @@ -567,10 +592,12 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { address outputReceiver ) internal returns (uint256 finalAmount) { if (useBalanceOf) { + // Measure output as (balance after − balance before) at `outputReceiver` uint256 before = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver); _doCallCalldata(swapData.target, swapData.value, swapCallData, false); finalAmount = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver) - before; } else { + // Decode output from returndata bytes memory ret = _doCallCalldata(swapData.target, swapData.value, swapCallData, true); finalAmount = _decodeReturnWord(ret, swapData.returnDataWordOffset); } @@ -603,6 +630,7 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { /** * @dev Low-level `call` using calldata copied to memory; optionally captures returndata. + * @dev Helps cheaper external calls avoiding early copy of calldata to memory. * @param target Call recipient. * @param value Wei forwarded with the call. * @param data Calldata slice forwarded to `target`. From 4eb6ab2b04fa521f85b0909e96d34ae9c91124b4 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 18:39:38 +0530 Subject: [PATCH 28/42] refactor: remove performActions return --- src/BungeeOpenRouter.sol | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/BungeeOpenRouter.sol b/src/BungeeOpenRouter.sol index b4d171c..fe38272 100644 --- a/src/BungeeOpenRouter.sol +++ b/src/BungeeOpenRouter.sol @@ -330,14 +330,9 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { * @notice Runs a sequence of generic actions with optional returndata splicing between steps. * @param quoteId Caller-defined correlation id logged in `RequestExecuted`. * @param actions Ordered actions; each may splice bytes from a prior action's returndata into its calldata. - * @return results Per-action returndata when the action's `actionInfo` store-result bit is set. */ - function performActions(bytes32 quoteId, Action[] calldata actions) - external - payable - returns (bytes[] memory results) - { - results = _performActions(actions); + function performActions(bytes32 quoteId, Action[] calldata actions) external payable { + _performActions(actions); emit RequestExecuted(quoteId); } @@ -452,11 +447,10 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { * - bits 16+: target address * splices[j` packs source index, src/dst byte offsets, and length. * @param actions Ordered list of actions to run. - * @return results Stored returndata per action when the store-result bit is set. */ - function _performActions(Action[] calldata actions) internal returns (bytes[] memory results) { + function _performActions(Action[] calldata actions) internal { uint256 actionsLength = actions.length; - results = new bytes[](actionsLength); + bytes[] memory results = new bytes[](actionsLength); for (uint256 i; i < actionsLength;) { Action calldata action = actions[i]; From e7a81b55fa38f5589ff57b831090e1d98faa787f Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 19:08:57 +0530 Subject: [PATCH 29/42] refactor: check and set max approval --- src/BungeeOpenRouter.sol | 60 +++++++++++++++++++++++--------- src/common/interfaces/IERC20.sol | 6 ++++ 2 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 src/common/interfaces/IERC20.sol diff --git a/src/BungeeOpenRouter.sol b/src/BungeeOpenRouter.sol index fe38272..3cc89d2 100644 --- a/src/BungeeOpenRouter.sol +++ b/src/BungeeOpenRouter.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.34; import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; +import {IERC20} from "./common/interfaces/IERC20.sol"; import {AccessControl} from "./common/utils/AccessControl.sol"; import {AllowanceHolderContext} from "./common/allowance/AllowanceHolderContext.sol"; import {ALLOWANCE_HOLDER} from "./common/interfaces/IAllowanceHolder.sol"; @@ -211,9 +212,15 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { } } - // Approve swap spender - if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); + // Approve spender + if ( + // check spender & token + swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS && + // check current allowance + swapInput > IERC20(input.inputToken).allowance(address(this), swapData.approvalSpender) + ) { + // approve max allowance + SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, type(uint256).max); } } @@ -311,13 +318,20 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); } + uint256 netAmount; + unchecked { + netAmount = input.inputAmount - feeAmount; + } + // Approve bridge spender - if (bridgeData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - uint256 netAmount; - unchecked { - netAmount = input.inputAmount - feeAmount; - } - SafeTransferLib.safeApproveWithRetry(input.inputToken, bridgeData.approvalSpender, netAmount); + if ( + // check spender && token + bridgeData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS && + // check current allowance + netAmount > IERC20(input.inputToken).allowance(address(this), bridgeData.approvalSpender) + ) { + // approve max allowance + SafeTransferLib.safeApproveWithRetry(input.inputToken, bridgeData.approvalSpender, type(uint256).max); } // Execute bridge @@ -379,8 +393,14 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { } // Approve swap spender - if (swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, swapInput); + if ( + // check spender & token + swapData.approvalSpender != address(0) && input.inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS && + // check current allowance + swapInput > IERC20(input.inputToken).allowance(address(this), swapData.approvalSpender) + ) { + // approve max allowance + SafeTransferLib.safeApproveWithRetry(input.inputToken, swapData.approvalSpender, type(uint256).max); } } @@ -424,8 +444,14 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { } // Approve bridge spender - if (bridgeData.approvalSpender != address(0) && token != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(token, bridgeData.approvalSpender, amount); + if ( + // check spender & token + bridgeData.approvalSpender != address(0) && token != CurrencyLib.NATIVE_TOKEN_ADDRESS && + // check current allowance + amount > IERC20(token).allowance(address(this), bridgeData.approvalSpender) + ) { + // approve max allowance + SafeTransferLib.safeApproveWithRetry(token, bridgeData.approvalSpender, type(uint256).max); } // Parse and set bridge value flag @@ -568,10 +594,10 @@ contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { } /** - * @dev Executes the swap call and returns the output amount. - * `useBalanceOf=true`: measure output as (balance after − balance before) at `outputReceiver`. - * `useBalanceOf=false`: decode output from returndata at `swapData.returnDataWordOffset`. - * `outputReceiver` must be `address(this)` when tokens are expected at the contract (post-swap fee path, bridge path) + * @dev Executes the swap call and returns the output amount. + * `useBalanceOf=true`: measure output as (balance after − balance before) at `outputReceiver`. + * `useBalanceOf=false`: decode output from returndata at `swapData.returnDataWordOffset`. + * `outputReceiver` must be `address(this)` when tokens are expected at the contract (post-swap fee path, bridge path) * or the end user when the router sends directly to them. * @param swapData Swap target, value, output token, and returndata layout. * @param swapCallData Calldata forwarded to `swapData.target`. diff --git a/src/common/interfaces/IERC20.sol b/src/common/interfaces/IERC20.sol new file mode 100644 index 0000000..d4ea45e --- /dev/null +++ b/src/common/interfaces/IERC20.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.34; + +interface IERC20 { + function allowance(address owner, address spender) external view returns (uint256); +} From 5307f1b142223f369f9322a2a0c397dfb887e038 Mon Sep 17 00:00:00 2001 From: arthcp Date: Tue, 19 May 2026 17:49:08 +0400 Subject: [PATCH 30/42] feat: agent docs --- AGENTS.md | 6 + OPENROUTER_ASSUMPTIONS.md | 248 ++++++++++++++++++++++++++++++++++++++ OPENROUTER_CONTEXT.md | 108 +++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 AGENTS.md create mode 100644 OPENROUTER_ASSUMPTIONS.md create mode 100644 OPENROUTER_CONTEXT.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f714adb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Project Context + +For OpenRouter contract work read files which are relevant for the task. general context - `OPENROUTER_CONTEXT.md` +assumptions - `OPENROUTER_ASSUMPTIONS.md` first. + +Main ship target is `src/combined/BungeeOpenRouterV2Unchecked.sol`. If its ABI changes, update the backend encoders in `bungee-backend/src/modules/dex/utils.ts` and `bungee-backend/src/modules/router/utils/directQuotesOpenRouter.ts`. diff --git a/OPENROUTER_ASSUMPTIONS.md b/OPENROUTER_ASSUMPTIONS.md new file mode 100644 index 0000000..7ba02a6 --- /dev/null +++ b/OPENROUTER_ASSUMPTIONS.md @@ -0,0 +1,248 @@ +# OpenRouter Assumptions + +Last reviewed: 2026-05-19. + +Scope: `src/combined/BungeeOpenRouterV2Unchecked.sol`. + +This document captures the assumptions that make the unchecked OpenRouter safe to operate. Many of these are business and integration assumptions, not guarantees enforced by the contract. + +## Source Of Truth + +`BungeeOpenRouterV2Unchecked` intentionally removes backend signature verification, nonces, and deadlines. Public entrypoints can be called by anyone. + +Current checked-in public surface: + +- `swap(...)` +- `swapAndBridge(...)` +- `bridge(...)` +- `performModularExecution(...)` +- `rescueFunds(...)` + +`OPENROUTER_CONTEXT.md` and `scripts/e2e/utils/routerAbi.ts` may mention `performExecution(...)`; verify against the Solidity file before relying on that ABI. + +## Enforcement Classes + +Use this distinction when reviewing any route or integration: + +- On-chain enforced: checked directly by the router. +- Operationally enforced: must be true because frontend, backend, deploy config, or runbooks enforce it. +- Policy assumption: not enforced by code. If it becomes false, the unchecked router can become unsafe. + +## Critical Business Assumptions + +### Router Never Holds Durable Funds + +The router may temporarily hold funds during one transaction, but it should not end routes with meaningful token or native balances. + +Failure mode: `performModularExecution` lets any caller make the router call arbitrary contracts. If the router holds ERC20s, native ETH, bridged refunds, swap dust, rebates, or protocol refunds, a public caller can move or approve those assets through modular actions before owner rescue. + +Operational requirements: + +- Do not use the router as a treasury, escrow, settlement account, refund address, or fee vault. +- Route calldata should send final assets to the user, bridge, or fee recipient in the same transaction. +- Monitor router token/native balances and treat non-zero balances as an incident or stuck-funds condition. +- Owner rescue is an operational recovery tool, not a security boundary. + +### Users Never Directly Approve The Router + +Users must not give persistent ERC20, Permit2, ERC721, ERC1155, or protocol-specific approvals directly to the router. + +Failure mode: if a user directly approves the router, any caller can use `performModularExecution` to make the router call `transferFrom`, `approve`, or equivalent privileged token functions against that user allowance. + +Operational requirements: + +- User ERC20 approvals should go to 0x AllowanceHolder, not OpenRouter. +- UI copy and wallet flows must never ask users to approve OpenRouter directly. +- Monitoring should flag direct allowances from users to the router. +- If a direct approval is discovered, revoke it before treating that user as safe. + +### Router Has No Privileged Role On Other Contracts + +No external contract should treat OpenRouter as a privileged actor unless every public caller is allowed to exercise that privilege. + +Failure mode: if another contract has `onlyRouter`, allowlists the router, grants it minter/burner/pauser/admin/operator/bridge-agent permissions, or keys permissions off `msg.sender == router`, any caller can exercise that role through modular execution. + +Operational requirements: + +- Do not grant OpenRouter roles in bridges, vaults, tokens, staking systems, receivers, relayers, or settlement contracts. +- Do not whitelist OpenRouter in downstream contracts as a trusted caller unless the called operation is safe for arbitrary public callers. +- Review new integrations for hidden trust checks against `msg.sender`. + +### Router Is Not A User-Intent Authority + +The unchecked router does not prove that a route reflects user intent. It only executes calldata. + +Failure mode: a malicious UI or compromised backend can make the user call `AllowanceHolder.exec` with calldata that pays an attacker, charges an arbitrary fee, bridges to a wrong recipient, or approves a malicious spender. + +Operational requirements: + +- The frontend/backend must validate recipients, fee receivers, fee amounts, swap targets, bridge targets, approval spenders, destination chain/domain, bridge min amounts, and refund addresses before presenting a transaction. +- Wallet simulation and transaction review should show the actual route effects where possible. +- `requestHash` is only an event correlation id. It does not enforce uniqueness, replay protection, or user consent. + +## Fund Pull Assumptions + +### ERC20 Inputs Use AllowanceHolder + +ERC20 input safety depends on 0x AllowanceHolder transient allowance scoping plus `_msgSender() == input.user`. + +On-chain enforced: + +- `_pullFromUser` reverts unless `_msgSender() == input.user` for ERC20 inputs. +- When called through AllowanceHolder, `_msgSender()` is decoded from the appended user address. + +Operational assumptions: + +- The user calls `AllowanceHolder.exec(operator, token, amount, target, data)`. +- `operator` is the router. +- `target` is the router. +- `token` and `amount` match the route input. +- The user has a persistent approval to AllowanceHolder, not to the router. + +Failure modes: + +- Direct ERC20 calls to the router fail because `_msgSender()` is not the user. +- Bad AH calldata can still execute a bad route if the user submits it. +- AH protects fund pulling for the route input, but it does not validate swap/bridge semantics. + +### Native Inputs Are Not User-Bound + +Native-token input routes only check that `msg.value >= inputAmount`. + +Failure mode: `input.user` is not authenticated for native routes. Anyone can submit native routes if they provide the ETH. This is usually acceptable because the caller funds the transaction, but downstream analytics must not treat `input.user` as authenticated identity for native paths. + +Operational requirements: + +- Native route attribution should come from transaction signer / AH sender / product context, not only `input.user`. +- Excess `msg.value` is not automatically refunded by the router. + +## Execution Assumptions + +### External Targets Are Trusted Per Route + +The router does not whitelist swap targets, bridge targets, approval spenders, manipulators, receivers, or fee recipients. + +Failure modes: + +- Malicious swap target can consume approved input and return misleading returndata. +- Malicious bridge target can consume approved output or native value. +- Malicious approval spender can use allowance after the route if allowance remains and the router later receives the same token. +- Malicious fee receiver can reject native fee transfers and revert the route. + +Operational requirements: + +- Backend/frontend must maintain target and spender allowlists or equivalent route validation. +- Approval spender should be the minimum necessary protocol spender. +- Prefer route patterns that leave no router balance and no meaningful residual allowance. + +### Swap Output Measurement Matches The Aggregator + +The router supports two output modes: + +- Returndata mode: decode a 32-byte word at `swapData.returnDataWordOffset`. +- Balance-delta mode: measure `balanceOf(outputReceiver)` before and after the swap. + +Failure modes: + +- Returndata mode is unsafe if the target return word is not the actual output amount. +- Balance-delta mode is unsafe if unrelated balance changes occur during the call, or if the token has rebasing/fee-on-transfer behavior that breaks expected deltas. +- In standalone pre-fee/no-fee swaps, the swap calldata must send output directly to `receiver`; the router will not forward output afterward. +- In standalone post-fee swaps and all `swapAndBridge` paths, the swap output must land on the router. + +Operational requirements: + +- Choose output mode per aggregator and route. +- Verify `returnDataWordOffset` against the concrete swap target ABI. +- Verify output recipient encoded in `swapCallData` matches the router mode. +- Treat `minOutput` as gross swap output, not guaranteed net-to-user output after post-fee or bridge fees. + +### Fee Semantics Are Caller-Defined + +The router does not enforce fee policy. + +Assumptions: + +- Pre-fee amounts are denominated in the input token. +- Post-fee amounts are denominated in the output token. +- `fee.receiver` is trusted and product-approved. +- `fee.amount` is within product policy. + +Failure modes: + +- A malicious caller can set an arbitrary fee receiver and amount if the user submits the calldata. +- Post-fee is applied after gross `minOutput` validation, so net user proceeds can be lower than `minOutput`. + +### Bridge Calldata Is Semantically Correct + +The router does not understand bridge-specific fields. + +Assumptions: + +- Destination chain/domain is correct. +- Recipient is correct. +- Refund address is not the router unless intentionally safe. +- Bridge min amount / slippage fields are correct. +- Bridge fee quote and native fee buffer are current enough. +- Token and amount fields in calldata match the route. + +Failure modes: + +- `bridge()` performs no runtime amount splicing; the amount must already be encoded. +- `swapAndBridge()` can splice one 32-byte amount word only. +- The bridge-value flag forwards `finalAmount + bridgeData.value` as native value. It must only be used when the bridge expects the bridged asset itself as native value plus a static fee. +- Excess native fee behavior depends on the bridge target and refund address, not OpenRouter. + +## Modular Execution Assumptions + +`performModularExecution` is the broadest surface. It makes the router a public generic call executor. + +Assumptions: + +- The router has no durable funds. +- No user has directly approved the router. +- No external contract gives the router privileged rights. +- Each action target is safe for the router to call. +- Splice offsets and lengths are generated by trusted tooling. +- Actions that are splice sources store their returndata. + +Failure modes: + +- Any public caller can transfer, approve, or spend assets already held by the router. +- Any public caller can exercise downstream privileges granted to the router. +- `CALL_WITH_NATIVE` can spend native ETH already sitting in the router. +- Invalid `callType` values fall through to normal `CALL`; encoders must emit only known call types. +- Splices are bounds-checked but not semantically validated. A bad splice can write a valid but wrong bridge amount, recipient field, fee field, or payload word. + +## Token Assumptions + +Assumptions: + +- ERC20s follow sane `transfer`, `transferFrom`, `approve`, and `balanceOf` behavior. +- The native token sentinel is exactly `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`. +- Tokens do not rebase or charge transfer fees in ways that invalidate route amounts, unless route tooling explicitly accounts for that. +- Approval reset/retry behavior in Solady `safeApproveWithRetry` is acceptable for the token. + +Failure modes: + +- Fee-on-transfer tokens can cause bridge approvals or calldata amounts to exceed actual received balances. +- Rebasing tokens can corrupt balance-delta output measurement. +- Non-standard tokens can revert, return false, or have allowance quirks. + +## Operational Checklist + +Before enabling a route or integration, confirm: + +- Users approve AllowanceHolder only. +- The router has no direct user allowances. +- The router has no privileged roles on any touched contract. +- The router is not used as recipient, refund address, treasury, or settlement vault unless public draining is acceptable. +- Swap target, bridge target, approval spenders, manipulators, fee receiver, and receiver are validated. +- Swap output mode and `returnDataWordOffset` are correct for the aggregator. +- Standalone swap recipient is correct for pre-fee/no-fee versus post-fee mode. +- Bridge calldata encodes the correct recipient, destination, min amount, refund address, and fees. +- Bridge amount splice offset is correct for the exact calldata shape. +- Native `msg.value` covers input amount plus all downstream native call values. +- Excess native value and bridge refunds do not end up on the router. +- Monitoring exists for router balances, direct allowances to router, and unexpected downstream roles. + +If any critical business assumption is false, do not rely on `BungeeOpenRouterV2Unchecked` as-is. Add access control, use a signed variant, or remove the downstream privilege/funds/allowance that makes the public call surface dangerous. diff --git a/OPENROUTER_CONTEXT.md b/OPENROUTER_CONTEXT.md new file mode 100644 index 0000000..94a4248 --- /dev/null +++ b/OPENROUTER_CONTEXT.md @@ -0,0 +1,108 @@ +# OpenRouter Contract Context + +Last researched: 2026-05-18. + +Main ship target: + +- `src/combined/BungeeOpenRouterV2Unchecked.sol` + +Use `src/combined/BungeeOpenRouterV2.sol` as the signed sibling/reference, but the backend branch researched here targets the unchecked ABI. + +## V2Unchecked Surface + +`BungeeOpenRouterV2Unchecked` removes backend signature verification, nonce, and deadline fields. Fund safety for ERC20 inputs depends on 0x AllowanceHolder transient approvals plus `_msgSender() == input.user` in `_pullFromUser`. + +External entrypoints: + +- `performExecution(bytes32 requestHash, MonolithicExecution exec, bytes swapCallData, bytes bridgeCallData)` + - Pulls via AllowanceHolder. + - Optional pre-fee, optional swap, optional post-fee. + - Bridges with optional single amount-word splice controlled by flags. + - Bit 0 fee flag is ignored here; fee placement comes from `preFee` and `postFee`. +- `swap(bytes32 requestHash, InputData input, address receiver, uint256 flags, FeeData fee, SwapData swapData, bytes swapCallData)` + - Same-chain DEX path. + - Pre-fee/no-fee swaps can send output directly to `receiver`. + - Post-fee swaps send output to the router, then the router skims fee and forwards net. +- `swapAndBridge(bytes32 requestHash, InputData input, uint256 flags, FeeData fee, SwapData swapData, bytes swapCallData, BridgeData bridgeData, bytes bridgeCallData)` + - Swap output always lands on the router so it can be bridged. + - Supports runtime bridge amount splice and native bridge-value mode via flags. +- `bridge(bytes32 requestHash, InputData input, FeeData fee, BridgeData bridgeData, bytes bridgeCallData)` + - Direct bridge, no swap. + - No runtime splice; bridge amount must already be encoded in `bridgeCallData`. +- `performModularExecution(bytes32 requestHash, Action[] actions)` + - Generic action loop with packed action metadata and packed splices. + +## Flags + +Flag constants in `BungeeOpenRouterV2Unchecked.sol`: + +- `0x01` - post-swap fee for `swap` and `swapAndBridge`; clear means pre-fee from input. Ignored by `performExecution`. +- `0x02` - measure swap output by `balanceOf` delta; clear means decode return word at `SwapData.returnDataWordOffset`. +- `0x04` - bridge `msg.value = finalAmount + BridgeData.value`; used for native bridge assets. +- `0x08` - splice `finalAmount` into `bridgeCallData`. +- Bits `16..31` - byte offset for the bridge amount splice when `0x08` is set. + +Backend constants live in both: + +- `bungee-backend/src/modules/dex/dex.config.ts` +- `bungee-backend/src/modules/router/router.config.ts` + +Keep those masks and deployed addresses in sync with this contract. + +## Modular Packing + +`Action.actionInfo` is packed as: + +```text +uint8(callType) | (storeResult ? 1 << 8 : 0) | (uint160(target) << 16) +``` + +`Action.splices[]` entries are packed as: + +```text +sourceActionIndex | (srcOffset << 64) | (dstOffset << 128) | (length << 192) +``` + +`CallType.CALL_WITH_NATIVE` treats the first 32 bytes of `action.data` as the call value and the remaining bytes as calldata. PoCs use this for native fee transfers and Stargate native sends. + +## Current PoCs + +- `test/poc/OpenOceanAcrossOpenRouterPoC.t.sol` + - Modular OpenOcean USDC -> WETH swap. + - `AcrossERC20AmountManipulator` derives the Across output amount. + - Splices swap output and derived output into `SpokePool.deposit`. +- `test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol` + - Modular OpenOcean USDC -> native ETH. + - `MathManipulator` derives fee, post-fee amount, and bridge amount. + - Uses `CALL_WITH_NATIVE` and splices Stargate `amountLD`. +- `test/poc/OneInchCctpOpenRouterPoC.t.sol` + - CCTP-oriented PoC. + +Fork tests need RPC env vars and sometimes block pins. Example: + +```bash +ARBITRUM_RPC=... ARBITRUM_FORK_BLOCK=461716058 forge test --match-path test/poc/OpenOceanAcrossOpenRouterPoC.t.sol -vv +``` + +## Backend ABI Expectations + +The backend encodes the unchecked ABI in: + +- `bungee-backend/src/modules/dex/utils.ts` + - `swap(...)` + - `AllowanceHolder.exec(...)` +- `bungee-backend/src/modules/router/utils/directQuotesOpenRouter.ts` + - `bridge(...)` + - `swapAndBridge(...)` + - `AllowanceHolder.exec(...)` + +If the Solidity ABI changes, update those hard-coded ABI strings first. Direct DEX and direct bridge quote builders depend on them. + +## Gotchas + +- ERC20 inputs must be submitted through 0x AllowanceHolder, not directly to the router, or `_msgSender() == user` fails. +- Native input paths send ETH with the outer `AllowanceHolder.exec` call; no ERC20 pull happens. +- `bridge()` cannot splice runtime amounts. Use `swapAndBridge()` when bridge calldata needs the live swap output. +- `swapAndBridge()` uses balance-delta output measurement in backend builders today. +- `performExecution` and `swapAndBridge` share helpers but have different fee semantics. +- Production use of `BungeeOpenRouterV2Unchecked` needs an operational access-control decision; the contract itself has no signature or nonce checks. From 8c3f4f89c63a8e460af05b46cedb90b77ebbca0e Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 20:07:31 +0530 Subject: [PATCH 31/42] fix: tests --- hardhat.config.ts | 2 +- package.json | 1 + scripts/deploy/deployBungeeOpenRouter.ts | 52 + scripts/deploy/deployBungeeOpenRouterV2.ts | 83 -- scripts/e2e/approveViaModular.ts | 21 +- .../e2e/arbitrum/performExecution.postFee.ts | 72 +- .../e2e/arbitrum/performExecution.preFee.ts | 50 +- .../performModularExecution.postFee.ts | 8 +- .../performModularExecution.preFee.ts | 12 +- scripts/e2e/bridgeViaRelay.ts | 492 -------- scripts/e2e/bridgeViaRelaySimple.ts | 234 ---- scripts/e2e/cctp/bridge.preFee.ts | 2 +- scripts/e2e/cctp/performExecution.postFee.ts | 46 +- scripts/e2e/cctp/performExecution.preFee.ts | 59 +- .../cctp/performModularExecution.postFee.ts | 8 +- .../cctp/performModularExecution.preFee.ts | 8 +- .../cctp/swapAndBridge.postFee.balanceOf.ts | 8 +- ...pAndBridge.postFee.returndata.kyberswap.ts | 8 +- .../cctp/swapAndBridge.postFee.returndata.ts | 8 +- .../cctp/swapAndBridge.preFee.balanceOf.ts | 8 +- .../cctp/swapAndBridge.preFee.returndata.ts | 8 +- scripts/e2e/config.ts | 2 +- .../{ => misc}/routerUsdc.withdraw.modular.ts | 24 +- scripts/e2e/oft/bridge.preFee.ts | 2 +- scripts/e2e/oft/performExecution.postFee.ts | 221 ++-- scripts/e2e/oft/performExecution.preFee.ts | 127 +- .../oft/performModularExecution.postFee.ts | 8 +- .../e2e/oft/performModularExecution.preFee.ts | 8 +- .../oft/swapAndBridge.postFee.balanceOf.ts | 8 +- .../oft/swapAndBridge.postFee.returndata.ts | 8 +- .../e2e/oft/swapAndBridge.preFee.balanceOf.ts | 8 +- .../oft/swapAndBridge.preFee.returndata.ts | 8 +- scripts/e2e/relay/aave.bridge.preFee.ts | 2 +- .../e2e/relay/aave.performExecution.preFee.ts | 34 +- .../aave.performModularExecution.preFee.ts | 10 +- scripts/e2e/relay/usdc.bridge.preFee.ts | 2 +- .../e2e/relay/usdc.performExecution.preFee.ts | 34 +- .../usdc.performModularExecution.preFee.ts | 10 +- ...arbUsdcBaseEth.performExecution.postFee.ts | 39 +- ...BaseEth.performModularExecution.postFee.ts | 8 +- ...baseUsdcArbEth.performExecution.postFee.ts | 224 ++-- ...cArbEth.performModularExecution.postFee.ts | 8 +- ...gonPolUsdt0Arb.performExecution.postFee.ts | 37 +- ...sdt0Arb.performModularExecution.postFee.ts | 8 +- .../stargate/polygonUsdcBase.bridge.preFee.ts | 2 +- ...olygonUsdcBase.performExecution.postFee.ts | 135 +- ...sdcBase.performModularExecution.postFee.ts | 8 +- .../swapAndBridge.postFee.balanceOf.ts | 7 +- .../swapAndBridge.postFee.returndata.ts | 7 +- .../swapAndBridge.preFee.balanceOf.ts | 7 +- .../swapAndBridge.preFee.returndata.ts | 7 +- .../e2e/swap/kyberswap.postFee.balanceOf.ts | 10 +- .../e2e/swap/kyberswap.postFee.returndata.ts | 10 +- .../e2e/swap/kyberswap.preFee.balanceOf.ts | 10 +- .../e2e/swap/kyberswap.preFee.returndata.ts | 10 +- scripts/e2e/swap/swap.postFee.balanceOf.ts | 10 +- scripts/e2e/swap/swap.postFee.returndata.ts | 10 +- scripts/e2e/swap/swap.preFee.balanceOf.ts | 10 +- scripts/e2e/swap/swap.preFee.returndata.ts | 10 +- scripts/e2e/swap/zerox.postFee.balanceOf.ts | 10 +- scripts/e2e/swap/zerox.postFee.returndata.ts | 10 +- scripts/e2e/swap/zerox.preFee.balanceOf.ts | 10 +- scripts/e2e/swap/zerox.preFee.returndata.ts | 10 +- scripts/e2e/swapBridgeViaArbitrumNative.ts | 435 ------- scripts/e2e/swapBridgeViaCctp.ts | 556 --------- scripts/e2e/swapBridgeViaCctpSimple.ts | 190 --- scripts/e2e/swapBridgeViaOft.ts | 809 ------------ scripts/e2e/swapBridgeViaStargateNative.ts | 1100 ----------------- scripts/e2e/utils/allowanceHolder.ts | 4 +- scripts/e2e/utils/contractTypes.ts | 100 +- .../e2e/utils/modularActionsBuilder/README.md | 4 +- .../utils/modularActionsBuilder/index.d.ts | 6 +- .../e2e/utils/modularActionsBuilder/index.js | 15 +- scripts/e2e/utils/reproducibility.ts | 60 +- scripts/e2e/utils/routerAbi.ts | 41 +- test/poc/OneInchCctpOpenRouterPoC.t.sol | 16 +- test/poc/OpenOceanAcrossOpenRouterPoC.t.sol | 1 + 77 files changed, 959 insertions(+), 4701 deletions(-) create mode 100644 scripts/deploy/deployBungeeOpenRouter.ts delete mode 100644 scripts/deploy/deployBungeeOpenRouterV2.ts delete mode 100644 scripts/e2e/bridgeViaRelay.ts delete mode 100644 scripts/e2e/bridgeViaRelaySimple.ts rename scripts/e2e/{ => misc}/routerUsdc.withdraw.modular.ts (77%) delete mode 100644 scripts/e2e/swapBridgeViaArbitrumNative.ts delete mode 100644 scripts/e2e/swapBridgeViaCctp.ts delete mode 100644 scripts/e2e/swapBridgeViaCctpSimple.ts delete mode 100644 scripts/e2e/swapBridgeViaOft.ts delete mode 100644 scripts/e2e/swapBridgeViaStargateNative.ts diff --git a/hardhat.config.ts b/hardhat.config.ts index 1fbbb90..60d2ffb 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -11,7 +11,7 @@ const accounts = deployerKey ? [deployerKey] : []; const config: HardhatUserConfig = { solidity: { - version: '0.8.25', + version: '0.8.34', settings: { optimizer: { enabled: true, diff --git a/package.json b/package.json index 4756cca..e3000a6 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "compile": "hardhat compile", + "deploy": "hardhat run scripts/deploy/deployBungeeOpenRouter.ts --network", "deploy:v2": "hardhat run scripts/deploy/deployBungeeOpenRouterV2.ts --network", "typechain": "hardhat typechain", "slither": "bash scripts/docker-slither.sh" diff --git a/scripts/deploy/deployBungeeOpenRouter.ts b/scripts/deploy/deployBungeeOpenRouter.ts new file mode 100644 index 0000000..d5db918 --- /dev/null +++ b/scripts/deploy/deployBungeeOpenRouter.ts @@ -0,0 +1,52 @@ +/** + * Deployment script for BungeeOpenRouter. + * + * Usage: + * npx hardhat run scripts/deploy/deployBungeeOpenRouter.ts --network + * + * Required env vars: + * DEPLOYER_PRIVATE_KEY — deployer wallet private key + + */ + +import hre from 'hardhat'; +import { ethers } from 'hardhat'; + +async function main() { + const [deployer] = await ethers.getSigners(); + const networkName = hre.network.name; + + const owner = deployer.address; + + console.log('Deployer: ', deployer.address); + console.log('Owner: ', owner); + console.log('Network: ', networkName); + console.log(''); + + console.log('Deploying BungeeOpenRouter...'); + const factory = await ethers.getContractFactory('BungeeOpenRouter'); + const router = await factory.deploy(owner); + await router.waitForDeployment(); + const routerAddress = await router.getAddress(); + console.log('BungeeOpenRouter deployed to:', routerAddress); + + console.log('\n=== Deployment Summary ==='); + console.log(`BungeeOpenRouter: ${routerAddress}`); + + const chainId = (await ethers.provider.getNetwork()).chainId; + if (chainId !== 31337n) { + // sleep for 5secs before verification attempt + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // run verification + await hre.run('verify:verify', { + address: routerAddress, + constructorArguments: [owner], + }); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/deploy/deployBungeeOpenRouterV2.ts b/scripts/deploy/deployBungeeOpenRouterV2.ts deleted file mode 100644 index 3d69756..0000000 --- a/scripts/deploy/deployBungeeOpenRouterV2.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Deployment script for BungeeOpenRouterV2 and BungeeOpenRouterV2Unchecked. - * - * Usage: - * npx hardhat run scripts/deploy/deployBungeeOpenRouterV2.ts --network - * - * Required env vars: - * DEPLOYER_PRIVATE_KEY — deployer wallet private key - * OWNER_ADDRESS — owner of both contracts (defaults to deployer) - * OPEN_ROUTER_SIGNER_ADDRESS — backend signer for BungeeOpenRouterV2 - * - * Optional: set --network to any network configured in hardhat.config.ts. - * Omitting --network runs against the in-process Hardhat network. - */ - -import hre from 'hardhat'; -import { ethers } from 'hardhat'; - -async function main() { - const [deployer] = await ethers.getSigners(); - const networkName = hre.network.name; - - const owner = deployer.address; - const openRouterSigner = deployer.address; - - if (!openRouterSigner) { - throw new Error('OPEN_ROUTER_SIGNER_ADDRESS is not set in environment'); - } - - console.log('Deployer: ', deployer.address); - console.log('Owner: ', owner); - console.log('OpenRouterSigner: ', openRouterSigner); - console.log('Network: ', networkName); - console.log(''); - - // ------------------------------------------------------------------------- - // BungeeOpenRouterV2 (monolithic + modular, signature-verified, AH pull) - // ------------------------------------------------------------------------- - // console.log("Deploying BungeeOpenRouterV2..."); - // const V2Factory = await ethers.getContractFactory("BungeeOpenRouterV2"); - // const v2 = await V2Factory.deploy(owner, openRouterSigner); - // await v2.waitForDeployment(); - // const v2Address = await v2.getAddress(); - // console.log("BungeeOpenRouterV2 deployed to:", v2Address); - - // ------------------------------------------------------------------------- - // BungeeOpenRouterV2Unchecked (same logic, no signature verification) - // ------------------------------------------------------------------------- - console.log('Deploying BungeeOpenRouterV2Unchecked...'); - const V2UFactory = await ethers.getContractFactory( - 'BungeeOpenRouterV2Unchecked', - ); - const v2u = await V2UFactory.deploy(owner); - await v2u.waitForDeployment(); - const v2uAddress = await v2u.getAddress(); - console.log('BungeeOpenRouterV2Unchecked deployed to:', v2uAddress); - - // ------------------------------------------------------------------------- - // Summary - // ------------------------------------------------------------------------- - console.log('\n=== Deployment Summary ==='); - // console.log(`BungeeOpenRouterV2: ${v2Address}`); - console.log(`BungeeOpenRouterV2Unchecked: ${v2uAddress}`); - - // ------------------------------------------------------------------------- - // Verification hint - // ------------------------------------------------------------------------- - const chainId = (await ethers.provider.getNetwork()).chainId; - if (chainId !== 31337n) { - console.log('\nTo verify on a block explorer:'); - // console.log( - // ` npx hardhat verify --network ${networkName} ${v2Address} "${owner}" "${openRouterSigner}"` - // ); - console.log( - ` npx hardhat verify --network ${networkName} ${v2uAddress} "${owner}"`, - ); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/e2e/approveViaModular.ts b/scripts/e2e/approveViaModular.ts index b110e7f..8a62bdb 100644 --- a/scripts/e2e/approveViaModular.ts +++ b/scripts/e2e/approveViaModular.ts @@ -1,10 +1,11 @@ /** * Script — Call ERC-20 approve(spender, amount) through the router using - * `performModularExecution(Action[])`. + * `performActions(Action[])`. * - * This routes a single CALL action targeting the token contract so the router - * itself issues the approval — useful when the router holds tokens and needs - * to authorise a downstream spender (e.g. a bridge contract) before calling it. + * DISABLED by default: `BungeeOpenRouter` now sets max allowance inside + * `swap`, `bridge`, and `swapAndBridge`. Use those entrypoints instead of a + * standalone approval tx. Set `E2E_ENABLE_MODULAR_PRE_APPROVE=1` only if you + * need this legacy helper for manual modular debugging. * * Usage: * TOKEN=0x... SPENDER=0x... AMOUNT=1000000 PRIVATE_KEY=0x... \ @@ -43,6 +44,14 @@ function packActionInfo( // ─── build + send ───────────────────────────────────────────────────────────── async function run(): Promise { + if (process.env.E2E_ENABLE_MODULAR_PRE_APPROVE !== '1') { + console.log( + 'approveViaModular is disabled (router pre-approves in swap/bridge/swapAndBridge).', + ); + console.log('Set E2E_ENABLE_MODULAR_PRE_APPROVE=1 to run this script.'); + return; + } + const privateKey = process.env.PRIVATE_KEY; if (!privateKey) throw new Error('PRIVATE_KEY env var required'); @@ -96,7 +105,7 @@ async function run(): Promise { }, ]; - const calldata = routerIface.encodeFunctionData('performModularExecution', [ + const calldata = routerIface.encodeFunctionData('performActions', [ ZERO_BYTES32, actions, ]); @@ -111,7 +120,7 @@ async function run(): Promise { amount === ethers.MaxUint256 ? 'MaxUint256' : amount.toString() }`, ); - console.log('Sending performModularExecution → token.approve...'); + console.log('Sending performActions → token.approve...'); const tx = await signer.sendTransaction({ to: routerAddress, diff --git a/scripts/e2e/arbitrum/performExecution.postFee.ts b/scripts/e2e/arbitrum/performExecution.postFee.ts index c05764a..4c009f1 100644 --- a/scripts/e2e/arbitrum/performExecution.postFee.ts +++ b/scripts/e2e/arbitrum/performExecution.postFee.ts @@ -1,10 +1,9 @@ /** * Route: Ethereum AAVE → ETH (OpenOcean) → Arbitrum ETH (inbox depositEth) - * Function: performExecution (monolithic) + * Function: swapAndBridge * Fee: postFee — FEE_BPS of estimatedOut ETH deducted after swap * - * BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to inbox.depositEth(). - * Input is AAVE (ERC-20) so AllowanceHolder.exec is required. + * BRIDGE_VALUE_FLAG: router forwards swap output as msg.value to inbox.depositEth(). * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performExecution.postFee.ts @@ -30,16 +29,16 @@ import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowance import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { - MonolithicExecutionCall, BRIDGE_VALUE_FLAG, - NO_FEE, + POST_FEE_FLAG, ZERO_ADDRESS, ZERO_BYTES32, - monolithicArgs, + swapAndBridgeArgs, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; +const FLAGS = POST_FEE_FLAG | BRIDGE_VALUE_FLAG; const ROUTER_ETH = routerAddressForChain(CHAIN_IDS.ETHEREUM); interface OoQuoteResponse { @@ -79,24 +78,6 @@ function buildDepositEthCalldata(): string { ]).encodeFunctionData('depositEth', []); } -async function estimateArbitrumBridgeFee(provider: ethers.Provider): Promise { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { ParentToChildMessageGasEstimator } = require('@arbitrum/sdk'); - const estimator = new ParentToChildMessageGasEstimator(provider); - const l2GasPrice = (await new ethers.JsonRpcProvider(RPC.ARBITRUM).getFeeData()).gasPrice ?? 0n; - const submissionFee = await estimator.estimateSubmissionFee(provider, 0n, 0n); - const executionCost = 250000n * (l2GasPrice + (l2GasPrice * 20n) / 100n); - const totalFee = BigInt(submissionFee.toString()) + executionCost; - console.log(` Estimated Arbitrum bridge fee: ${ethers.formatEther(totalFee)} ETH`); - return totalFee; - } catch (err) { - const fallback = ethers.parseEther('0.001'); - console.warn(` Arb fee estimation failed (${(err as Error).message}), using fallback: ${ethers.formatEther(fallback)} ETH`); - return fallback; - } -} - async function main() { const privateKey = process.env.PRIVATE_KEY; if (!privateKey) throw new Error('PRIVATE_KEY env var required'); @@ -125,20 +106,18 @@ async function main() { console.log(` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); - const arbFee = await estimateArbitrumBridgeFee(provider); - if (estimatedOut < feeAmount + arbFee) { - console.warn(` Warning: estimated ETH may be insufficient to cover fee + bridge cost`); - } - await ensureRouterErc20Balance(signer, TOKENS.AAVE_ETH, ROUTER_ETH); await ensureRouterNativeBalance(signer, ROUTER_ETH); await ensureRouterApproval(signer, ROUTER_ETH, TOKENS.AAVE_ETH, ooRouter); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: TOKENS.AAVE_ETH, inputAmount }, - preFee: NO_FEE, - swap: { + const callData = routerIface.encodeFunctionData( + 'swapAndBridge', + swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { user: signerAddress, inputToken: TOKENS.AAVE_ETH, inputAmount }, + { receiver: signerAddress, amount: feeAmount }, + { target: ooRouter, approvalSpender: ooRouter, outputToken: NATIVE_TOKEN_ADDRESS, @@ -146,26 +125,21 @@ async function main() { minOutput: minAmountOut, returnDataWordOffset: 0n, }, - postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { target: ARBITRUM_INBOX, approvalSpender: ZERO_ADDRESS, value: 0n }, - flags: BRIDGE_VALUE_FLAG, - }, - swapCallData: swapData, - bridgeCallData: buildDepositEthCalldata(), - }; - - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + swapData, + { target: ARBITRUM_INBOX, approvalSpender: ZERO_ADDRESS, value: 0n }, + buildDepositEthCalldata(), + ), + ); await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_ETH, inputAmount); const receipt = await execViaAH(signer, ROUTER_ETH, TOKENS.AAVE_ETH, inputAmount, ROUTER_ETH, callData, 0n); - logTxnSummary( - 'Ethereum AAVE → Arbitrum ETH (depositEth) — performExecution postFee', - CHAIN_IDS.ETHEREUM, - receipt, - ); + logTxnSummary('Ethereum AAVE → Arbitrum ETH (depositEth) — swapAndBridge postFee', CHAIN_IDS.ETHEREUM, receipt); console.log('\nETH arrives on Arbitrum once the retryable ticket is processed.'); } -main().catch((err) => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/arbitrum/performExecution.preFee.ts b/scripts/e2e/arbitrum/performExecution.preFee.ts index 63ff1f4..bbcd01a 100644 --- a/scripts/e2e/arbitrum/performExecution.preFee.ts +++ b/scripts/e2e/arbitrum/performExecution.preFee.ts @@ -1,11 +1,9 @@ /** * Route: Ethereum ETH → Arbitrum ETH (Arbitrum inbox depositEth, no swap) - * Function: performExecution (monolithic) + * Function: bridge * Fee: preFee — FEE_BPS of inputAmount ETH deducted before bridge * - * BRIDGE_VALUE_FLAG set: router forwards the remaining ETH after preFee as - * msg.value to inbox.depositEth(). Input is native ETH so we call execDirect - * (no AllowanceHolder needed — router checks msg.value >= inputAmount directly). + * Input is native ETH — call router.bridge directly (msg.value = inputAmount). * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performExecution.preFee.ts @@ -25,21 +23,12 @@ import { } from '../config'; import { execDirect } from '../utils/allowanceHolder'; import { ROUTER_ABI } from '../utils/routerAbi'; -import { - MonolithicExecutionCall, - BRIDGE_VALUE_FLAG, - NO_FEE, - NO_SWAP, - ZERO_ADDRESS, - ZERO_BYTES32, - monolithicArgs, -} from '../utils/contractTypes'; +import { ZERO_ADDRESS, ZERO_BYTES32, bridgeArgs, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterNativeBalance } from '../utils/reproducibility'; const ROUTER_ETH = routerAddressForChain(CHAIN_IDS.ETHEREUM); -/** Gas reserve kept in the signer's wallet to cover the transaction itself. */ const GAS_RESERVE = ethers.parseEther('0.005'); function buildDepositEthCalldata(): string { @@ -61,7 +50,9 @@ async function estimateArbitrumBridgeFee(provider: ethers.Provider): Promise { const arbFee = await estimateArbitrumBridgeFee(provider); if (bridgeValue < arbFee) { - console.warn(` Warning: bridgeValue (${ethers.formatEther(bridgeValue)}) may be below Arbitrum bridge cost (${ethers.formatEther(arbFee)})`); + console.warn( + ` Warning: bridgeValue (${ethers.formatEther(bridgeValue)}) may be below Arbitrum bridge cost (${ethers.formatEther(arbFee)})`, + ); } await ensureRouterNativeBalance(signer, ROUTER_ETH); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: NATIVE_TOKEN_ADDRESS, inputAmount }, - preFee: { receiver: signerAddress, amount: feeAmount }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { target: ARBITRUM_INBOX, approvalSpender: ZERO_ADDRESS, value: 0n }, - flags: BRIDGE_VALUE_FLAG, - }, - swapCallData: '0x', - bridgeCallData: buildDepositEthCalldata(), - }; + const input: InputData = { user: signerAddress, inputToken: NATIVE_TOKEN_ADDRESS, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { target: ARBITRUM_INBOX, approvalSpender: ZERO_ADDRESS, value: bridgeValue }; const routerIface = new ethers.Interface(ROUTER_ABI); - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + const callData = routerIface.encodeFunctionData( + 'bridge', + bridgeArgs(ZERO_BYTES32, input, fee, bridgeData, buildDepositEthCalldata()), + ); - // Native ETH input — send directly to the router; no AllowanceHolder needed. - console.log('Sending direct router tx → router.performExecution...'); + console.log('Sending direct router tx → router.bridge...'); const receipt = await execDirect(signer, ROUTER_ETH, callData, inputAmount); - logTxnSummary('Ethereum ETH → Arbitrum ETH (depositEth direct) — performExecution preFee', CHAIN_IDS.ETHEREUM, receipt); + logTxnSummary('Ethereum ETH → Arbitrum ETH (depositEth direct) — bridge preFee', CHAIN_IDS.ETHEREUM, receipt); console.log('\nETH arrives on Arbitrum once the retryable ticket is processed.'); diff --git a/scripts/e2e/arbitrum/performModularExecution.postFee.ts b/scripts/e2e/arbitrum/performModularExecution.postFee.ts index f3c01c6..78a0dcf 100644 --- a/scripts/e2e/arbitrum/performModularExecution.postFee.ts +++ b/scripts/e2e/arbitrum/performModularExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Ethereum AAVE → ETH (OpenOcean) → Arbitrum ETH (inbox depositEth) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: postFee — FEE_BPS of estimatedOut ETH sent to signer after swap * * Modular action sequence: @@ -13,7 +13,7 @@ * Input is AAVE (ERC-20) so AllowanceHolder.exec is required. * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performModularExecution.postFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performActions.postFee.ts */ import axios from 'axios'; import { ethers } from 'ethers'; @@ -145,13 +145,13 @@ async function main() { exec.nativeCall(signerAddress, '0x', feeAmount); exec.nativeCall(ARBITRUM_INBOX, buildDepositEthCalldata(), bridgeValue); - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_ETH, inputAmount); const receipt = await execViaAH(signer, ROUTER_ETH, TOKENS.AAVE_ETH, inputAmount, ROUTER_ETH, callData, 0n); logTxnSummary( - 'Ethereum AAVE → Arbitrum ETH (depositEth) — performModularExecution postFee', + 'Ethereum AAVE → Arbitrum ETH (depositEth) — performActions postFee', CHAIN_IDS.ETHEREUM, receipt, ); diff --git a/scripts/e2e/arbitrum/performModularExecution.preFee.ts b/scripts/e2e/arbitrum/performModularExecution.preFee.ts index 4048057..ab9c1d9 100644 --- a/scripts/e2e/arbitrum/performModularExecution.preFee.ts +++ b/scripts/e2e/arbitrum/performModularExecution.preFee.ts @@ -1,6 +1,6 @@ /** * Route: Ethereum ETH → Arbitrum ETH (Arbitrum inbox depositEth, no swap) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: preFee — FEE_BPS of inputAmount ETH deducted before bridge * * Modular action sequence: @@ -8,10 +8,10 @@ * [1] nativeCall(inbox, depositEth(), bridgeValue) — bridge remaining ETH * * Input is native ETH so we call execDirect (no AllowanceHolder needed — - * performModularExecution has no _pullFromUser; ETH arrives via msg.value). + * performActions has no _pullFromUser; ETH arrives via msg.value). * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performModularExecution.preFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performActions.preFee.ts */ import { ethers } from 'ethers'; import * as dotenv from 'dotenv'; @@ -99,13 +99,13 @@ async function main(): Promise { exec.nativeCall(ARBITRUM_INBOX, buildDepositEthCalldata(), bridgeValue); const routerIface = new ethers.Interface(ROUTER_ABI); - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); // Native ETH input — send directly to the router; no AllowanceHolder needed. - console.log('Sending direct router tx → router.performModularExecution...'); + console.log('Sending direct router tx → router.performActions...'); const receipt = await execDirect(signer, ROUTER_ETH, callData, inputAmount); - logTxnSummary('Ethereum ETH → Arbitrum ETH (depositEth direct) — performModularExecution preFee', CHAIN_IDS.ETHEREUM, receipt); + logTxnSummary('Ethereum ETH → Arbitrum ETH (depositEth direct) — performActions preFee', CHAIN_IDS.ETHEREUM, receipt); console.log('\nETH arrives on Arbitrum once the retryable ticket is processed.'); diff --git a/scripts/e2e/bridgeViaRelay.ts b/scripts/e2e/bridgeViaRelay.ts deleted file mode 100644 index 60b7c08..0000000 --- a/scripts/e2e/bridgeViaRelay.ts +++ /dev/null @@ -1,492 +0,0 @@ -/** - * Script 1 — Bridge AAVE (Polygon PoS) → AAVE (Base) via Relay.link - * - * Flow: - * 1. Spend half of the signer’s Polygon AAVE balance twice: first via - * performExecution (monolithic), then via performModularExecution, each leg - * using balance/2 of the initial snapshot. - * 2. For each leg: Relay.link /quote/v2 for AAVE→AAVE cross-chain swap for the - * net relay amount (slice − feeAmount). - * 3. Parse approve + deposit steps → build mono or modular payload. - * 4. AllowanceHolder.exec → router.performExecution / performModularExecution. - * - * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/bridgeViaRelay.ts - * Polygon USDC (Circle) → Base USDC: - * PRIVATE_KEY=0x... ts-node scripts/e2e/bridgeViaRelay.ts usdc-polygon-base - * - * Router addresses: {@link ROUTER_BY_CHAIN_ID} / `routerAddressForChain` in config.ts. - * Override Polygon with `ROUTER_CHAIN_137` or legacy `ROUTER_ADDRESS` if needed. - */ -import { ethers } from 'ethers'; -import * as dotenv from 'dotenv'; -dotenv.config(); - -import { - CHAIN_IDS, - routerAddressForChain, - TOKENS, - FEE_BPS, - bpsOf, - RPC, - ALLOWANCE_HOLDER, -} from './config'; -import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; -import { - encodeApprove, - encodeTransfer, - getWalletErc20Balance, -} from './utils/erc20'; -import { ROUTER_ABI } from './utils/routerAbi'; -import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; -import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { MonolithicExecutionCall, NO_FEE, NO_SWAP, ZERO_BYTES32, monolithicArgs } from './utils/contractTypes'; -import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; -import { - ensureRouterErc20Balance, - ensureRouterApproval, -} from './utils/reproducibility'; - -/** Router on Polygon — quotes + modular recipient target must match executing chain deployment. */ -const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); - -function buildMonolithicExecution( - signerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - relaySpender: string, - depositTarget: string, - depositData: string, -): MonolithicExecutionCall { - return { - exec: { - input: { - user: signerAddress, - inputToken: TOKENS.AAVE_POLYGON, - inputAmount, - }, - preFee: { - receiver: signerAddress, - amount: feeAmount, - }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { - target: depositTarget, - approvalSpender: relaySpender, - value: 0n, - }, - flags: 0n, - }, - swapCallData: '0x', - bridgeCallData: depositData, - }; -} - -function buildModularActions( - signerAddress: string, - routerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - bridgeAmount: bigint, - relaySpender: string, - depositTarget: string, - depositData: string, -): ModularAction[] { - const ahIface = new ethers.Interface([ - 'function transferFrom(address token, address owner, address recipient, uint256 amount)', - ]); - const ahTransferFromData = ahIface.encodeFunctionData('transferFrom', [ - TOKENS.AAVE_POLYGON, - signerAddress, - routerAddress, - inputAmount, - ]); - - const exec = new ModularActionsBuilder(); - exec.call(ALLOWANCE_HOLDER, ahTransferFromData); - exec.call(TOKENS.AAVE_POLYGON, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.AAVE_POLYGON, encodeApprove(relaySpender, bridgeAmount)); - exec.call(depositTarget, depositData); - return exec.toActions(); -} - -async function executeLeg(args: { - label: string; - useModular: boolean; - signer: ethers.Wallet; - signerAddress: string; - inputAmount: bigint; - routerIface: ethers.Interface; -}): Promise { - const { label, useModular, signer, signerAddress, inputAmount, routerIface } = - args; - - const feeAmount = bpsOf(inputAmount, FEE_BPS); - const bridgeAmount = inputAmount - feeAmount; - - console.log(`\n── ${label} (${useModular ? 'MODULAR' : 'MONOLITHIC'}) ──`); - console.log(`Input slice: ${ethers.formatUnits(inputAmount, 18)} AAVE`); - console.log( - `Fee (pre-bridge): ${ethers.formatUnits(feeAmount, 18)} (${FEE_BPS} bps)`, - ); - console.log(`Relay amount: ${ethers.formatUnits(bridgeAmount, 18)}`); - - console.log('Fetching Relay.link quote...'); - const quote = await fetchRelayQuoteV2({ - routerAddress: ROUTER_POLYGON, - recipient: signerAddress, - originChainId: CHAIN_IDS.POLYGON, - destinationChainId: CHAIN_IDS.BASE, - originCurrency: TOKENS.AAVE_POLYGON, - destinationCurrency: TOKENS.AAVE_BASE, - amount: bridgeAmount, - }); - const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); - console.log(`Relay spender: ${relaySpender}`); - console.log(`Deposit target: ${depositTarget}`); - - await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, relaySpender); - - let execCalldata: string; - if (useModular) { - const actions = buildModularActions( - signerAddress, - ROUTER_POLYGON, - inputAmount, - feeAmount, - bridgeAmount, - relaySpender, - depositTarget, - depositData, - ); - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - ZERO_BYTES32, - actions, - ]); - } else { - const execPayload = buildMonolithicExecution( - signerAddress, - inputAmount, - feeAmount, - relaySpender, - depositTarget, - depositData, - ); - execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(execPayload)); - } - - await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); - - console.log('Sending AllowanceHolder.exec...'); - const receipt = await execViaAH( - signer, - ROUTER_POLYGON, - TOKENS.AAVE_POLYGON, - inputAmount, - ROUTER_POLYGON, - execCalldata, - ); - - const modeLabel = useModular ? 'Modular' : 'Monolithic'; - logTxnSummary( - `Polygon AAVE → Base AAVE — Relay — ${modeLabel}`, - CHAIN_IDS.POLYGON, - receipt, - ); -} - -function buildMonolithicExecutionUsdcPolygonToBase( - signerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - relaySpender: string, - depositTarget: string, - depositData: string, -): MonolithicExecutionCall { - return { - exec: { - input: { - user: signerAddress, - inputToken: TOKENS.USDC_POLYGON_CIRCLE, - inputAmount, - }, - preFee: { - receiver: signerAddress, - amount: feeAmount, - }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { - target: depositTarget, - approvalSpender: relaySpender, - value: 0n, - }, - flags: 0n, - }, - swapCallData: '0x', - bridgeCallData: depositData, - }; -} - -function buildModularActionsUsdcPolygonToBase( - signerAddress: string, - routerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - bridgeAmount: bigint, - relaySpender: string, - depositTarget: string, - depositData: string, -): ModularAction[] { - const ahIface = new ethers.Interface([ - 'function transferFrom(address token, address owner, address recipient, uint256 amount)', - ]); - const ahTransferFromData = ahIface.encodeFunctionData('transferFrom', [ - TOKENS.USDC_POLYGON_CIRCLE, - signerAddress, - routerAddress, - inputAmount, - ]); - - const exec = new ModularActionsBuilder(); - exec.call(ALLOWANCE_HOLDER, ahTransferFromData); - exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeApprove(relaySpender, bridgeAmount)); - exec.call(depositTarget, depositData); - return exec.toActions(); -} - -async function executeLegUsdcPolygonToBase(args: { - label: string; - useModular: boolean; - signer: ethers.Wallet; - signerAddress: string; - inputAmount: bigint; - routerIface: ethers.Interface; -}): Promise { - const { label, useModular, signer, signerAddress, inputAmount, routerIface } = - args; - - const feeAmount = bpsOf(inputAmount, FEE_BPS); - const bridgeAmount = inputAmount - feeAmount; - - console.log(`\n── ${label} (${useModular ? 'MODULAR' : 'MONOLITHIC'}) ──`); - console.log(`Input slice: ${ethers.formatUnits(inputAmount, 6)} USDC`); - console.log( - `Fee (pre-bridge): ${ethers.formatUnits(feeAmount, 6)} (${FEE_BPS} bps)`, - ); - console.log(`Relay amount: ${ethers.formatUnits(bridgeAmount, 6)}`); - - console.log('Fetching Relay.link quote...'); - const quote = await fetchRelayQuoteV2({ - routerAddress: ROUTER_POLYGON, - recipient: signerAddress, - originChainId: CHAIN_IDS.POLYGON, - destinationChainId: CHAIN_IDS.BASE, - originCurrency: TOKENS.USDC_POLYGON_CIRCLE, - destinationCurrency: TOKENS.USDC_BASE, - amount: bridgeAmount, - }); - const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); - console.log(`Relay spender: ${relaySpender}`); - console.log(`Deposit target: ${depositTarget}`); - - await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, relaySpender); - - let execCalldata: string; - if (useModular) { - const actions = buildModularActionsUsdcPolygonToBase( - signerAddress, - ROUTER_POLYGON, - inputAmount, - feeAmount, - bridgeAmount, - relaySpender, - depositTarget, - depositData, - ); - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - ZERO_BYTES32, - actions, - ]); - } else { - const execPayload = buildMonolithicExecutionUsdcPolygonToBase( - signerAddress, - inputAmount, - feeAmount, - relaySpender, - depositTarget, - depositData, - ); - execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(execPayload)); - } - - await ensureAllowanceForAllowanceHolder( - signer, - TOKENS.USDC_POLYGON_CIRCLE, - inputAmount, - ); - - console.log('Sending AllowanceHolder.exec...'); - const receipt = await execViaAH( - signer, - ROUTER_POLYGON, - TOKENS.USDC_POLYGON_CIRCLE, - inputAmount, - ROUTER_POLYGON, - execCalldata, - ); - - const modeLabel = useModular ? 'Modular' : 'Monolithic'; - logTxnSummary( - `Polygon USDC → Base USDC — Relay — ${modeLabel}`, - CHAIN_IDS.POLYGON, - receipt, - ); -} - -async function mainUsdcPolygonToBaseRelay() { - const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) { - throw new Error('PRIVATE_KEY env var required'); - } - - const provider = new ethers.JsonRpcProvider(RPC.POLYGON); - const signer = new ethers.Wallet(privateKey, provider); - const signerAddress = await signer.getAddress(); - - const inputToken = TOKENS.USDC_POLYGON_CIRCLE; - const { balance: walletBalance } = await getWalletErc20Balance( - inputToken, - signerAddress, - provider, - ); - if (walletBalance === 0n) { - throw new Error( - `Signer ${signerAddress} has zero balance of ${inputToken}. Fund the wallet with Polygon native Circle USDC first.`, - ); - } - - const legAmount = (walletBalance - 20n) / 2n; - if (legAmount === 0n) { - throw new Error( - `Wallet balance (${walletBalance}) is too small to split into two nonzero halves.`, - ); - } - - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_POLYGON}`); - console.log(`Input token: ${inputToken}`); - console.log(`Full balance: ${ethers.formatUnits(walletBalance, 6)} USDC`); - console.log( - `Per execution: ${ethers.formatUnits(legAmount, 6)} USDC (50% snapshots)`, - ); - - const routerIface = new ethers.Interface(ROUTER_ABI); - - await executeLegUsdcPolygonToBase({ - label: '1/2 Monolithic', - useModular: false, - signer, - signerAddress, - inputAmount: legAmount, - routerIface, - }); - - console.log('Sleeping ~3s before modular execution...'); - await sleep(3000); - - await executeLegUsdcPolygonToBase({ - label: '2/2 Modular', - useModular: true, - signer, - signerAddress, - inputAmount: legAmount, - routerIface, - }); - - console.log( - '\nCompleted monolithic then modular executions (Relay Polygon USDC → Base USDC).', - ); -} - -async function main() { - const relayE2eCase = process.argv[2]?.toLowerCase(); - if (relayE2eCase === 'usdc-polygon-base' || relayE2eCase === 'usdc') { - await mainUsdcPolygonToBaseRelay(); - return; - } - - const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) { - throw new Error('PRIVATE_KEY env var required'); - } - - const provider = new ethers.JsonRpcProvider(RPC.POLYGON); - const signer = new ethers.Wallet(privateKey, provider); - const signerAddress = await signer.getAddress(); - - const inputToken = TOKENS.AAVE_POLYGON; - const { balance: walletBalance } = await getWalletErc20Balance( - inputToken, - signerAddress, - provider, - ); - if (walletBalance === 0n) { - throw new Error( - `Signer ${signerAddress} has zero balance of ${inputToken}. Fund the wallet with Polygon AAVE first.`, - ); - } - - const legAmount = (walletBalance - 20n) / 2n; - if (legAmount === 0n) { - throw new Error( - `Wallet balance (${walletBalance}) is too small to split into two nonzero halves.`, - ); - } - - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_POLYGON}`); - console.log(`Input token: ${inputToken}`); - console.log(`Full balance: ${ethers.formatUnits(walletBalance, 18)} AAVE`); - console.log( - `Per execution: ${ethers.formatUnits(legAmount, 18)} AAVE (50% snapshots)`, - ); - - const routerIface = new ethers.Interface(ROUTER_ABI); - - await executeLeg({ - label: '1/2 Monolithic', - useModular: false, - signer, - signerAddress, - inputAmount: legAmount, - routerIface, - }); - - console.log('Sleeping ~3s before modular execution...'); - await sleep(3000); - - await executeLeg({ - label: '2/2 Modular', - useModular: true, - signer, - signerAddress, - inputAmount: legAmount, - routerIface, - }); - - console.log( - '\nCompleted monolithic then modular executions (Relay Polygon → Base AAVE).', - ); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/e2e/bridgeViaRelaySimple.ts b/scripts/e2e/bridgeViaRelaySimple.ts deleted file mode 100644 index 4a7a78f..0000000 --- a/scripts/e2e/bridgeViaRelaySimple.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Script — Bridge AAVE (Polygon PoS) → AAVE (Base) via Relay.link - * using the `bridge(InputData, FeeData, BridgeData, bytes)` entrypoint. - * - * Flow: - * 1. Read signer's Polygon AAVE (or USDC) balance. - * 2. Compute fee via FEE_BPS; set fee.amount=0 to skip the fee entirely. - * 3. Fetch Relay.link /quote/v2 for the net bridge amount (inputAmount − fee). - * 4. AllowanceHolder.exec → router.bridge(input, fee, bridgeData, bridgeCallData). - * - * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/bridgeViaRelaySimple.ts - * - * USDC path (Polygon Circle USDC → Base USDC): - * PRIVATE_KEY=0x... ts-node scripts/e2e/bridgeViaRelaySimple.ts usdc - * - * No fee: - * FEE_AMOUNT_BPS=0 PRIVATE_KEY=0x... ts-node scripts/e2e/bridgeViaRelaySimple.ts - * - * Router addresses: {@link ROUTER_BY_CHAIN_ID} / `routerAddressForChain` in config.ts. - * Override with `ROUTER_CHAIN_137` or legacy `ROUTER_ADDRESS` if needed. - */ -import { ethers } from 'ethers'; -import * as dotenv from 'dotenv'; -dotenv.config(); - -import { - CHAIN_IDS, - routerAddressForChain, - TOKENS, - FEE_BPS, - bpsOf, - RPC, -} from './config'; -import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; -import { getWalletErc20Balance } from './utils/erc20'; -import { ROUTER_ABI } from './utils/routerAbi'; -import { ZERO_BYTES32 } from './utils/contractTypes'; -import type { BridgeData, FeeData, InputData } from './utils/contractTypes'; -import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; -import { logTxnSummary } from './utils/txnLogSummary'; -import { - ensureRouterErc20Balance, - ensureRouterApproval, -} from './utils/reproducibility'; - -const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); - -// ─── Execution builder ──────────────────────────────────────────────────────── - -interface BridgeParams { - signerAddress: string; - inputToken: string; - inputAmount: bigint; - fee: FeeData; - relaySpender: string; - depositTarget: string; - /** Bridge calldata with finalAmount (= inputAmount - feeAmount) already encoded inside. */ - depositData: string; -} - -function buildBridgeCalldata(routerIface: ethers.Interface, p: BridgeParams): string { - const input: InputData = { - user: p.signerAddress, - inputToken: p.inputToken, - inputAmount: p.inputAmount, - }; - - // No bridge amount-position flag or bridge-value flag — the caller bakes the net amount - // directly into depositData before calling. - const bridgeData: BridgeData = { - target: p.depositTarget, - approvalSpender: p.relaySpender, - value: 0n, - }; - - return routerIface.encodeFunctionData('bridge', [ - ZERO_BYTES32, - input, - p.fee, - bridgeData, - p.depositData, - ]); -} - -// ─── Execution leg ──────────────────────────────────────────────────────────── - -interface LegConfig { - label: string; - inputToken: string; - decimals: number; - symbol: string; - originChainId: number; - destinationChainId: number; - destinationCurrency: string; -} - -async function executeLeg(args: { - config: LegConfig; - signer: ethers.Wallet; - signerAddress: string; - inputAmount: bigint; - routerIface: ethers.Interface; -}): Promise { - const { config, signer, signerAddress, inputAmount, routerIface } = args; - - const feeAmount = bpsOf(inputAmount, FEE_BPS); - const bridgeAmount = inputAmount - feeAmount; - - const fmt = (n: bigint) => ethers.formatUnits(n, config.decimals); - - console.log(`\n── ${config.label} ──`); - console.log(`Input: ${fmt(inputAmount)} ${config.symbol}`); - console.log(`Fee (${FEE_BPS} bps): ${fmt(feeAmount)} ${config.symbol}`); - console.log(`Bridge amount: ${fmt(bridgeAmount)} ${config.symbol}`); - - const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; - console.log(`Fee tuple: ${fee.amount === 0n ? 'amount=0 (no fee)' : `receiver=${fee.receiver}, amount=${fee.amount}`}`); - - console.log('Fetching Relay.link quote...'); - const quote = await fetchRelayQuoteV2({ - routerAddress: ROUTER_POLYGON, - recipient: signerAddress, - originChainId: config.originChainId, - destinationChainId: config.destinationChainId, - originCurrency: config.inputToken, - destinationCurrency: config.destinationCurrency, - amount: bridgeAmount, - }); - const { relaySpender, depositTarget, depositData } = parseRelayQuote(quote); - console.log(`Relay spender: ${relaySpender}`); - console.log(`Deposit target: ${depositTarget}`); - - await ensureRouterErc20Balance(signer, config.inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, config.inputToken, relaySpender); - - const execCalldata = buildBridgeCalldata(routerIface, { - signerAddress, - inputToken: config.inputToken, - inputAmount, - fee, - relaySpender, - depositTarget, - depositData, - }); - - await ensureAllowanceForAllowanceHolder(signer, config.inputToken, inputAmount); - - console.log('Sending AllowanceHolder.exec → router.bridge...'); - const receipt = await execViaAH( - signer, - ROUTER_POLYGON, - config.inputToken, - inputAmount, - ROUTER_POLYGON, - execCalldata, - ); - - logTxnSummary(config.label, config.originChainId, receipt); -} - -// ─── Entry points ───────────────────────────────────────────────────────────── - -async function run(useUsdc: boolean): Promise { - const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) { - throw new Error('PRIVATE_KEY env var required'); - } - - const provider = new ethers.JsonRpcProvider(RPC.POLYGON); - const signer = new ethers.Wallet(privateKey, provider); - const signerAddress = await signer.getAddress(); - - const legConfig: LegConfig = useUsdc - ? { - label: 'Polygon USDC → Base USDC — Relay — Simple Bridge', - inputToken: TOKENS.USDC_POLYGON_CIRCLE, - decimals: 6, - symbol: 'USDC', - originChainId: CHAIN_IDS.POLYGON, - destinationChainId: CHAIN_IDS.BASE, - destinationCurrency: TOKENS.USDC_BASE, - } - : { - label: 'Polygon AAVE → Base AAVE — Relay — Simple Bridge', - inputToken: TOKENS.AAVE_POLYGON, - decimals: 18, - symbol: 'AAVE', - originChainId: CHAIN_IDS.POLYGON, - destinationChainId: CHAIN_IDS.BASE, - destinationCurrency: TOKENS.AAVE_BASE, - }; - - const { balance: walletBalance } = await getWalletErc20Balance( - legConfig.inputToken, - signerAddress, - provider, - ); - if (walletBalance === 0n) { - throw new Error( - `Signer ${signerAddress} has zero balance of ${legConfig.inputToken}. Fund the wallet first.`, - ); - } - - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_POLYGON}`); - console.log(`Input token: ${legConfig.inputToken}`); - console.log( - `Balance: ${ethers.formatUnits(walletBalance, legConfig.decimals)} ${legConfig.symbol}`, - ); - - const routerIface = new ethers.Interface(ROUTER_ABI); - - await executeLeg({ - config: legConfig, - signer, - signerAddress, - inputAmount: (walletBalance - 20n) / 2n, - routerIface, - }); - - console.log('\nDone.'); -} - -async function main(): Promise { - const arg = process.argv[2]?.toLowerCase(); - const useUsdc = arg === 'usdc' || arg === 'usdc-polygon-base'; - await run(useUsdc); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/e2e/cctp/bridge.preFee.ts b/scripts/e2e/cctp/bridge.preFee.ts index 196daf6..ce85682 100644 --- a/scripts/e2e/cctp/bridge.preFee.ts +++ b/scripts/e2e/cctp/bridge.preFee.ts @@ -4,7 +4,7 @@ * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge * * Bridge amount is pre-encoded in depositForBurn calldata (no splice needed). - * Uses router.bridge() rather than performExecution / performModularExecution. + * Uses router.bridge() rather than performExecution / performActions. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/bridge.preFee.ts diff --git a/scripts/e2e/cctp/performExecution.postFee.ts b/scripts/e2e/cctp/performExecution.postFee.ts index 45d8544..645346a 100644 --- a/scripts/e2e/cctp/performExecution.postFee.ts +++ b/scripts/e2e/cctp/performExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) - * Function: performExecution (monolithic) + * Function: swapAndBridge * Fee: postFee — FEE_BPS of estimatedOut USDC deducted after swap * * Usage: @@ -26,15 +26,15 @@ import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowance import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { - MonolithicExecutionCall, - NO_FEE, + POST_FEE_FLAG, ZERO_BYTES32, bridgeAmountPositionFlag, - monolithicArgs, + swapAndBridgeArgs, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +const FLAGS = POST_FEE_FLAG | bridgeAmountPositionFlag(4); const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); interface OoQuoteResponse { @@ -124,11 +124,14 @@ async function main() { await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount }, - preFee: NO_FEE, - swap: { + const callData = routerIface.encodeFunctionData( + 'swapAndBridge', + swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount }, + { receiver: signerAddress, amount: feeAmount }, + { target: ooRouter, approvalSpender: ooRouter, outputToken: TOKENS.USDC_POLYGON_CIRCLE, @@ -136,26 +139,21 @@ async function main() { minOutput: minAmountOut, returnDataWordOffset: 0n, }, - postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { target: polyCctp.tokenMessenger, approvalSpender: polyCctp.tokenMessenger, value: 0n }, - flags: bridgeAmountPositionFlag(4), - }, - swapCallData: swapData, - bridgeCallData: depositForBurnData, - }; - - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + swapData, + { target: polyCctp.tokenMessenger, approvalSpender: polyCctp.tokenMessenger, value: 0n }, + depositForBurnData, + ), + ); await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, inputAmount, ROUTER_POLYGON, callData); - logTxnSummary( - 'Polygon AAVE → Base USDC (CCTP) — performExecution postFee', - CHAIN_IDS.POLYGON, - receipt, - ); + logTxnSummary('Polygon AAVE → Base USDC (CCTP) — swapAndBridge postFee', CHAIN_IDS.POLYGON, receipt); console.log(`\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`); } -main().catch((err) => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/cctp/performExecution.preFee.ts b/scripts/e2e/cctp/performExecution.preFee.ts index 07808f6..5065564 100644 --- a/scripts/e2e/cctp/performExecution.preFee.ts +++ b/scripts/e2e/cctp/performExecution.preFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon USDC → Base USDC (CCTP depositForBurn, no swap) - * Function: performExecution (monolithic) + * Function: bridge * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge * * Usage: @@ -23,12 +23,11 @@ import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowance import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { - MonolithicExecutionCall, - NO_FEE, - NO_SWAP, ZERO_BYTES32, - bridgeAmountPositionFlag, - monolithicArgs, + bridgeArgs, + type BridgeData, + type FeeData, + type InputData, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; @@ -39,12 +38,13 @@ function buildDepositForBurnCalldata( recipientAddress: string, burnToken: string, destinationCctpDomain: number, + amount: bigint, ): string { const iface = new ethers.Interface([ 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external', ]); return iface.encodeFunctionData('depositForBurn', [ - 0n, + amount, destinationCctpDomain, ethers.zeroPadValue(recipientAddress, 32), burnToken, @@ -69,47 +69,46 @@ async function main() { if (inputAmount === 0n) throw new Error('Balance too small'); const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; console.log(`Signer: ${signerAddress}`); console.log(`Router: ${ROUTER_POLYGON}`); console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); console.log(`Pre-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); - console.log(`Net to bridge: ${ethers.formatUnits(inputAmount - feeAmount, 6)}`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); - const routerIface = new ethers.Interface(ROUTER_ABI); const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + const depositForBurnData = buildDepositForBurnCalldata( + signerAddress, + polyCctp.usdcAddress, + baseCctp.cctpDomain, + bridgeAmount, + ); - const depositForBurnData = buildDepositForBurnCalldata(signerAddress, polyCctp.usdcAddress, baseCctp.cctpDomain); + const input: InputData = { user: signerAddress, inputToken: TOKENS.USDC_POLYGON_CIRCLE, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { + target: polyCctp.tokenMessenger, + approvalSpender: polyCctp.tokenMessenger, + value: 0n, + }; await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: TOKENS.USDC_POLYGON_CIRCLE, inputAmount }, - preFee: { receiver: signerAddress, amount: feeAmount }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { target: polyCctp.tokenMessenger, approvalSpender: polyCctp.tokenMessenger, value: 0n }, - flags: bridgeAmountPositionFlag(4), - }, - swapCallData: '0x', - bridgeCallData: depositForBurnData, - }; - - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + const routerIface = new ethers.Interface(ROUTER_ABI); + const callData = routerIface.encodeFunctionData('bridge', bridgeArgs(ZERO_BYTES32, input, fee, bridgeData, depositForBurnData)); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData); - logTxnSummary( - 'Polygon USDC → Base USDC (CCTP) — performExecution preFee', - CHAIN_IDS.POLYGON, - receipt, - ); + logTxnSummary('Polygon USDC → Base USDC (CCTP) — bridge preFee', CHAIN_IDS.POLYGON, receipt); console.log(`\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`); } -main().catch((err) => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/cctp/performModularExecution.postFee.ts b/scripts/e2e/cctp/performModularExecution.postFee.ts index 719e0b9..7d37635 100644 --- a/scripts/e2e/cctp/performModularExecution.postFee.ts +++ b/scripts/e2e/cctp/performModularExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: postFee — FEE_BPS of estimatedOut USDC transferred to signer after swap * * Modular action sequence: @@ -13,7 +13,7 @@ * [6] tokenMessenger.depositForBurn(...) — spliceArg(0) patches amount from [5] * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/performModularExecution.postFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/performActions.postFee.ts */ import axios from 'axios'; import { ethers } from 'ethers'; @@ -141,13 +141,13 @@ async function main() { const usdcBalance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(ROUTER_POLYGON)); exec.call(polyCctp.tokenMessenger, depositForBurnData).spliceArg(0, usdcBalance.returnWord()); - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, inputAmount, ROUTER_POLYGON, callData); logTxnSummary( - 'Polygon AAVE → Base USDC (CCTP) — performModularExecution postFee', + 'Polygon AAVE → Base USDC (CCTP) — performActions postFee', CHAIN_IDS.POLYGON, receipt, ); diff --git a/scripts/e2e/cctp/performModularExecution.preFee.ts b/scripts/e2e/cctp/performModularExecution.preFee.ts index b432ff9..89582a2 100644 --- a/scripts/e2e/cctp/performModularExecution.preFee.ts +++ b/scripts/e2e/cctp/performModularExecution.preFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon USDC → Base USDC (CCTP depositForBurn, no swap) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: preFee — FEE_BPS of inputAmount USDC transferred to signer before bridge * * Modular action sequence: @@ -11,7 +11,7 @@ * [4] tokenMessenger.depositForBurn(...) — spliceArg(0) patches amount from [3] * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/performModularExecution.preFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/cctp/performActions.preFee.ts */ import { ethers } from 'ethers'; import * as dotenv from 'dotenv'; @@ -97,13 +97,13 @@ async function main() { const usdcBalance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(ROUTER_POLYGON)); exec.call(polyCctp.tokenMessenger, depositForBurnData).spliceArg(0, usdcBalance.returnWord()); - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData); logTxnSummary( - 'Polygon USDC → Base USDC (CCTP) — performModularExecution preFee', + 'Polygon USDC → Base USDC (CCTP) — performActions preFee', CHAIN_IDS.POLYGON, receipt, ); diff --git a/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts index f622b53..1a7fe75 100644 --- a/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts +++ b/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts @@ -31,7 +31,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -162,14 +162,14 @@ async function main() { polyCctp.tokenMessenger ); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -186,7 +186,7 @@ async function main() { value: 0n, }, depositForBurnData, - ]); + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts index b09b480..3a8abf6 100644 --- a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts +++ b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts @@ -31,7 +31,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -216,14 +216,14 @@ async function main() { polyCctp.tokenMessenger ); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, @@ -240,7 +240,7 @@ async function main() { value: 0n, }, depositForBurnData, - ]); + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts index c12c1ba..dffa1df 100644 --- a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts +++ b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts @@ -31,7 +31,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -162,14 +162,14 @@ async function main() { polyCctp.tokenMessenger ); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -186,7 +186,7 @@ async function main() { value: 0n, }, depositForBurnData, - ]); + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts index 78ba078..7d0b75c 100644 --- a/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts +++ b/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts @@ -31,7 +31,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -164,14 +164,14 @@ async function main() { polyCctp.tokenMessenger ); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -188,7 +188,7 @@ async function main() { value: 0n, }, depositForBurnData, - ]); + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts b/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts index c72b884..f9729ac 100644 --- a/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts +++ b/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts @@ -31,7 +31,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -164,14 +164,14 @@ async function main() { polyCctp.tokenMessenger ); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -188,7 +188,7 @@ async function main() { value: 0n, }, depositForBurnData, - ]); + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/config.ts b/scripts/e2e/config.ts index a285251..24f4dc6 100644 --- a/scripts/e2e/config.ts +++ b/scripts/e2e/config.ts @@ -34,7 +34,7 @@ export const ALLOWANCE_HOLDER = '0x0000000000001fF3684f28c67538d4D072C22734'; * Chains without an entry fall back to legacy `ROUTER_ADDRESS` when set. */ export const ROUTER_BY_CHAIN_ID: Record = { - [CHAIN_IDS.POLYGON]: '0x5abf9dccabc44ea9421f1e1Fbd6BA6A4f2387342', + [CHAIN_IDS.POLYGON]: '0x7894c2c93e8952867e2fA4C0778296fEE77074Ea', [CHAIN_IDS.ARBITRUM]: '0xe1788A0374EF5D4C35e62478FdB35F37CeE5B951', [CHAIN_IDS.BASE]: '0x91b536E79cd3607b593f3011937862609D608253', [CHAIN_IDS.ETHEREUM]: '0xeB5ae85Fe7e3E272Ac77fd316079589C0Ed91648', diff --git a/scripts/e2e/routerUsdc.withdraw.modular.ts b/scripts/e2e/misc/routerUsdc.withdraw.modular.ts similarity index 77% rename from scripts/e2e/routerUsdc.withdraw.modular.ts rename to scripts/e2e/misc/routerUsdc.withdraw.modular.ts index 210307d..4df585d 100644 --- a/scripts/e2e/routerUsdc.withdraw.modular.ts +++ b/scripts/e2e/misc/routerUsdc.withdraw.modular.ts @@ -1,6 +1,6 @@ /** - * Polygon: sweep USDC from `BungeeOpenRouterV2Unchecked` to the tx sender using - * `performModularExecution` only — no AllowanceHolder, no pull step. + * Polygon: sweep USDC from `BungeeOpenRouter` to the tx sender using + * `performActions` only — no AllowanceHolder, no pull step. * * Actions: * [0] STATICCALL USDC.balanceOf(router) — stored returndata (32-byte uint256) @@ -8,7 +8,7 @@ * is transferring the router's entire USDC balance to `msg.sender` of this tx. * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/polygon/routerUsdc.withdraw.modular.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/misc/routerUsdc.withdraw.modular.ts * * Requires the router contract to actually hold Polygon USDC * ({@link TOKENS.USDC_POLYGON_CIRCLE}). @@ -17,16 +17,16 @@ import { ethers } from 'ethers'; import * as dotenv from 'dotenv'; dotenv.config(); -import { CHAIN_IDS, routerAddressForChain, TOKENS, RPC } from './config'; +import { CHAIN_IDS, routerAddressForChain, TOKENS, RPC } from '../config'; import { encodeBalanceOf, encodeTransfer, getWalletErc20Balance, -} from './utils/erc20'; -import { ROUTER_ABI } from './utils/routerAbi'; -import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; -import { ZERO_BYTES32 } from './utils/contractTypes'; -import { logTxnSummary } from './utils/txnLogSummary'; +} from '../utils/erc20'; +import { ROUTER_ABI } from '../utils/routerAbi'; +import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; +import { ZERO_BYTES32 } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; async function main(): Promise { const privateKey = process.env.PRIVATE_KEY; @@ -64,13 +64,13 @@ async function main(): Promise { .spliceArg(1, routerBal.ref().returnWord(0)); const routerIface = new ethers.Interface(ROUTER_ABI); - const calldata = routerIface.encodeFunctionData('performModularExecution', [ + const calldata = routerIface.encodeFunctionData('performActions', [ ZERO_BYTES32, exec.toActions(), ]); console.log( - 'Sending performModularExecution (balanceOf → transfer with spliced amount)...', + 'Sending performActions (balanceOf → transfer with spliced amount)...', ); const tx = await signer.sendTransaction({ to: routerAddress, @@ -84,7 +84,7 @@ async function main(): Promise { } logTxnSummary( - 'Polygon — withdraw router USDC to caller via performModularExecution', + 'Polygon — withdraw router USDC to caller via performActions', chainId, receipt, ); diff --git a/scripts/e2e/oft/bridge.preFee.ts b/scripts/e2e/oft/bridge.preFee.ts index e1f1033..49a909f 100644 --- a/scripts/e2e/oft/bridge.preFee.ts +++ b/scripts/e2e/oft/bridge.preFee.ts @@ -5,7 +5,7 @@ * * Bridge amount is pre-encoded in OFT send() calldata (no splice needed). * bridge.value = nativeFeeWithBuffer forwarded as LZ msg.value. - * Uses router.bridge() rather than performExecution / performModularExecution. + * Uses router.bridge() rather than performExecution / performActions. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/bridge.preFee.ts diff --git a/scripts/e2e/oft/performExecution.postFee.ts b/scripts/e2e/oft/performExecution.postFee.ts index eb4a2d1..67c0bbb 100644 --- a/scripts/e2e/oft/performExecution.postFee.ts +++ b/scripts/e2e/oft/performExecution.postFee.ts @@ -1,18 +1,18 @@ /** * Route: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2) - * Function: performExecution (monolithic) - * Fee: postFee — FEE_BPS of estimatedOut USDT0 deducted after swap + * Flags: post-fee (fee taken from USDT0 output after swap), output read from swap returndata word 0 + * bridge amount spliced into send() calldata at byte offset 196 (sendParam.amountLD) * - * Bridge amount position flag splices actual post-fee balance into send() amountLD at byte 196. - * bridge.value = nativeFeeWithBuffer (5% buffer on LZ fee) forwarded as LZ msg.value. + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut USDT0, deducted from swap output. + * Returndata (bit1=0): final USDT0 amount is read from word 0 of the swap call returndata. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performExecution.postFee.ts */ -import axios from 'axios'; -import { ethers } from 'ethers'; -import * as dotenv from 'dotenv'; -import { Options } from '@layerzerolabs/lz-v2-utilities'; +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; +import { Options } from "@layerzerolabs/lz-v2-utilities"; dotenv.config(); import { @@ -26,29 +26,33 @@ import { OO_SLIPPAGE_PERCENT, ARBITRUM_LZ_EID, USDT0_OFT_ADAPTER_POLYGON, -} from '../config'; -import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { getWalletErc20Balance } from '../utils/erc20'; -import { ROUTER_ABI } from '../utils/routerAbi'; +} from "../config"; import { - MonolithicExecutionCall, - NO_FEE, - ZERO_BYTES32, - bridgeAmountPositionFlag, - monolithicArgs, -} from '../utils/contractTypes'; -import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterApproval, +} from "../utils/reproducibility"; +// post-fee (0x01) | bridge amount at byte offset 196 (sendParam.amountLD) +const FLAGS = 0x01n | bridgeAmountPositionFlag(196); const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); -const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); -const OFT_AMOUNT_LD_OFFSET = 196; +const LZ_EXTRA_OPTIONS = Options.newOptions() + .addExecutorLzReceiveOption(65000, 0) + .toHex(); const OFT_ABI = [ - 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', - 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', - 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", ]; + const OFT_IFACE = new ethers.Interface(OFT_ABI); interface OoQuoteResponse { @@ -68,7 +72,7 @@ async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ slippage: OO_SLIPPAGE_PERCENT, sender: ROUTER_POLYGON, account: ROUTER_POLYGON, - gasPrice: '1', + gasPrice: "1", }; if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; @@ -85,21 +89,45 @@ async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ async function fetchOftQuote( provider: ethers.JsonRpcProvider, bridgeAmountLD: bigint, - recipient: string, + recipient: string ): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { - const contract = new ethers.Contract(USDT0_OFT_ADAPTER_POLYGON, OFT_ABI, provider); + const contract = new ethers.Contract( + USDT0_OFT_ADAPTER_POLYGON, + OFT_ABI, + provider + ); const to32 = ethers.zeroPadValue(recipient, 32); - const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }; - const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + const sendParam = { + dstEid: ARBITRUM_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); + const nativeFee = fee.nativeFee as bigint; return { - nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + nativeFeeWithBuffer: (nativeFee * 105n) / 100n, amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, }; } function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { - return OFT_IFACE.encodeFunctionData('send', [ - { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }, + return OFT_IFACE.encodeFunctionData("send", [ + { + dstEid: ARBITRUM_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD: 0n, // placeholder — spliced at runtime at offset 196 + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: "0x", + oftCmd: "0x", + }, { nativeFee, lzTokenFee: 0n }, recipient, ]); @@ -107,77 +135,128 @@ function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { async function main() { const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); const provider = new ethers.JsonRpcProvider(RPC.POLYGON); const signer = new ethers.Wallet(privateKey, provider); const signerAddress = await signer.getAddress(); - const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.AAVE_POLYGON, signerAddress, provider); - if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.AAVE_POLYGON, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero AAVE on Polygon`); const inputAmount = walletBalance - 20n; - if (inputAmount === 0n) throw new Error('Balance too small'); + if (inputAmount === 0n) throw new Error("Balance too small"); console.log(`Signer: ${signerAddress}`); console.log(`Router: ${ROUTER_POLYGON}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (post-fee, returndata, bridge-amount-pos=196)` + ); console.log(`AAVE balance: ${ethers.formatUnits(walletBalance, 18)}`); const routerIface = new ethers.Interface(ROUTER_ABI); - console.log('Fetching OpenOcean quote (AAVE → USDT0)...'); - const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + console.log("Fetching OpenOcean quote (AAVE → USDT0)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated USDT0 output after swap const feeAmount = bpsOf(estimatedOut, FEE_BPS); const bridgeAmount = estimatedOut - feeAmount; console.log(` OO router: ${ooRouter}`); console.log(` Est. USDT0: ${ethers.formatUnits(estimatedOut, 6)}`); - console.log(` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)`); + console.log( + ` Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)` + ); + console.log(` Bridge est: ${ethers.formatUnits(bridgeAmount, 6)}`); console.log(` Min USDT0: ${ethers.formatUnits(minAmountOut, 6)}`); - console.log('Fetching OFT quote (Polygon → Arbitrum)...'); - const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote(provider, bridgeAmount, signerAddress); + console.log("Fetching OFT quote (Polygon → Arbitrum)..."); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote( + provider, + bridgeAmount, + signerAddress + ); console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); - console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); + console.log( + ` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0` + ); await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter + ); + await ensureRouterApproval( + signer, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON + ); const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ooRouter, - outputToken: TOKENS.USDT0_POLYGON, - value: 0n, - minOutput: minAmountOut, - returnDataWordOffset: 0n, - }, - postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: USDT0_OFT_ADAPTER_POLYGON, value: nativeFeeWithBuffer }, - flags: bridgeAmountPositionFlag(OFT_AMOUNT_LD_OFFSET), + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, }, - swapCallData: swapData, - bridgeCallData: oftSendData, - }; - - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + value: nativeFeeWithBuffer, + }, + oftSendData, + )); - await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); - const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); + await ensureAllowanceForAllowanceHolder( + signer, + TOKENS.AAVE_POLYGON, + inputAmount + ); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + inputAmount, + ROUTER_POLYGON, + callData, + nativeFeeWithBuffer + ); logTxnSummary( - 'Polygon AAVE → Arbitrum USDT0 (OFT) — performExecution postFee', + `Polygon AAVE → Arbitrum USDT0 (OFT) — swapAndBridge postFee/returndata`, CHAIN_IDS.POLYGON, - receipt, + receipt ); - console.log('\nUSDT0 arrives on Arbitrum once LZ delivers the message.'); + console.log("\nUSDT0 arrives on Arbitrum once LZ delivers the message."); } -main().catch((err) => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/oft/performExecution.preFee.ts b/scripts/e2e/oft/performExecution.preFee.ts index a26827f..ebb6ab8 100644 --- a/scripts/e2e/oft/performExecution.preFee.ts +++ b/scripts/e2e/oft/performExecution.preFee.ts @@ -1,10 +1,11 @@ /** * Route: Polygon USDT0 → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2, no swap) - * Function: performExecution (monolithic) + * Function: bridge (simple bridge entrypoint) * Fee: preFee — FEE_BPS of inputAmount USDT0 deducted before bridge * - * Bridge amount position flag splices actual post-fee balance into send() amountLD at byte 196. + * Bridge amount is pre-encoded in OFT send() calldata (no splice needed). * bridge.value = nativeFeeWithBuffer forwarded as LZ msg.value. + * Uses router.bridge() rather than performExecution / performActions. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performExecution.preFee.ts @@ -27,20 +28,12 @@ import { import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; -import { - MonolithicExecutionCall, - NO_FEE, - NO_SWAP, - ZERO_BYTES32, - bridgeAmountPositionFlag, - monolithicArgs, -} from '../utils/contractTypes'; +import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); -const OFT_AMOUNT_LD_OFFSET = 196; const OFT_ABI = [ 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', @@ -56,32 +49,60 @@ async function fetchOftQuote( ): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { const contract = new ethers.Contract(USDT0_OFT_ADAPTER_POLYGON, OFT_ABI, provider); const to32 = ethers.zeroPadValue(recipient, 32); - const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }; - const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + const sendParam = { + dstEid: ARBITRUM_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: '0x', + oftCmd: '0x', + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); return { nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, }; } -function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { +/** + * Builds OFT send() calldata with the exact bridgeAmount pre-encoded (no 0 placeholder). + * Used by router.bridge() which has no splice mechanism. + */ +function buildOftSendCalldata(bridgeAmount: bigint, nativeFee: bigint, recipient: string): string { return OFT_IFACE.encodeFunctionData('send', [ - { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: LZ_EXTRA_OPTIONS, composeMsg: '0x', oftCmd: '0x' }, + { + dstEid: ARBITRUM_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD: bridgeAmount, + minAmountLD: 0n, + extraOptions: LZ_EXTRA_OPTIONS, + composeMsg: '0x', + oftCmd: '0x', + }, { nativeFee, lzTokenFee: 0n }, recipient, ]); } -async function main() { +async function main(): Promise { const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } const provider = new ethers.JsonRpcProvider(RPC.POLYGON); const signer = new ethers.Wallet(privateKey, provider); const signerAddress = await signer.getAddress(); - const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDT0_POLYGON, signerAddress, provider); - if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDT0 on Polygon`); + const inputToken = TOKENS.USDT0_POLYGON; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero USDT0 on Polygon`); + } const inputAmount = walletBalance - 20n; if (inputAmount === 0n) throw new Error('Balance too small'); @@ -89,49 +110,51 @@ async function main() { const feeAmount = bpsOf(inputAmount, FEE_BPS); const bridgeAmount = inputAmount - feeAmount; - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_POLYGON}`); - console.log(`USDT0 balance: ${ethers.formatUnits(walletBalance, 6)}`); - console.log(`Pre-fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)`); - console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); - - const routerIface = new ethers.Interface(ROUTER_ABI); + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDT0 balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} USDT0 (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); console.log('Fetching OFT quote (Polygon → Arbitrum)...'); const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote(provider, bridgeAmount, signerAddress); - console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); - await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); - - const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + const sendData = buildOftSendCalldata(bridgeAmount, nativeFeeWithBuffer, signerAddress); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: TOKENS.USDT0_POLYGON, inputAmount }, - preFee: { receiver: signerAddress, amount: feeAmount }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: USDT0_OFT_ADAPTER_POLYGON, value: nativeFeeWithBuffer }, - flags: bridgeAmountPositionFlag(OFT_AMOUNT_LD_OFFSET), - }, - swapCallData: '0x', - bridgeCallData: oftSendData, + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + value: nativeFeeWithBuffer, }; - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); - - await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDT0_POLYGON, inputAmount); - const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); - - logTxnSummary( - 'Polygon USDT0 → Arbitrum USDT0 (OFT direct) — performExecution preFee', - CHAIN_IDS.POLYGON, - receipt, + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, sendData]); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, USDT0_OFT_ADAPTER_POLYGON); + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.bridge...'); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + inputToken, + inputAmount, + ROUTER_POLYGON, + execCalldata, + nativeFeeWithBuffer, ); + logTxnSummary('Polygon USDT0 → Arbitrum USDT0 (OFT) — bridge preFee', CHAIN_IDS.POLYGON, receipt); + console.log('\nUSDT0 arrives on Arbitrum once LZ delivers the message.'); } -main().catch((err) => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/oft/performModularExecution.postFee.ts b/scripts/e2e/oft/performModularExecution.postFee.ts index e59d095..74e7e59 100644 --- a/scripts/e2e/oft/performModularExecution.postFee.ts +++ b/scripts/e2e/oft/performModularExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: postFee — FEE_BPS of estimatedOut USDT0 transferred to signer after swap * * Modular action sequence: @@ -13,7 +13,7 @@ * [6] nativeCall adapter.send(...) — spliceWord patches amountLD at offset 196 from [5] * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performModularExecution.postFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performActions.postFee.ts */ import axios from 'axios'; import { ethers } from 'ethers'; @@ -161,13 +161,13 @@ async function main() { exec.nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) .spliceWord(BigInt(OFT_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); logTxnSummary( - 'Polygon AAVE → Arbitrum USDT0 (OFT) — performModularExecution postFee', + 'Polygon AAVE → Arbitrum USDT0 (OFT) — performActions postFee', CHAIN_IDS.POLYGON, receipt, ); diff --git a/scripts/e2e/oft/performModularExecution.preFee.ts b/scripts/e2e/oft/performModularExecution.preFee.ts index 097ef85..4f02cdc 100644 --- a/scripts/e2e/oft/performModularExecution.preFee.ts +++ b/scripts/e2e/oft/performModularExecution.preFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon USDT0 → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2, no swap) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: preFee — FEE_BPS of inputAmount USDT0 transferred to signer before bridge * * Modular action sequence: @@ -11,7 +11,7 @@ * [4] nativeCall adapter.send(...) — spliceWord patches amountLD at offset 196 from [3] * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performModularExecution.preFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/oft/performActions.preFee.ts */ import { ethers } from 'ethers'; import * as dotenv from 'dotenv'; @@ -117,13 +117,13 @@ async function main() { exec.nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) .spliceWord(BigInt(OFT_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDT0_POLYGON, inputAmount); const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); logTxnSummary( - 'Polygon USDT0 → Arbitrum USDT0 (OFT direct) — performModularExecution preFee', + 'Polygon USDT0 → Arbitrum USDT0 (OFT direct) — performActions preFee', CHAIN_IDS.POLYGON, receipt, ); diff --git a/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts index b291e9b..d865f0e 100644 --- a/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts +++ b/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts @@ -33,7 +33,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -206,14 +206,14 @@ async function main() { const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -230,7 +230,7 @@ async function main() { value: nativeFeeWithBuffer, }, oftSendData, - ]); + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts b/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts index 7d76600..fad5ebb 100644 --- a/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts +++ b/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts @@ -33,7 +33,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -206,14 +206,14 @@ async function main() { const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -230,7 +230,7 @@ async function main() { value: nativeFeeWithBuffer, }, oftSendData, - ]); + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts index f1e91ab..789858f 100644 --- a/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts +++ b/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts @@ -33,7 +33,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -205,14 +205,14 @@ async function main() { const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -229,7 +229,7 @@ async function main() { value: nativeFeeWithBuffer, }, oftSendData, - ]); + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts b/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts index 5e6353b..e8a60ae 100644 --- a/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts +++ b/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts @@ -33,7 +33,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32, bridgeAmountPositionFlag } from "../utils/contractTypes"; +import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -205,14 +205,14 @@ async function main() { const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -229,7 +229,7 @@ async function main() { value: nativeFeeWithBuffer, }, oftSendData, - ]); + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/relay/aave.bridge.preFee.ts b/scripts/e2e/relay/aave.bridge.preFee.ts index 15efa33..2d90da3 100644 --- a/scripts/e2e/relay/aave.bridge.preFee.ts +++ b/scripts/e2e/relay/aave.bridge.preFee.ts @@ -4,7 +4,7 @@ * Fee: preFee — FEE_BPS of inputAmount AAVE deducted before bridge * * Bridge amount is pre-encoded in Relay deposit calldata. - * Uses router.bridge() rather than performExecution / performModularExecution. + * Uses router.bridge() rather than performExecution / performActions. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/aave.bridge.preFee.ts diff --git a/scripts/e2e/relay/aave.performExecution.preFee.ts b/scripts/e2e/relay/aave.performExecution.preFee.ts index ea6ea8c..188ba82 100644 --- a/scripts/e2e/relay/aave.performExecution.preFee.ts +++ b/scripts/e2e/relay/aave.performExecution.preFee.ts @@ -1,11 +1,8 @@ /** * Route: Polygon AAVE → Base AAVE via Relay.link (no swap) - * Function: performExecution (monolithic) + * Function: bridge * Fee: preFee — FEE_BPS of inputAmount AAVE deducted before bridge * - * Fetches a Relay.link /quote/v2 for the net bridge amount, then encodes a - * MonolithicExecutionCall with preFee and the deposit calldata. - * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/aave.performExecution.preFee.ts */ @@ -24,13 +21,7 @@ import { import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; -import { - MonolithicExecutionCall, - NO_FEE, - NO_SWAP, - ZERO_BYTES32, - monolithicArgs, -} from '../utils/contractTypes'; +import { ZERO_BYTES32, bridgeArgs, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; @@ -82,28 +73,19 @@ async function main(): Promise { await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken, inputAmount }, - preFee: { receiver: signerAddress, amount: feeAmount }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { target: depositTarget, approvalSpender: relaySpender, value: 0n }, - flags: 0n, - }, - swapCallData: '0x', - bridgeCallData: depositData, - }; + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { target: depositTarget, approvalSpender: relaySpender, value: 0n }; const routerIface = new ethers.Interface(ROUTER_ABI); - const execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + const execCalldata = routerIface.encodeFunctionData('bridge', bridgeArgs(ZERO_BYTES32, input, fee, bridgeData, depositData)); await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); - console.log('Sending AllowanceHolder.exec → router.performExecution...'); + console.log('Sending AllowanceHolder.exec → router.bridge...'); const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); - logTxnSummary('Polygon AAVE → Base AAVE — Relay — performExecution preFee', CHAIN_IDS.POLYGON, receipt); + logTxnSummary('Polygon AAVE → Base AAVE — Relay — bridge preFee', CHAIN_IDS.POLYGON, receipt); } main().catch((err) => { diff --git a/scripts/e2e/relay/aave.performModularExecution.preFee.ts b/scripts/e2e/relay/aave.performModularExecution.preFee.ts index eaa5dd0..42076a0 100644 --- a/scripts/e2e/relay/aave.performModularExecution.preFee.ts +++ b/scripts/e2e/relay/aave.performModularExecution.preFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon AAVE → Base AAVE via Relay.link (no swap) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: preFee — FEE_BPS of inputAmount AAVE deducted before bridge * * Modular action sequence: @@ -10,7 +10,7 @@ * [3] call(depositTarget, depositData) — Relay bridge * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/aave.performModularExecution.preFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/aave.performActions.preFee.ts */ import { ethers } from 'ethers'; import * as dotenv from 'dotenv'; @@ -91,14 +91,14 @@ async function main(): Promise { exec.call(depositTarget, depositData); const routerIface = new ethers.Interface(ROUTER_ABI); - const execCalldata = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const execCalldata = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); - console.log('Sending AllowanceHolder.exec → router.performModularExecution...'); + console.log('Sending AllowanceHolder.exec → router.performActions...'); const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); - logTxnSummary('Polygon AAVE → Base AAVE — Relay — performModularExecution preFee', CHAIN_IDS.POLYGON, receipt); + logTxnSummary('Polygon AAVE → Base AAVE — Relay — performActions preFee', CHAIN_IDS.POLYGON, receipt); } main().catch((err) => { diff --git a/scripts/e2e/relay/usdc.bridge.preFee.ts b/scripts/e2e/relay/usdc.bridge.preFee.ts index 3b374b6..438d915 100644 --- a/scripts/e2e/relay/usdc.bridge.preFee.ts +++ b/scripts/e2e/relay/usdc.bridge.preFee.ts @@ -4,7 +4,7 @@ * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge * * Bridge amount is pre-encoded in Relay deposit calldata. - * Uses router.bridge() rather than performExecution / performModularExecution. + * Uses router.bridge() rather than performExecution / performActions. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/usdc.bridge.preFee.ts diff --git a/scripts/e2e/relay/usdc.performExecution.preFee.ts b/scripts/e2e/relay/usdc.performExecution.preFee.ts index 9be5f2b..baf4aff 100644 --- a/scripts/e2e/relay/usdc.performExecution.preFee.ts +++ b/scripts/e2e/relay/usdc.performExecution.preFee.ts @@ -1,11 +1,8 @@ /** * Route: Polygon USDC → Base USDC via Relay.link (no swap) - * Function: performExecution (monolithic) + * Function: bridge * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge * - * Fetches a Relay.link /quote/v2 for the net bridge amount, then encodes a - * MonolithicExecutionCall with preFee and the deposit calldata. - * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/usdc.performExecution.preFee.ts */ @@ -24,13 +21,7 @@ import { import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; -import { - MonolithicExecutionCall, - NO_FEE, - NO_SWAP, - ZERO_BYTES32, - monolithicArgs, -} from '../utils/contractTypes'; +import { ZERO_BYTES32, bridgeArgs, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; @@ -82,28 +73,19 @@ async function main(): Promise { await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken, inputAmount }, - preFee: { receiver: signerAddress, amount: feeAmount }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { target: depositTarget, approvalSpender: relaySpender, value: 0n }, - flags: 0n, - }, - swapCallData: '0x', - bridgeCallData: depositData, - }; + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { target: depositTarget, approvalSpender: relaySpender, value: 0n }; const routerIface = new ethers.Interface(ROUTER_ABI); - const execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + const execCalldata = routerIface.encodeFunctionData('bridge', bridgeArgs(ZERO_BYTES32, input, fee, bridgeData, depositData)); await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); - console.log('Sending AllowanceHolder.exec → router.performExecution...'); + console.log('Sending AllowanceHolder.exec → router.bridge...'); const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); - logTxnSummary('Polygon USDC → Base USDC — Relay — performExecution preFee', CHAIN_IDS.POLYGON, receipt); + logTxnSummary('Polygon USDC → Base USDC — Relay — bridge preFee', CHAIN_IDS.POLYGON, receipt); } main().catch((err) => { diff --git a/scripts/e2e/relay/usdc.performModularExecution.preFee.ts b/scripts/e2e/relay/usdc.performModularExecution.preFee.ts index 66cbc85..d070ae0 100644 --- a/scripts/e2e/relay/usdc.performModularExecution.preFee.ts +++ b/scripts/e2e/relay/usdc.performModularExecution.preFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon USDC → Base USDC via Relay.link (no swap) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge * * Modular action sequence: @@ -10,7 +10,7 @@ * [3] call(depositTarget, depositData) — Relay bridge * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/usdc.performModularExecution.preFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/relay/usdc.performActions.preFee.ts */ import { ethers } from 'ethers'; import * as dotenv from 'dotenv'; @@ -91,14 +91,14 @@ async function main(): Promise { exec.call(depositTarget, depositData); const routerIface = new ethers.Interface(ROUTER_ABI); - const execCalldata = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const execCalldata = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); - console.log('Sending AllowanceHolder.exec → router.performModularExecution...'); + console.log('Sending AllowanceHolder.exec → router.performActions...'); const receipt = await execViaAH(signer, ROUTER_POLYGON, inputToken, inputAmount, ROUTER_POLYGON, execCalldata); - logTxnSummary('Polygon USDC → Base USDC — Relay — performModularExecution preFee', CHAIN_IDS.POLYGON, receipt); + logTxnSummary('Polygon USDC → Base USDC — Relay — performActions preFee', CHAIN_IDS.POLYGON, receipt); } main().catch((err) => { diff --git a/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts index 198a451..adaf4f6 100644 --- a/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Arbitrum USDC → ETH (OpenOcean) → Base ETH (Stargate Native ETH Pool) - * Function: performExecution (monolithic) + * Function: swapAndBridge * Fee: postFee — FEE_BPS of estimatedOut ETH deducted after swap * * BRIDGE_VALUE_FLAG set: router forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate. @@ -33,18 +33,18 @@ import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowance import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { - MonolithicExecutionCall, BRIDGE_VALUE_FLAG, - NO_FEE, + POST_FEE_FLAG, ZERO_ADDRESS, ZERO_BYTES32, - monolithicArgs, bridgeAmountPositionFlag, + swapAndBridgeArgs, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); +const FLAGS = POST_FEE_FLAG | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); const STARGATE_ABI = [ 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', @@ -101,7 +101,7 @@ async function fetchStargateQuote( }; } -function buildStargateCalldata(nativeFee: bigint, amountLD: bigint, recipient: string): string { +function buildStargateCalldata(nativeFee: bigint, recipient: string, amountLD: bigint): string { return STARGATE_IFACE.encodeFunctionData('send', [ { dstEid: BASE_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }, { nativeFee, lzTokenFee: 0n }, @@ -150,13 +150,16 @@ async function main() { await ensureRouterNativeBalance(signer, ROUTER_ARB); await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); - const stargateData = buildStargateCalldata(nativeFeeWithBuffer, amountLD, signerAddress); + const stargateData = buildStargateCalldata(nativeFeeWithBuffer, signerAddress, amountLD); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: TOKENS.USDC_ARB, inputAmount }, - preFee: NO_FEE, - swap: { + const callData = routerIface.encodeFunctionData( + 'swapAndBridge', + swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { user: signerAddress, inputToken: TOKENS.USDC_ARB, inputAmount }, + { receiver: signerAddress, amount: feeAmount }, + { target: ooRouter, approvalSpender: ooRouter, outputToken: NATIVE_TOKEN_ADDRESS, @@ -164,15 +167,11 @@ async function main() { minOutput: minAmountOut, returnDataWordOffset: 0n, }, - postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { target: STARGATE_NATIVE_ARB, approvalSpender: ZERO_ADDRESS, value: nativeFeeWithBuffer }, - flags: BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), - }, - swapCallData: swapData, - bridgeCallData: stargateData, - }; - - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + swapData, + { target: STARGATE_NATIVE_ARB, approvalSpender: ZERO_ADDRESS, value: nativeFeeWithBuffer }, + stargateData, + ), + ); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); const receipt = await execViaAH(signer, ROUTER_ARB, TOKENS.USDC_ARB, inputAmount, ROUTER_ARB, callData, nativeFeeWithBuffer); diff --git a/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts b/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts index 49c6a92..2b8a109 100644 --- a/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Arbitrum USDC → ETH (OpenOcean) → Base ETH (Stargate Native ETH Pool) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: postFee — FEE_BPS of estimatedOut ETH sent to signer after swap * * Modular action sequence: @@ -14,7 +14,7 @@ * bridgeValue = minAmountOut - feeAmount (amountLD + nativeFeeWithBuffer). * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/arbUsdcBaseEth.performActions.postFee.ts */ import axios from 'axios'; import { ethers } from 'ethers'; @@ -161,13 +161,13 @@ async function main() { exec.nativeCall(signerAddress, '0x', feeAmount); exec.nativeCall(STARGATE_NATIVE_ARB, stargateData, bridgeValue); - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_ARB, inputAmount); const receipt = await execViaAH(signer, ROUTER_ARB, TOKENS.USDC_ARB, inputAmount, ROUTER_ARB, callData, nativeFeeWithBuffer); logTxnSummary( - 'Arbitrum USDC → ETH (OO) → Base ETH (Stargate native) — performModularExecution postFee', + 'Arbitrum USDC → ETH (OO) → Base ETH (Stargate native) — performActions postFee', CHAIN_IDS.ARBITRUM, receipt, ); diff --git a/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts index fa2f89d..ed16e8c 100644 --- a/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts +++ b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts @@ -1,56 +1,66 @@ /** - * Route: Base USDC → ETH (OpenOcean) → Arbitrum ETH (Stargate Native ETH Pool) - * Function: performExecution (monolithic) - * Fee: postFee — FEE_BPS of estimatedOut ETH deducted after swap + * Route: Base USDC → native ETH (OpenOcean) → Arbitrum ETH (Stargate Native Pool on Base, LayerZero v2) + * Flags: post-fee (fee taken from ETH output after swap), output read from swap returndata word 0 + * bridge-value + bridge-amount-position flags: router splices finalETH into amountLD and + * forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate * - * BRIDGE_VALUE_FLAG set: router forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate. - * BRIDGE_AMOUNT_POSITION_FLAG set: router splices finalETH into amountLD at runtime. - * Stargate receives the exact actual post-swap, post-fee ETH as amountLD. + * Post-fee (bit0=1): feeAmount = FEE_BPS of estimatedOut ETH, deducted from swap output. + * Returndata (bit1=0): final ETH amount is read from word 0 of the swap call returndata. + * BridgeValue (bit2=1): router forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate. + * BridgeAmountPosition (bit3=1): router splices finalETH into amountLD at STARGATE_AMOUNT_LD_OFFSET. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts */ -import axios from 'axios'; -import { ethers } from 'ethers'; -import * as dotenv from 'dotenv'; +import axios from "axios"; +import { ethers } from "ethers"; +import * as dotenv from "dotenv"; dotenv.config(); import { CHAIN_IDS, routerAddressForChain, TOKENS, + NATIVE_TOKEN_ADDRESS, FEE_BPS, bpsOf, RPC, OPEN_OCEAN_API_KEY, OO_SLIPPAGE_PERCENT, - NATIVE_TOKEN_ADDRESS, STARGATE_NATIVE_BASE, ARBITRUM_LZ_EID, - STARGATE_AMOUNT_LD_OFFSET, -} from '../config'; -import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { getWalletErc20Balance } from '../utils/erc20'; -import { ROUTER_ABI } from '../utils/routerAbi'; +} from "../config"; import { - MonolithicExecutionCall, + execViaAH, + ensureAllowanceForAllowanceHolder, +} from "../utils/allowanceHolder"; +import { getWalletErc20Balance } from "../utils/erc20"; +import { ROUTER_ABI } from "../utils/routerAbi"; +import { + ZERO_BYTES32, BRIDGE_VALUE_FLAG, - NO_FEE, ZERO_ADDRESS, - ZERO_BYTES32, - monolithicArgs, bridgeAmountPositionFlag, -} from '../utils/contractTypes'; -import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; + swapAndBridgeArgs, +} from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, + ensureRouterApproval, +} from "../utils/reproducibility"; +// post-fee (0x01) | bridge-value (0x04) | bridge-amount-position (0x08 + offset): splice finalETH into amountLD + forward with nativeFee +const FLAGS = 0x01n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); const ROUTER_BASE = routerAddressForChain(CHAIN_IDS.BASE); const STARGATE_ABI = [ - 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', - 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', - 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', + "function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)", + "function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)", + "function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable", ]; + const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); interface OoQuoteResponse { @@ -70,7 +80,7 @@ async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ slippage: OO_SLIPPAGE_PERCENT, sender: ROUTER_BASE, account: ROUTER_BASE, - gasPrice: '1', + gasPrice: "1", }; if (OPEN_OCEAN_API_KEY) params.apikey = OPEN_OCEAN_API_KEY; const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.BASE}/swap_quote`; @@ -87,21 +97,48 @@ async function fetchOpenOceanQuote(inputAmount: bigint): Promise<{ async function fetchStargateQuote( provider: ethers.JsonRpcProvider, bridgeAmountLD: bigint, - recipient: string, -): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { - const contract = new ethers.Contract(STARGATE_NATIVE_BASE, STARGATE_ABI, provider); + recipient: string +): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { + const contract = new ethers.Contract( + STARGATE_NATIVE_BASE, + STARGATE_ABI, + provider + ); const to32 = ethers.zeroPadValue(recipient, 32); - const sendParam = { dstEid: ARBITRUM_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }; - const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + const sendParam = { + dstEid: ARBITRUM_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); return { - nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, + nativeFee: fee.nativeFee as bigint, amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, }; } -function buildStargateCalldata(nativeFee: bigint, amountLD: bigint, recipient: string): string { - return STARGATE_IFACE.encodeFunctionData('send', [ - { dstEid: ARBITRUM_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }, +function buildStargateCalldata( + nativeFee: bigint, + recipient: string, + amountLD: bigint +): string { + return STARGATE_IFACE.encodeFunctionData("send", [ + { + dstEid: ARBITRUM_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD, + minAmountLD: 0n, + extraOptions: "0x", + composeMsg: "0x", + oftCmd: "0x", + }, { nativeFee, lzTokenFee: 0n }, recipient, ]); @@ -109,79 +146,118 @@ function buildStargateCalldata(nativeFee: bigint, amountLD: bigint, recipient: s async function main() { const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + if (!privateKey) throw new Error("PRIVATE_KEY env var required"); const provider = new ethers.JsonRpcProvider(RPC.BASE); const signer = new ethers.Wallet(privateKey, provider); const signerAddress = await signer.getAddress(); - const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_BASE, signerAddress, provider); - if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDC on Base`); + const { balance: walletBalance } = await getWalletErc20Balance( + TOKENS.USDC_BASE, + signerAddress, + provider + ); + if (walletBalance === 0n) + throw new Error(`Signer ${signerAddress} has zero USDC on Base`); const inputAmount = walletBalance - 20n; - if (inputAmount === 0n) throw new Error('Balance too small'); + if (inputAmount === 0n) throw new Error("Balance too small"); console.log(`Signer: ${signerAddress}`); console.log(`Router: ${ROUTER_BASE}`); + console.log( + `Flags: 0x${FLAGS.toString( + 16 + )} (post-fee, returndata, bridge-value)` + ); console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); const routerIface = new ethers.Interface(ROUTER_ABI); - console.log('Fetching OpenOcean quote (USDC → ETH on Base)...'); - const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOpenOceanQuote(inputAmount); + console.log("Fetching OpenOcean quote (USDC → ETH)..."); + const { ooRouter, swapData, estimatedOut, minAmountOut } = + await fetchOpenOceanQuote(inputAmount); + + // post-fee: deduct from estimated ETH output after swap const feeAmount = bpsOf(estimatedOut, FEE_BPS); - const estimatedBridgeAmount = estimatedOut - feeAmount; console.log(` OO router: ${ooRouter}`); console.log(` Est. ETH: ${ethers.formatEther(estimatedOut)}`); - console.log(` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); + console.log( + ` Post-fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)` + ); console.log(` Min ETH: ${ethers.formatEther(minAmountOut)}`); - console.log('Fetching Stargate quote (Base → Arbitrum native pool)...'); - const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, estimatedBridgeAmount, signerAddress); + console.log("Fetching Stargate quote (Base native pool → Arbitrum)..."); + const bridgeEstimate = estimatedOut - feeAmount; + const { nativeFee, amountReceivedLD } = await fetchStargateQuote( + provider, + bridgeEstimate, + signerAddress + ); + const nativeFeeWithBuffer = (nativeFee * 105n) / 100n; console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} ETH`); console.log(` Est. received: ${ethers.formatEther(amountReceivedLD)} ETH`); - // estimatedBridgeAmount is a placeholder; router splices the actual finalETH at runtime - const amountLD = estimatedBridgeAmount; + // bridgeEstimate is a placeholder for amountLD; router splices the actual finalETH at runtime + const amountLD = bridgeEstimate; await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); await ensureRouterNativeBalance(signer, ROUTER_BASE); await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); - const stargateData = buildStargateCalldata(nativeFeeWithBuffer, amountLD, signerAddress); - - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: TOKENS.USDC_BASE, inputAmount }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ooRouter, - outputToken: NATIVE_TOKEN_ADDRESS, - value: 0n, - minOutput: minAmountOut, - returnDataWordOffset: 0n, - }, - postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { target: STARGATE_NATIVE_BASE, approvalSpender: ZERO_ADDRESS, value: nativeFeeWithBuffer }, - flags: BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), - }, - swapCallData: swapData, - bridgeCallData: stargateData, - }; + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.USDC_BASE, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: ooRouter, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: STARGATE_NATIVE_BASE, + approvalSpender: ZERO_ADDRESS, + value: nativeFeeWithBuffer, + }, + stargateData, + )); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); - const receipt = await execViaAH(signer, ROUTER_BASE, TOKENS.USDC_BASE, inputAmount, ROUTER_BASE, callData, nativeFeeWithBuffer); + const receipt = await execViaAH( + signer, + ROUTER_BASE, + TOKENS.USDC_BASE, + inputAmount, + ROUTER_BASE, + callData, + nativeFeeWithBuffer + ); logTxnSummary( - 'Base USDC → ETH (OO) → Arbitrum ETH (Stargate native) — performExecution postFee', + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge postFee/returndata`, CHAIN_IDS.BASE, - receipt, + receipt ); - console.log('\nETH arrives on Arbitrum once LZ delivers the message.'); + console.log("\nETH arrives on Arbitrum once LZ delivers the message."); } -main().catch((err) => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts b/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts index d26740e..7afc9c8 100644 --- a/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Base USDC → ETH (OpenOcean) → Arbitrum ETH (Stargate Native ETH Pool) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: postFee — FEE_BPS of estimatedOut ETH sent to signer after swap * * Modular action sequence: @@ -11,7 +11,7 @@ * [4] nativeCall(Stargate, sendData, bridgeValue) — value = amountLD + nativeFeeWithBuffer * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/baseUsdcArbEth.performActions.postFee.ts */ import axios from 'axios'; import { ethers } from 'ethers'; @@ -157,13 +157,13 @@ async function main() { exec.nativeCall(signerAddress, '0x', feeAmount); exec.nativeCall(STARGATE_NATIVE_BASE, stargateData, bridgeValue); - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); const receipt = await execViaAH(signer, ROUTER_BASE, TOKENS.USDC_BASE, inputAmount, ROUTER_BASE, callData, nativeFeeWithBuffer); logTxnSummary( - 'Base USDC → ETH (OO) → Arbitrum ETH (Stargate native) — performModularExecution postFee', + 'Base USDC → ETH (OO) → Arbitrum ETH (Stargate native) — performActions postFee', CHAIN_IDS.BASE, receipt, ); diff --git a/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts b/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts index b56d40e..cd5fc38 100644 --- a/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon POL (native) → USDT0 (OpenOcean) → Arbitrum USDT0 (LZ OFT Adapter) - * Function: performExecution (monolithic) + * Function: swapAndBridge * Fee: postFee — FEE_BPS of estimatedOut USDT0 deducted after swap * * Input is native POL; msg.value = ooSwapNativeWei + nativeFeeWithBuffer. @@ -35,17 +35,17 @@ import { import { execViaAH } from '../utils/allowanceHolder'; import { ROUTER_ABI } from '../utils/routerAbi'; import { - MonolithicExecutionCall, - NO_FEE, + POST_FEE_FLAG, ZERO_ADDRESS, ZERO_BYTES32, bridgeAmountPositionFlag, - monolithicArgs, + swapAndBridgeArgs, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); +const FLAGS = POST_FEE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); const NATIVE_INPUT_GAS_RESERVE = parseEther('0.01'); const NATIVE_INPUT_GAS_LIMIT_ESTIMATE = 2_000_000n; @@ -189,11 +189,15 @@ async function main() { const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: NATIVE_TOKEN_ADDRESS, inputAmount: inputAmountWei }, - preFee: NO_FEE, - swap: { + const txValue = inputAmountWei + nativeFeeWithBuffer; + const callData = routerIface.encodeFunctionData( + 'swapAndBridge', + swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { user: signerAddress, inputToken: NATIVE_TOKEN_ADDRESS, inputAmount: inputAmountWei }, + { receiver: signerAddress, amount: feeAmount }, + { target: ooRouter, approvalSpender: ZERO_ADDRESS, outputToken: TOKENS.USDT0_POLYGON, @@ -201,16 +205,11 @@ async function main() { minOutput: minAmountOut, returnDataWordOffset: 0n, }, - postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: USDT0_OFT_ADAPTER_POLYGON, value: nativeFeeWithBuffer }, - flags: bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), - }, - swapCallData: swapData, - bridgeCallData: oftSendData, - }; - - const txValue = inputAmountWei + nativeFeeWithBuffer; - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); + swapData, + { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: USDT0_OFT_ADAPTER_POLYGON, value: nativeFeeWithBuffer }, + oftSendData, + ), + ); // Native input — no ERC-20 allowance needed for AH; pass NATIVE_TOKEN_ADDRESS const receipt = await execViaAH(signer, ROUTER_POLYGON, NATIVE_TOKEN_ADDRESS, inputAmountWei, ROUTER_POLYGON, callData, txValue); diff --git a/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts b/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts index 2bd1c19..b65902f 100644 --- a/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon POL (native) → USDT0 (OpenOcean) → Arbitrum USDT0 (LZ OFT Adapter) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: postFee — FEE_BPS of estimatedOut USDT0 transferred to signer after swap * * Input is native POL; msg.value = ooSwapNativeWei + nativeFeeWithBuffer. @@ -13,7 +13,7 @@ * [4] nativeCall(adapter, oftSendData, nativeFeeWithBuffer) — splicePayloadWord(196) from [3] * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonPolUsdt0Arb.performActions.postFee.ts */ import axios from 'axios'; import { ethers, parseEther } from 'ethers'; @@ -192,12 +192,12 @@ async function main() { .splicePayloadWord(BigInt(STARGATE_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); const txValue = inputAmountWei + nativeFeeWithBuffer; - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); const receipt = await execViaAH(signer, ROUTER_POLYGON, NATIVE_TOKEN_ADDRESS, inputAmountWei, ROUTER_POLYGON, callData, txValue); logTxnSummary( - 'Polygon POL → USDT0 (OO) → Arbitrum USDT0 (OFT) — performModularExecution postFee', + 'Polygon POL → USDT0 (OO) → Arbitrum USDT0 (OFT) — performActions postFee', CHAIN_IDS.POLYGON, receipt, ); diff --git a/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts b/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts index 5483846..80b62be 100644 --- a/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts +++ b/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts @@ -5,7 +5,7 @@ * * Bridge amount is pre-encoded in Stargate send() calldata (no splice needed). * bridge.value = nativeFeeWithBuffer forwarded as LZ msg.value. - * Uses router.bridge() rather than performExecution / performModularExecution. + * Uses router.bridge() rather than performExecution / performActions. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts diff --git a/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts index 50c0083..b4256dc 100644 --- a/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts @@ -1,9 +1,11 @@ /** * Route: Polygon USDC → Base USDC (Stargate USDC Pool, no swap) - * Function: performExecution (monolithic) - * Fee: postFee — FEE_BPS of inputAmount USDC deducted; bridge amount position flag splices - * actual post-fee balance into amountLD at byte 196 of Stargate send() calldata. + * Function: bridge (simple bridge entrypoint) + * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge + * + * Bridge amount is pre-encoded in Stargate send() calldata (no splice needed). * bridge.value = nativeFeeWithBuffer forwarded as LZ msg.value. + * Uses router.bridge() rather than performExecution / performActions. * * Usage: * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts @@ -21,19 +23,11 @@ import { RPC, STARGATE_USDC_POLYGON, BASE_LZ_EID, - STARGATE_AMOUNT_LD_OFFSET, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; -import { - MonolithicExecutionCall, - NO_FEE, - NO_SWAP, - ZERO_BYTES32, - bridgeAmountPositionFlag, - monolithicArgs, -} from '../utils/contractTypes'; +import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; @@ -53,83 +47,112 @@ async function fetchStargateQuote( ): Promise<{ nativeFeeWithBuffer: bigint; amountReceivedLD: bigint }> { const contract = new ethers.Contract(STARGATE_USDC_POLYGON, STARGATE_ABI, provider); const to32 = ethers.zeroPadValue(recipient, 32); - const sendParam = { dstEid: BASE_LZ_EID, to: to32, amountLD: bridgeAmountLD, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }; - const [fee, oft] = await Promise.all([contract.quoteSend(sendParam, false), contract.quoteOFT(sendParam)]); + const sendParam = { + dstEid: BASE_LZ_EID, + to: to32, + amountLD: bridgeAmountLD, + minAmountLD: 0n, + extraOptions: '0x', + composeMsg: '0x', + oftCmd: '0x', + }; + const [fee, oft] = await Promise.all([ + contract.quoteSend(sendParam, false), + contract.quoteOFT(sendParam), + ]); return { nativeFeeWithBuffer: ((fee.nativeFee as bigint) * 105n) / 100n, amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, }; } -function buildStargateCalldata(nativeFee: bigint, recipient: string): string { - // amountLD = 0 placeholder; router splices actual balance at STARGATE_AMOUNT_LD_OFFSET +/** + * Builds Stargate send() calldata with the exact bridgeAmount pre-encoded (no 0 placeholder). + * Used by router.bridge() which has no splice mechanism. + */ +function buildStargateCalldata(bridgeAmount: bigint, nativeFee: bigint, recipient: string): string { return STARGATE_IFACE.encodeFunctionData('send', [ - { dstEid: BASE_LZ_EID, to: ethers.zeroPadValue(recipient, 32), amountLD: 0n, minAmountLD: 0n, extraOptions: '0x', composeMsg: '0x', oftCmd: '0x' }, + { + dstEid: BASE_LZ_EID, + to: ethers.zeroPadValue(recipient, 32), + amountLD: bridgeAmount, + minAmountLD: 0n, + extraOptions: '0x', + composeMsg: '0x', + oftCmd: '0x', + }, { nativeFee, lzTokenFee: 0n }, recipient, ]); } -async function main() { +async function main(): Promise { const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) throw new Error('PRIVATE_KEY env var required'); + if (!privateKey) { + throw new Error('PRIVATE_KEY env var required'); + } const provider = new ethers.JsonRpcProvider(RPC.POLYGON); const signer = new ethers.Wallet(privateKey, provider); const signerAddress = await signer.getAddress(); - const { balance: walletBalance } = await getWalletErc20Balance(TOKENS.USDC_POLYGON_CIRCLE, signerAddress, provider); - if (walletBalance === 0n) throw new Error(`Signer ${signerAddress} has zero USDC on Polygon`); + const inputToken = TOKENS.USDC_POLYGON_CIRCLE; + const { balance: walletBalance } = await getWalletErc20Balance(inputToken, signerAddress, provider); + if (walletBalance === 0n) { + throw new Error(`Signer ${signerAddress} has zero USDC on Polygon`); + } const inputAmount = walletBalance - 20n; if (inputAmount === 0n) throw new Error('Balance too small'); const feeAmount = bpsOf(inputAmount, FEE_BPS); - const estimatedBridgeAmount = inputAmount - feeAmount; + const bridgeAmount = inputAmount - feeAmount; - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_POLYGON}`); - console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); - console.log(`Post-fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); - console.log(`Est. bridge: ${ethers.formatUnits(estimatedBridgeAmount, 6)} USDC`); - - const routerIface = new ethers.Interface(ROUTER_ABI); + console.log(`Signer: ${signerAddress}`); + console.log(`Router: ${ROUTER_POLYGON}`); + console.log(`USDC balance: ${ethers.formatUnits(walletBalance, 6)}`); + console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); + console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); console.log('Fetching Stargate quote (Polygon → Base USDC pool)...'); - const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, estimatedBridgeAmount, signerAddress); - console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); + const { nativeFeeWithBuffer, amountReceivedLD } = await fetchStargateQuote(provider, bridgeAmount, signerAddress); + console.log(` nativeFee+5%: ${ethers.formatEther(nativeFeeWithBuffer)} POL`); console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDC`); - await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, STARGATE_USDC_POLYGON); - - const stargateData = buildStargateCalldata(nativeFeeWithBuffer, signerAddress); + const sendData = buildStargateCalldata(bridgeAmount, nativeFeeWithBuffer, signerAddress); - const mono: MonolithicExecutionCall = { - exec: { - input: { user: signerAddress, inputToken: TOKENS.USDC_POLYGON_CIRCLE, inputAmount }, - preFee: NO_FEE, - swap: NO_SWAP, - postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { target: STARGATE_USDC_POLYGON, approvalSpender: STARGATE_USDC_POLYGON, value: nativeFeeWithBuffer }, - flags: bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), - }, - swapCallData: '0x', - bridgeCallData: stargateData, + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeData: BridgeData = { + target: STARGATE_USDC_POLYGON, + approvalSpender: STARGATE_USDC_POLYGON, + value: nativeFeeWithBuffer, }; - const callData = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono, ZERO_BYTES32)); - - await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); - const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); - - logTxnSummary( - 'Polygon USDC → Base USDC (Stargate USDC pool) — performExecution postFee', - CHAIN_IDS.POLYGON, - receipt, + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, sendData]); + + await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); + await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, STARGATE_USDC_POLYGON); + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + console.log('Sending AllowanceHolder.exec → router.bridge...'); + const receipt = await execViaAH( + signer, + ROUTER_POLYGON, + inputToken, + inputAmount, + ROUTER_POLYGON, + execCalldata, + nativeFeeWithBuffer, ); + logTxnSummary('Polygon USDC → Base USDC (Stargate USDC pool) — bridge preFee', CHAIN_IDS.POLYGON, receipt); + console.log('\nUSDC arrives on Base once LZ delivers the message.'); } -main().catch((err) => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts index 7ecf31e..1a5e457 100644 --- a/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts @@ -1,6 +1,6 @@ /** * Route: Polygon USDC → Base USDC (Stargate USDC Pool, no swap) - * Function: performModularExecution (modular) + * Function: performActions (modular) * Fee: postFee — FEE_BPS of inputAmount USDC transferred to signer; staticCall balance spliced * into Stargate amountLD at STARGATE_AMOUNT_LD_OFFSET (byte 196). * @@ -12,7 +12,7 @@ * [4] nativeCall(Stargate, sendData, nativeFeeWithBuffer) — splicePayloadWord(196) from [3] * * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts + * PRIVATE_KEY=0x... ts-node scripts/e2e/stargate/polygonUsdcBase.performActions.postFee.ts */ import { ethers } from 'ethers'; import * as dotenv from 'dotenv'; @@ -116,13 +116,13 @@ async function main() { exec.nativeCall(STARGATE_USDC_POLYGON, stargateData, nativeFeeWithBuffer) .splicePayloadWord(BigInt(STARGATE_AMOUNT_LD_OFFSET), usdcBalance.returnWord()); - const callData = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, exec.toActions()]); + const callData = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_POLYGON_CIRCLE, inputAmount); const receipt = await execViaAH(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, inputAmount, ROUTER_POLYGON, callData, nativeFeeWithBuffer); logTxnSummary( - 'Polygon USDC → Base USDC (Stargate USDC pool) — performModularExecution postFee', + 'Polygon USDC → Base USDC (Stargate USDC pool) — performActions postFee', CHAIN_IDS.POLYGON, receipt, ); diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts index 46c549f..0b3a295 100644 --- a/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts +++ b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts @@ -41,6 +41,7 @@ import { BRIDGE_VALUE_FLAG, ZERO_ADDRESS, bridgeAmountPositionFlag, + swapAndBridgeArgs, } from "../utils/contractTypes"; import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; import { logTxnSummary } from "../utils/txnLogSummary"; @@ -209,14 +210,14 @@ async function main() { amountLD ); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.USDC_BASE, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -233,7 +234,7 @@ async function main() { value: nativeFeeWithBuffer, }, stargateData, - ]); + )); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); const receipt = await execViaAH( diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts index 6ccdd7e..10f84e2 100644 --- a/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts +++ b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts @@ -41,6 +41,7 @@ import { BRIDGE_VALUE_FLAG, ZERO_ADDRESS, bridgeAmountPositionFlag, + swapAndBridgeArgs, } from "../utils/contractTypes"; import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; import { logTxnSummary } from "../utils/txnLogSummary"; @@ -210,14 +211,14 @@ async function main() { amountLD ); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.USDC_BASE, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -234,7 +235,7 @@ async function main() { value: nativeFeeWithBuffer, }, stargateData, - ]); + )); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); const receipt = await execViaAH( diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts index 8addf9b..00e5659 100644 --- a/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts +++ b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts @@ -41,6 +41,7 @@ import { BRIDGE_VALUE_FLAG, ZERO_ADDRESS, bridgeAmountPositionFlag, + swapAndBridgeArgs, } from "../utils/contractTypes"; import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; import { logTxnSummary } from "../utils/txnLogSummary"; @@ -209,14 +210,14 @@ async function main() { amountLD ); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.USDC_BASE, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -233,7 +234,7 @@ async function main() { value: nativeFeeWithBuffer, }, stargateData, - ]); + )); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); const receipt = await execViaAH( diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts index 0a2ee3a..2324ea5 100644 --- a/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts +++ b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts @@ -41,6 +41,7 @@ import { BRIDGE_VALUE_FLAG, ZERO_ADDRESS, bridgeAmountPositionFlag, + swapAndBridgeArgs, } from "../utils/contractTypes"; import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; import { logTxnSummary } from "../utils/txnLogSummary"; @@ -209,14 +210,14 @@ async function main() { amountLD ); - const callData = routerIface.encodeFunctionData("swapAndBridge", [ + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.USDC_BASE, inputAmount: inputAmount, }, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -233,7 +234,7 @@ async function main() { value: nativeFeeWithBuffer, }, stargateData, - ]); + )); await ensureAllowanceForAllowanceHolder(signer, TOKENS.USDC_BASE, inputAmount); const receipt = await execViaAH( diff --git a/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts b/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts index cc4ccb4..570c08e 100644 --- a/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts +++ b/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts @@ -33,7 +33,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -183,15 +183,14 @@ async function main() { ksRouter, ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, @@ -202,7 +201,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/kyberswap.postFee.returndata.ts b/scripts/e2e/swap/kyberswap.postFee.returndata.ts index 06e35dd..6da3af8 100644 --- a/scripts/e2e/swap/kyberswap.postFee.returndata.ts +++ b/scripts/e2e/swap/kyberswap.postFee.returndata.ts @@ -34,7 +34,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -178,15 +178,14 @@ async function main() { ksRouter, ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, @@ -197,7 +196,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts b/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts index 1043d72..9f9312d 100644 --- a/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts +++ b/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts @@ -35,7 +35,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -189,15 +189,14 @@ async function main() { ksRouter, ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, @@ -208,7 +207,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/kyberswap.preFee.returndata.ts b/scripts/e2e/swap/kyberswap.preFee.returndata.ts index 95df823..59c9302 100644 --- a/scripts/e2e/swap/kyberswap.preFee.returndata.ts +++ b/scripts/e2e/swap/kyberswap.preFee.returndata.ts @@ -36,7 +36,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -185,15 +185,14 @@ async function main() { ksRouter, ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, @@ -204,7 +203,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/swap.postFee.balanceOf.ts b/scripts/e2e/swap/swap.postFee.balanceOf.ts index 7faf26d..06e8377 100644 --- a/scripts/e2e/swap/swap.postFee.balanceOf.ts +++ b/scripts/e2e/swap/swap.postFee.balanceOf.ts @@ -31,7 +31,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -125,15 +125,14 @@ async function main() { ooRouter ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -144,7 +143,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/swap.postFee.returndata.ts b/scripts/e2e/swap/swap.postFee.returndata.ts index 01eb4b7..9fa27fa 100644 --- a/scripts/e2e/swap/swap.postFee.returndata.ts +++ b/scripts/e2e/swap/swap.postFee.returndata.ts @@ -31,7 +31,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -120,15 +120,14 @@ async function main() { ooRouter ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -139,7 +138,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/swap.preFee.balanceOf.ts b/scripts/e2e/swap/swap.preFee.balanceOf.ts index d21c98b..4e4c5c0 100644 --- a/scripts/e2e/swap/swap.preFee.balanceOf.ts +++ b/scripts/e2e/swap/swap.preFee.balanceOf.ts @@ -31,7 +31,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -125,15 +125,14 @@ async function main() { ooRouter ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -144,7 +143,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/swap.preFee.returndata.ts b/scripts/e2e/swap/swap.preFee.returndata.ts index 7de1155..bd38078 100644 --- a/scripts/e2e/swap/swap.preFee.returndata.ts +++ b/scripts/e2e/swap/swap.preFee.returndata.ts @@ -31,7 +31,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -120,15 +120,14 @@ async function main() { ooRouter ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, @@ -139,7 +138,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/zerox.postFee.balanceOf.ts b/scripts/e2e/swap/zerox.postFee.balanceOf.ts index 593ef82..0e1e7e0 100644 --- a/scripts/e2e/swap/zerox.postFee.balanceOf.ts +++ b/scripts/e2e/swap/zerox.postFee.balanceOf.ts @@ -36,7 +36,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -179,15 +179,14 @@ async function main() { approvalSpender, ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: swapTarget, @@ -198,7 +197,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/zerox.postFee.returndata.ts b/scripts/e2e/swap/zerox.postFee.returndata.ts index 3f88890..12253ef 100644 --- a/scripts/e2e/swap/zerox.postFee.returndata.ts +++ b/scripts/e2e/swap/zerox.postFee.returndata.ts @@ -35,7 +35,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -168,15 +168,14 @@ async function main() { approvalSpender, ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: swapTarget, @@ -187,7 +186,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/zerox.preFee.balanceOf.ts b/scripts/e2e/swap/zerox.preFee.balanceOf.ts index ca37956..75b18a1 100644 --- a/scripts/e2e/swap/zerox.preFee.balanceOf.ts +++ b/scripts/e2e/swap/zerox.preFee.balanceOf.ts @@ -39,7 +39,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -186,15 +186,14 @@ async function main() { approvalSpender, ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: swapTarget, @@ -205,7 +204,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swap/zerox.preFee.returndata.ts b/scripts/e2e/swap/zerox.preFee.returndata.ts index 2ca1b56..dfa9057 100644 --- a/scripts/e2e/swap/zerox.preFee.returndata.ts +++ b/scripts/e2e/swap/zerox.preFee.returndata.ts @@ -37,7 +37,7 @@ import { } from "../utils/allowanceHolder"; import { getWalletErc20Balance } from "../utils/erc20"; import { ROUTER_ABI } from "../utils/routerAbi"; -import { ZERO_BYTES32 } from "../utils/contractTypes"; +import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, @@ -178,15 +178,14 @@ async function main() { approvalSpender, ); - const callData = routerIface.encodeFunctionData("swap", [ + const callData = routerIface.encodeFunctionData("swap", swapArgs( ZERO_BYTES32, + FLAGS, { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount: inputAmount, }, - signerAddress, - FLAGS, { receiver: signerAddress, amount: feeAmount }, { target: swapTarget, @@ -197,7 +196,8 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - ]); + signerAddress, + )); await ensureAllowanceForAllowanceHolder( signer, diff --git a/scripts/e2e/swapBridgeViaArbitrumNative.ts b/scripts/e2e/swapBridgeViaArbitrumNative.ts deleted file mode 100644 index 1dce431..0000000 --- a/scripts/e2e/swapBridgeViaArbitrumNative.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * Arbitrum bridge e2e script — AAVE (Ethereum) → ETH (OO swap) → Arbitrum ETH (depositEth) - * - * Flow: - * 1. Fetch an OpenOcean swap quote for AAVE → ETH on Ethereum mainnet (router is sender). - * 2. Estimate the Arbitrum retryable submission fee so we know the minimum ETH required - * to bridge. A conservative fallback of 0.001 ETH is used if estimation fails. - * 3. Split the signer's AAVE balance in half and run two legs back-to-back: - * Leg 1 MONOLITHIC — single `performExecution` call - * Leg 2 MODULAR — `performModularExecution` call (3-second pause before) - * - * Monolithic mechanics: - * - Pull inputAmount AAVE via AH.exec grant, approve OO router, swap AAVE → ETH. - * - Post-swap fee (FEE_BPS) in ETH sent to signer. - * - BRIDGE_VALUE_FLAG set: router forwards actualFinalETH as msg.value to inbox. - * - No ETH splice needed (depositEth takes no calldata amount param). - * - * Modular mechanics: - * [0] AH.transferFrom AAVE → router (uses ephemeral AH grant) - * [1] AAVE.approve(ooRouter, inputAmount) - * [2] OO swap AAVE → ETH (lands in router) - * [3] nativeCall(signer, '0x', feeAmount) — ETH fee out - * [4] nativeCall(inbox, depositEth(), bridgeValue) - * - * Input is always AAVE (ERC-20) so `direct` router txs are rejected — the router's - * `_pullFromUser` requires the ephemeral allowance set by AllowanceHolder.exec. - * - * Exec mode (argv[1] or ARB_ROUTER_EXEC env): - * allowance-holder (default) — wrap via AllowanceHolder.exec - * direct — rejected for ERC-20 input with a clear error - * - * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaArbitrumNative.ts - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaArbitrumNative.ts allowance-holder - * ARB_ROUTER_EXEC=allowance-holder PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaArbitrumNative.ts - * - * Router on Ethereum mainnet: set `ROUTER_CHAIN_1` env or legacy `ROUTER_ADDRESS`. - */ -import axios from 'axios'; -import { ethers } from 'ethers'; -import * as dotenv from 'dotenv'; -dotenv.config(); - -import { - CHAIN_IDS, - routerAddressForChain, - TOKENS, - ARBITRUM_INBOX, - FEE_BPS, - bpsOf, - RPC, - OPEN_OCEAN_API_KEY, - OO_SLIPPAGE_PERCENT, - ALLOWANCE_HOLDER, - NATIVE_TOKEN_ADDRESS, -} from './config'; -import { - execViaAH, - execDirect, - ensureAllowanceForAllowanceHolder, -} from './utils/allowanceHolder'; -import { encodeApprove, getWalletErc20Balance } from './utils/erc20'; -import { ROUTER_ABI } from './utils/routerAbi'; -import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; -import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { - BRIDGE_VALUE_FLAG, - MonolithicExecutionCall, - NO_FEE, - ZERO_ADDRESS, - ZERO_BYTES32, - monolithicArgs, -} from './utils/contractTypes'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; -import { - ensureRouterErc20Balance, - ensureRouterNativeBalance, - ensureRouterApproval, -} from './utils/reproducibility'; - -// ─── Exec-mode selection ────────────────────────────────────────────────────── - -/** How the signer reaches the router. */ -type RouterExecRoute = 'direct' | 'allowance-holder'; - -const EXEC_ALIASES: Record = { - direct: 'direct', - dr: 'direct', - router: 'direct', - 'allowance-holder': 'allowance-holder', - ah: 'allowance-holder', - exec: 'allowance-holder', -}; - -/** - * Resolves exec route from `argv[1]` (overrides) or `ARB_ROUTER_EXEC` env. - * Defaults to `allowance-holder` since AAVE is ERC-20. - * `direct` is rejected with a clear error because `_pullFromUser` requires AH. - */ -function resolveRouterExecRoute(): RouterExecRoute { - const rawArg = typeof process.argv[2] === 'string' ? process.argv[2].trim().toLowerCase() : ''; - const rawEnv = (process.env.ARB_ROUTER_EXEC ?? '').trim().toLowerCase(); - const raw = rawArg || rawEnv; - - if (raw) { - const route = EXEC_ALIASES[raw]; - if (!route) { - console.error( - `Unknown exec mode "${raw}". Use argv[1] or ARB_ROUTER_EXEC: allowance-holder | direct (aliases ah, exec, dr, router).`, - ); - process.exit(1); - } - if (route === 'direct') { - console.error( - 'ERC-20 input (AAVE) cannot use direct router txs: `_pullFromUser` invokes AllowanceHolder.transferFrom, ' + - 'which requires the ephemeral allowance set by AH.exec. Use allowance-holder (default).', - ); - process.exit(1); - } - return route; - } - - return 'allowance-holder'; -} - -// ─── Arbitrum bridge fee estimation ────────────────────────────────────────── - -/** - * Estimates the minimum ETH required for the Arbitrum inbox submission fee. - * Falls back to 0.001 ETH if the SDK is unavailable or estimation fails. - */ -async function estimateArbitrumBridgeFee(ethereumProvider: ethers.Provider): Promise { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { ParentToChildMessageGasEstimator } = require('@arbitrum/sdk'); - const estimator = new ParentToChildMessageGasEstimator(ethereumProvider); - const l2GasPrice = (await new ethers.JsonRpcProvider(RPC.ARBITRUM).getFeeData()).gasPrice ?? 0n; - const submissionFee = await estimator.estimateSubmissionFee(ethereumProvider, 0n, 0n); - const executionCost = 250000n * (l2GasPrice + (l2GasPrice * 20n) / 100n); - const totalFee = BigInt(submissionFee.toString()) + executionCost; - console.log(` Estimated Arbitrum bridge fee: ${ethers.formatEther(totalFee)} ETH`); - return totalFee; - } catch (err) { - const fallback = ethers.parseEther('0.001'); - console.warn( - ` Arbitrum fee estimation failed (${(err as Error).message}), using fallback: ${ethers.formatEther(fallback)} ETH`, - ); - return fallback; - } -} - -// ─── OpenOcean quote ────────────────────────────────────────────────────────── - -interface OoSwapQuoteResponse { - data: { - to: string; - data: string; - value?: string; - outAmount: string; - minOutAmount: string; - }; -} - -/** - * Fetches an OpenOcean swap_quote for AAVE → ETH on Ethereum mainnet. - * Router address is used as sender and account so ETH output lands in the router. - */ -async function fetchOoQuote( - routerAddress: string, - inputAmount: bigint, -): Promise<{ - ooRouter: string; - swapData: string; - estimatedOut: bigint; - minAmountOut: bigint; -}> { - const params: Record = { - inTokenAddress: TOKENS.AAVE_ETH, - outTokenAddress: NATIVE_TOKEN_ADDRESS, - amount: ethers.formatUnits(inputAmount, 18), - slippage: OO_SLIPPAGE_PERCENT, - sender: routerAddress, - account: routerAddress, - gasPrice: '20', - }; - if (OPEN_OCEAN_API_KEY) { - params.apikey = OPEN_OCEAN_API_KEY; - } - const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.ETHEREUM}/swap_quote`; - const response = await axios.get(url, { params }); - const q = response.data.data; - return { - ooRouter: q.to, - swapData: q.data, - estimatedOut: BigInt(q.outAmount), - minAmountOut: BigInt(q.minOutAmount), - }; -} - -// ─── Calldata helpers ───────────────────────────────────────────────────────── - -/** Encodes Arbitrum inbox `depositEth()` — ETH amount is entirely in msg.value. */ -function buildDepositEthCalldata(): string { - return new ethers.Interface([ - 'function depositEth() external payable returns (uint256)', - ]).encodeFunctionData('depositEth', []); -} - -// ─── Monolithic builder ─────────────────────────────────────────────────────── - -/** - * AAVE → OO → ETH → Arbitrum inbox (monolithic): - * - input: AAVE pulled via AH - * - swap: AAVE → native ETH, BRIDGE_VALUE_FLAG forwards actualFinalETH - * - bridge: depositEth() — no amount in calldata, all ETH passed as msg.value - */ -function buildMonolithic( - signerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - minAmountOut: bigint, - ooRouter: string, - swapData: string, -): MonolithicExecutionCall { - return { - exec: { - input: { user: signerAddress, inputToken: TOKENS.AAVE_ETH, inputAmount }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ooRouter, - outputToken: NATIVE_TOKEN_ADDRESS, - value: 0n, - minOutput: minAmountOut, - returnDataWordOffset: 0n, - }, - postFee: { receiver: signerAddress, amount: feeAmount }, - bridge: { - target: ARBITRUM_INBOX, - approvalSpender: ZERO_ADDRESS, - value: 0n, // no addend: bridgeValue = finalETH + 0 = finalETH - }, - flags: BRIDGE_VALUE_FLAG, - }, - swapCallData: swapData, - bridgeCallData: buildDepositEthCalldata(), - }; -} - -// ─── Modular builder ────────────────────────────────────────────────────────── - -/** - * AAVE → OO → ETH → Arbitrum inbox (modular): - * [0] AH.transferFrom(AAVE, signer, router, inputAmount) - * [1] AAVE.approve(ooRouter, inputAmount) - * [2] call(ooRouter, swapData) — AAVE → ETH, ETH lands in router - * [3] nativeCall(signer, '0x', feeAmount) - * [4] nativeCall(inbox, depositEthData, bridgeValue) - */ -function buildModularActions( - signerAddress: string, - routerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - bridgeValue: bigint, - ooRouter: string, - swapData: string, -): ModularAction[] { - const ahIface = new ethers.Interface([ - 'function transferFrom(address token, address owner, address recipient, uint256 amount)', - ]); - const exec = new ModularActionsBuilder(); - - exec.call( - ALLOWANCE_HOLDER, - ahIface.encodeFunctionData('transferFrom', [TOKENS.AAVE_ETH, signerAddress, routerAddress, inputAmount]), - ); - exec.call(TOKENS.AAVE_ETH, encodeApprove(ooRouter, inputAmount)); - exec.call(ooRouter, swapData); - exec.nativeCall(signerAddress, '0x', feeAmount); - exec.nativeCall(ARBITRUM_INBOX, buildDepositEthCalldata(), bridgeValue); - - return exec.toActions(); -} - -// ─── Execution leg ──────────────────────────────────────────────────────────── - -/** - * Runs one monolithic or modular leg: fetches OO quote + arb fee, builds calldata, - * dispatches via AllowanceHolder.exec (msg.value=0 since input is AAVE). - */ -async function executeLeg( - legLabel: string, - useModular: boolean, - routerAddress: string, - signer: ethers.Wallet, - signerAddress: string, - provider: ethers.JsonRpcProvider, - inputAmount: bigint, - routerExec: RouterExecRoute, - routerIface: ethers.Interface, -): Promise { - console.log(`\n── ${legLabel} (${useModular ? 'MODULAR' : 'MONOLITHIC'}) ──`); - - console.log('Fetching OpenOcean quote (AAVE → ETH)...'); - const { ooRouter, swapData, estimatedOut, minAmountOut } = await fetchOoQuote( - routerAddress, - inputAmount, - ); - const feeAmount = bpsOf(estimatedOut, FEE_BPS); - - console.log(` OO router: ${ooRouter}`); - console.log(` Est. ETH out: ${ethers.formatEther(estimatedOut)} ETH`); - console.log(` Fee: ${ethers.formatEther(feeAmount)} ETH (${FEE_BPS} bps)`); - console.log(` Min ETH out: ${ethers.formatEther(minAmountOut)} ETH`); - - await ensureRouterErc20Balance(signer, TOKENS.AAVE_ETH, routerAddress); - await ensureRouterNativeBalance(signer, routerAddress); - await ensureRouterApproval(signer, routerAddress, TOKENS.AAVE_ETH, ooRouter); - - const arbFee = await estimateArbitrumBridgeFee(provider); - const minEthRequired = feeAmount + arbFee; - if (estimatedOut < minEthRequired) { - console.warn( - ` Warning: est. ETH out (${ethers.formatEther(estimatedOut)}) may be insufficient ` + - `to cover fee + bridge cost (${ethers.formatEther(minEthRequired)}).`, - ); - } - - // bridgeValue = everything left after the fee; use minAmountOut-based floor so - // the modular nativeCall carries at least as much ETH as the inbox requires. - const bridgeValue = minAmountOut > feeAmount ? minAmountOut - feeAmount : 0n; - console.log(` Bridge value: ${ethers.formatEther(bridgeValue)} ETH (floor for nativeCall)`); - - let execCalldata: string; - if (useModular) { - const actions = buildModularActions( - signerAddress, - routerAddress, - inputAmount, - feeAmount, - bridgeValue, - ooRouter, - swapData, - ); - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, actions]); - } else { - const mono = buildMonolithic(signerAddress, inputAmount, feeAmount, minAmountOut, ooRouter, swapData); - execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono)); - } - - // Input is AAVE (ERC-20) — msg.value is always 0; ETH comes from the swap output. - const txValue = 0n; - - let receipt: ethers.TransactionReceipt; - if (routerExec === 'direct') { - // Guarded at startup — should never reach here for ERC-20 input. - console.log(`[exec=direct] value=0 ETH`); - receipt = await execDirect(signer, routerAddress, execCalldata, txValue); - } else { - await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_ETH, inputAmount); - console.log(`[exec=allowance-holder] value=0 ETH`); - receipt = await execViaAH( - signer, - routerAddress, - TOKENS.AAVE_ETH, - inputAmount, - routerAddress, - execCalldata, - txValue, - ); - } - - logTxnSummary( - `AAVE → ETH → Arbitrum — ${useModular ? 'Modular' : 'Monolithic'}`, - CHAIN_IDS.ETHEREUM, - receipt, - ); -} - -// ─── Main ───────────────────────────────────────────────────────────────────── - -async function main(): Promise { - const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) { - throw new Error('PRIVATE_KEY env var required'); - } - - const routerExec = resolveRouterExecRoute(); - const routerAddress = routerAddressForChain(CHAIN_IDS.ETHEREUM); - - const provider = new ethers.JsonRpcProvider(RPC.ETHEREUM); - const signer = new ethers.Wallet(privateKey, provider); - const signerAddress = await signer.getAddress(); - - const { balance: fullBalance, decimals } = await getWalletErc20Balance( - TOKENS.AAVE_ETH, - signerAddress, - provider, - ); - if (fullBalance === 0n) { - throw new Error( - `Signer ${signerAddress} has zero AAVE on Ethereum. Fund the wallet first.`, - ); - } - - const legAmount = (fullBalance - 20n) / 2n; - if (legAmount === 0n) { - throw new Error('AAVE balance too small to split into two legs.'); - } - - const routerIface = new ethers.Interface(ROUTER_ABI); - - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${routerAddress}`); - console.log(`Input token: ${TOKENS.AAVE_ETH} (AAVE Ethereum)`); - console.log(`Balance: ${ethers.formatUnits(fullBalance, decimals)} AAVE`); - console.log(`Per leg (½): ${ethers.formatUnits(legAmount, decimals)} AAVE`); - console.log(`Exec route: ${routerExec}`); - - await executeLeg('1/2', false, routerAddress, signer, signerAddress, provider, legAmount, routerExec, routerIface); - - console.log('\nSleeping 3s before modular leg...'); - await sleep(3000); - - await executeLeg('2/2', true, routerAddress, signer, signerAddress, provider, legAmount, routerExec, routerIface); - - console.log('\n✓ Arbitrum bridge case completed.'); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/e2e/swapBridgeViaCctp.ts b/scripts/e2e/swapBridgeViaCctp.ts deleted file mode 100644 index c55af37..0000000 --- a/scripts/e2e/swapBridgeViaCctp.ts +++ /dev/null @@ -1,556 +0,0 @@ -/** - * Script 2 — Swap AAVE→USDC on Polygon, then bridge USDC to Base via CCTP v2 - * - * OpenOcean must output Circle’s **native** Polygon USDC (`USDC_POLYGON_CIRCLE`). - * Bridged USDC (`0x2791…`, USDC.e) is rejected by TokenMessenger (“Burn token not supported”). - * - * Each run uses half of the initial AAVE snapshot: monolithic then modular. - * - * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaCctp.ts - * Polygon native USDC → Base USDC via CCTP only (no OpenOcean swap): - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaCctp.ts usdc-polygon-base - * - * Router on Polygon: {@link ROUTER_BY_CHAIN_ID} / `routerAddressForChain(137)`. - */ -import axios from 'axios'; -import { ethers } from 'ethers'; -import * as dotenv from 'dotenv'; -dotenv.config(); - -import { - CHAIN_IDS, - routerAddressForChain, - TOKENS, - CCTP_CONFIG, - FEE_BPS, - bpsOf, - RPC, - OPEN_OCEAN_API_KEY, - OO_SLIPPAGE_PERCENT, - ALLOWANCE_HOLDER, -} from './config'; -import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; -import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from './utils/erc20'; -import { ROUTER_ABI } from './utils/routerAbi'; -import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; -import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { - MonolithicExecutionCall, - NO_FEE, - NO_SWAP, - ZERO_BYTES32, - bridgeAmountPositionFlag, - monolithicArgs, -} from './utils/contractTypes'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; -import { - ensureRouterErc20Balance, - ensureRouterApproval, -} from './utils/reproducibility'; - -const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); - -interface OpenOceanSwapQuoteResponse { - data: { - to: string; - data: string; - value: string; - estimatedGas: string; - outAmount: string; - minOutAmount: string; - }; -} - -async function fetchOpenOceanSwapQuote( - routerAddress: string, - inputAmount: bigint, -): Promise<{ - routerAddress: string; - swapData: string; - minAmountOut: bigint; - estimatedOut: bigint; -}> { - const params: Record = { - inTokenAddress: TOKENS.AAVE_POLYGON, - outTokenAddress: TOKENS.USDC_POLYGON_CIRCLE, - amount: ethers.formatUnits(inputAmount, 18), - slippage: OO_SLIPPAGE_PERCENT, - sender: routerAddress, - account: routerAddress, - gasPrice: '1', - }; - if (OPEN_OCEAN_API_KEY) { - params.apikey = OPEN_OCEAN_API_KEY; - } - - const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; - const response = await axios.get(url, { params }); - const q = response.data.data; - - return { - routerAddress: q.to, - swapData: q.data, - minAmountOut: BigInt(q.minOutAmount), - estimatedOut: BigInt(q.outAmount), - }; -} - -function buildDepositForBurnCalldata( - recipientAddress: string, - burnToken: string, - destinationCctpDomain: number, - fastPath: boolean = true, -): string { - const iface = new ethers.Interface([ - 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external', - ]); - - const mintRecipient = ethers.zeroPadValue(recipientAddress, 32); - const maxFee = fastPath ? 1_000_000n : 0n; - const minFinalityThreshold = fastPath ? 1000 : 2000; - - return iface.encodeFunctionData('depositForBurn', [ - 0n, - destinationCctpDomain, - mintRecipient, - burnToken, - ethers.ZeroHash, - maxFee, - minFinalityThreshold, - ]); -} - -function buildMonolithicExecution( - signerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - minAmountOut: bigint, - ooRouterAddress: string, - swapData: string, - depositForBurnData: string, - tokenMessenger: string, -): MonolithicExecutionCall { - return { - exec: { - input: { - user: signerAddress, - inputToken: TOKENS.AAVE_POLYGON, - inputAmount, - }, - preFee: NO_FEE, - swap: { - target: ooRouterAddress, - approvalSpender: ooRouterAddress, - outputToken: TOKENS.USDC_POLYGON_CIRCLE, - value: 0n, - minOutput: minAmountOut, - returnDataWordOffset: 0n, - }, - postFee: { - receiver: signerAddress, - amount: feeAmount, - }, - bridge: { - target: tokenMessenger, - approvalSpender: tokenMessenger, - value: 0n, - }, - flags: bridgeAmountPositionFlag(4n), - }, - swapCallData: swapData, - bridgeCallData: depositForBurnData, - }; -} - -function buildModularActions( - signerAddress: string, - routerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - ooRouterAddress: string, - swapData: string, - depositForBurnData: string, - tokenMessenger: string, -): ModularAction[] { - const ahIface = new ethers.Interface([ - 'function transferFrom(address token, address owner, address recipient, uint256 amount)', - ]); - const ahTransferFromData = ahIface.encodeFunctionData('transferFrom', [ - TOKENS.AAVE_POLYGON, - signerAddress, - routerAddress, - inputAmount, - ]); - - const exec = new ModularActionsBuilder(); - exec.call(ALLOWANCE_HOLDER, ahTransferFromData); - exec.call(TOKENS.AAVE_POLYGON, encodeApprove(ooRouterAddress, inputAmount)); - exec.call(ooRouterAddress, swapData); - exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeApprove(tokenMessenger, ethers.MaxUint256)); - const balance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(routerAddress)); - exec.call(tokenMessenger, depositForBurnData).spliceArg(0, balance.returnWord()); - return exec.toActions(); -} - -function buildMonolithicExecutionUsdcPolygonToBaseCctp( - signerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - depositForBurnData: string, - tokenMessenger: string, -): MonolithicExecutionCall { - return { - exec: { - input: { - user: signerAddress, - inputToken: TOKENS.USDC_POLYGON_CIRCLE, - inputAmount, - }, - preFee: { - receiver: signerAddress, - amount: feeAmount, - }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { - target: tokenMessenger, - approvalSpender: tokenMessenger, - value: 0n, - }, - flags: bridgeAmountPositionFlag(4n), - }, - swapCallData: '0x', - bridgeCallData: depositForBurnData, - }; -} - -function buildModularActionsUsdcPolygonToBaseCctp( - signerAddress: string, - routerAddress: string, - inputAmount: bigint, - feeAmount: bigint, - depositForBurnData: string, - tokenMessenger: string, -): ModularAction[] { - const ahIface = new ethers.Interface([ - 'function transferFrom(address token, address owner, address recipient, uint256 amount)', - ]); - const ahTransferFromData = ahIface.encodeFunctionData('transferFrom', [ - TOKENS.USDC_POLYGON_CIRCLE, - signerAddress, - routerAddress, - inputAmount, - ]); - - const exec = new ModularActionsBuilder(); - exec.call(ALLOWANCE_HOLDER, ahTransferFromData); - exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeApprove(tokenMessenger, ethers.MaxUint256)); - const balance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(routerAddress)); - exec.call(tokenMessenger, depositForBurnData).spliceArg(0, balance.returnWord()); - return exec.toActions(); -} - -async function executeLegUsdcPolygonToBaseCctp(args: { - label: string; - useModular: boolean; - signer: ethers.Wallet; - signerAddress: string; - inputAmount: bigint; - routerIface: ethers.Interface; -}): Promise { - const { label, useModular, signer, signerAddress, inputAmount, routerIface } = args; - - console.log(`\n── ${label} (${useModular ? 'MODULAR' : 'MONOLITHIC'}) ──`); - - const feeAmount = bpsOf(inputAmount, FEE_BPS); - console.log(`Input USDC: ${ethers.formatUnits(inputAmount, 6)}`); - console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} (${FEE_BPS} bps)`); - console.log(`Net to bridge: ${ethers.formatUnits(inputAmount - feeAmount, 6)}`); - - const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; - const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; - console.log(`CCTP burn token: ${polyCctp.usdcAddress}`); - const depositForBurnData = buildDepositForBurnCalldata( - signerAddress, - polyCctp.usdcAddress, - baseCctp.cctpDomain, - true, - ); - - await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); - - let execCalldata: string; - if (useModular) { - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - ZERO_BYTES32, - buildModularActionsUsdcPolygonToBaseCctp( - signerAddress, - ROUTER_POLYGON, - inputAmount, - feeAmount, - depositForBurnData, - polyCctp.tokenMessenger, - ), - ]); - } else { - execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs( - buildMonolithicExecutionUsdcPolygonToBaseCctp( - signerAddress, - inputAmount, - feeAmount, - depositForBurnData, - polyCctp.tokenMessenger, - ), - )); - } - - await ensureAllowanceForAllowanceHolder( - signer, - TOKENS.USDC_POLYGON_CIRCLE, - inputAmount, - ); - const receipt = await execViaAH( - signer, - ROUTER_POLYGON, - TOKENS.USDC_POLYGON_CIRCLE, - inputAmount, - ROUTER_POLYGON, - execCalldata, - ); - - const modeLabel = useModular ? 'Modular' : 'Monolithic'; - logTxnSummary( - `Polygon USDC → Base USDC — CCTP — ${modeLabel}`, - CHAIN_IDS.POLYGON, - receipt, - ); -} - -async function executeLeg(args: { - label: string; - useModular: boolean; - signer: ethers.Wallet; - signerAddress: string; - inputAmount: bigint; - routerIface: ethers.Interface; -}): Promise { - const { label, useModular, signer, signerAddress, inputAmount, routerIface } = args; - - console.log(`\n── ${label} (${useModular ? 'MODULAR' : 'MONOLITHIC'}) ──`); - - console.log('Fetching OpenOcean swap quote (Polygon AAVE → Circle native USDC)...'); - const { - routerAddress: ooRouterAddress, - swapData, - minAmountOut, - estimatedOut, - } = await fetchOpenOceanSwapQuote(ROUTER_POLYGON, inputAmount); - - const feeAmount = bpsOf(estimatedOut, FEE_BPS); - console.log(`OO Router: ${ooRouterAddress}`); - console.log(`Est. USDC out: ${ethers.formatUnits(estimatedOut, 6)}`); - console.log(`Post-swap fee: ${ethers.formatUnits(feeAmount, 6)} (${FEE_BPS} bps)`); - console.log(`Min USDC out: ${ethers.formatUnits(minAmountOut, 6)}`); - - const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; - const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; - console.log(`CCTP burn token: ${polyCctp.usdcAddress} (must match swap output)`); - const depositForBurnData = buildDepositForBurnCalldata( - signerAddress, - polyCctp.usdcAddress, - baseCctp.cctpDomain, - true, - ); - - await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); - await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouterAddress); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); - - let execCalldata: string; - if (useModular) { - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - ZERO_BYTES32, - buildModularActions( - signerAddress, - ROUTER_POLYGON, - inputAmount, - feeAmount, - ooRouterAddress, - swapData, - depositForBurnData, - polyCctp.tokenMessenger, - ), - ]); - } else { - execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs( - buildMonolithicExecution( - signerAddress, - inputAmount, - feeAmount, - minAmountOut, - ooRouterAddress, - swapData, - depositForBurnData, - polyCctp.tokenMessenger, - ), - )); - } - - await ensureAllowanceForAllowanceHolder(signer, TOKENS.AAVE_POLYGON, inputAmount); - const receipt = await execViaAH( - signer, - ROUTER_POLYGON, - TOKENS.AAVE_POLYGON, - inputAmount, - ROUTER_POLYGON, - execCalldata, - ); - - const modeLabel = useModular ? 'Modular' : 'Monolithic'; - logTxnSummary( - `Polygon AAVE → Base USDC — OpenOcean + CCTP — ${modeLabel}`, - CHAIN_IDS.POLYGON, - receipt, - ); -} - -async function mainUsdcPolygonToBaseCctp() { - const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) { - throw new Error('PRIVATE_KEY env var required'); - } - - const provider = new ethers.JsonRpcProvider(RPC.POLYGON); - const signer = new ethers.Wallet(privateKey, provider); - const signerAddress = await signer.getAddress(); - - const inputToken = TOKENS.USDC_POLYGON_CIRCLE; - const { balance: walletBalance } = await getWalletErc20Balance( - inputToken, - signerAddress, - provider, - ); - if (walletBalance === 0n) { - throw new Error( - `Signer ${signerAddress} has zero Circle native USDC on Polygon. Fund ${inputToken} on Polygon PoS.`, - ); - } - - const legAmount = (walletBalance - 20n) / 2n; - if (legAmount === 0n) { - throw new Error( - `Balance ${walletBalance} too small for two nonzero 50% legs.`, - ); - } - - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_POLYGON}`); - console.log(`Polygon USDC: ${ethers.formatUnits(walletBalance, 6)} (full)`); - console.log(`Per leg input: ${ethers.formatUnits(legAmount, 6)} (50%)`); - - const routerIface = new ethers.Interface(ROUTER_ABI); - - await executeLegUsdcPolygonToBaseCctp({ - label: '1/2 Monolithic', - useModular: false, - signer, - signerAddress, - inputAmount: legAmount, - routerIface, - }); - - console.log('Sleeping ~3s before modular execution...'); - await sleep(3000); - - await executeLegUsdcPolygonToBaseCctp({ - label: '2/2 Modular', - useModular: true, - signer, - signerAddress, - inputAmount: legAmount, - routerIface, - }); - - console.log( - `\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`, - ); -} - -async function main() { - const cctpE2eCase = process.argv[2]?.toLowerCase(); - if (cctpE2eCase === 'usdc-polygon-base' || cctpE2eCase === 'usdc') { - await mainUsdcPolygonToBaseCctp(); - return; - } - - const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) { - throw new Error('PRIVATE_KEY env var required'); - } - - const provider = new ethers.JsonRpcProvider(RPC.POLYGON); - const signer = new ethers.Wallet(privateKey, provider); - const signerAddress = await signer.getAddress(); - - const inputToken = TOKENS.AAVE_POLYGON; - const { balance: walletBalance } = await getWalletErc20Balance( - inputToken, - signerAddress, - provider, - ); - if (walletBalance === 0n) { - throw new Error( - `Signer ${signerAddress} has zero Polygon AAVE. Fund ${inputToken} on Polygon PoS.`, - ); - } - - const legAmount = (walletBalance - 20n) / 2n; - if (legAmount === 0n) { - throw new Error( - `Balance ${walletBalance} too small for two nonzero 50% legs.`, - ); - } - - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_POLYGON}`); - console.log(`Polygon AAVE: ${ethers.formatUnits(walletBalance, 18)} (full)`); - console.log(`Per leg input: ${ethers.formatUnits(legAmount, 18)} (50%)`); - - const routerIface = new ethers.Interface(ROUTER_ABI); - - await executeLeg({ - label: '1/2 Monolithic', - useModular: false, - signer, - signerAddress, - inputAmount: legAmount, - routerIface, - }); - - console.log('Sleeping ~3s before modular execution...'); - await sleep(3000); - - await executeLeg({ - label: '2/2 Modular', - useModular: true, - signer, - signerAddress, - inputAmount: legAmount, - routerIface, - }); - - console.log( - `\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`, - ); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/e2e/swapBridgeViaCctpSimple.ts b/scripts/e2e/swapBridgeViaCctpSimple.ts deleted file mode 100644 index 5ffc4fb..0000000 --- a/scripts/e2e/swapBridgeViaCctpSimple.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Polygon native USDC → Base USDC via CCTP v2 using `router.bridge(...)`. - * - * Same burn token / TokenMessenger constraints as {@link swapBridgeViaCctp}: - * use Circle’s native Polygon USDC (`USDC_POLYGON_CIRCLE`); bridged USDC.e is unsupported. - * - * Unlike the monolithic/modular paths in `swapBridgeViaCctp.ts`, this script: - * – only supports USDC-in (no OpenOcean AAVE→USDC swap); - * – encodes the net `depositForBurn` amount in calldata up front (no splice); - * – uses a single `bridge` entrypoint per run (full wallet balance by default). - * - * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaCctpSimple.ts - * - * No pre-bridge fee: - * FEE_AMOUNT_BPS=0 PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaCctpSimple.ts - * - * Router: {@link ROUTER_BY_CHAIN_ID} / `routerAddressForChain(137)` in config.ts. - */ -import { ethers } from 'ethers'; -import * as dotenv from 'dotenv'; -dotenv.config(); - -import { - CHAIN_IDS, - routerAddressForChain, - TOKENS, - CCTP_CONFIG, - FEE_BPS, - bpsOf, - RPC, -} from './config'; -import { execViaAH, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; -import { getWalletErc20Balance } from './utils/erc20'; -import { ROUTER_ABI } from './utils/routerAbi'; -import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from './utils/contractTypes'; -import { logTxnSummary } from './utils/txnLogSummary'; -import { - ensureRouterErc20Balance, - ensureRouterApproval, -} from './utils/reproducibility'; - -const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); - -/** - * CCTP `depositForBurn` with explicit burn amount (net after optional fee). - */ -function buildDepositForBurnCalldata( - recipientAddress: string, - burnToken: string, - destinationCctpDomain: number, - amount: bigint, - fastPath: boolean = true, -): string { - const iface = new ethers.Interface([ - 'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold) external', - ]); - - const mintRecipient = ethers.zeroPadValue(recipientAddress, 32); - const maxFee = fastPath ? 1_000_000n : 0n; - const minFinalityThreshold = fastPath ? 1000 : 2000; - - return iface.encodeFunctionData('depositForBurn', [ - amount, - destinationCctpDomain, - mintRecipient, - burnToken, - ethers.ZeroHash, - maxFee, - minFinalityThreshold, - ]); -} - -function buildBridgeCalldata( - routerIface: ethers.Interface, - args: { - signerAddress: string; - inputToken: string; - inputAmount: bigint; - fee: FeeData; - tokenMessenger: string; - depositData: string; - }, -): string { - const input: InputData = { - user: args.signerAddress, - inputToken: args.inputToken, - inputAmount: args.inputAmount, - }; - - const bridgeData: BridgeData = { - target: args.tokenMessenger, - approvalSpender: args.tokenMessenger, - value: 0n, - }; - - return routerIface.encodeFunctionData('bridge', [ - ZERO_BYTES32, - input, - args.fee, - bridgeData, - args.depositData, - ]); -} - -async function main(): Promise { - const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) { - throw new Error('PRIVATE_KEY env var required'); - } - - const provider = new ethers.JsonRpcProvider(RPC.POLYGON); - const signer = new ethers.Wallet(privateKey, provider); - const signerAddress = await signer.getAddress(); - - const inputToken = TOKENS.USDC_POLYGON_CIRCLE; - const { balance: walletBalance } = await getWalletErc20Balance( - inputToken, - signerAddress, - provider, - ); - if (walletBalance === 0n) { - throw new Error( - `Signer ${signerAddress} has zero Circle native USDC on Polygon. Fund ${inputToken} on Polygon PoS.`, - ); - } - - const inputAmount = walletBalance - 20n; - const feeAmount = bpsOf(inputAmount, FEE_BPS); - const bridgeAmount = inputAmount - feeAmount; - - const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; - const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; - - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_POLYGON}`); - console.log(`Input USDC: ${ethers.formatUnits(inputAmount, 6)}`); - console.log(`Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} (${FEE_BPS} bps)`); - console.log(`Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); - console.log(`TokenMessenger: ${polyCctp.tokenMessenger}`); - console.log(`Burn token: ${polyCctp.usdcAddress}`); - - const depositData = buildDepositForBurnCalldata( - signerAddress, - polyCctp.usdcAddress, - baseCctp.cctpDomain, - bridgeAmount, - true, - ); - - const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; - const routerIface = new ethers.Interface(ROUTER_ABI); - const execCalldata = buildBridgeCalldata(routerIface, { - signerAddress, - inputToken, - inputAmount, - fee, - tokenMessenger: polyCctp.tokenMessenger, - depositData, - }); - - await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, polyCctp.tokenMessenger); - await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); - - console.log('Sending AllowanceHolder.exec → router.bridge...'); - const receipt = await execViaAH( - signer, - ROUTER_POLYGON, - inputToken, - inputAmount, - ROUTER_POLYGON, - execCalldata, - ); - - logTxnSummary( - 'Polygon USDC → Base USDC — CCTP — Simple bridge', - CHAIN_IDS.POLYGON, - receipt, - ); - - console.log( - `\nUSDC mints on Base at ${signerAddress} once CCTP attestation completes.`, - ); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/e2e/swapBridgeViaOft.ts b/scripts/e2e/swapBridgeViaOft.ts deleted file mode 100644 index 53971f3..0000000 --- a/scripts/e2e/swapBridgeViaOft.ts +++ /dev/null @@ -1,809 +0,0 @@ -/** - * Script — Swap AAVE Polygon → USDT0 Polygon, then bridge to Arbitrum USDT via USDT0 OFT (LayerZero v2) - * - * Two independent scenarios run back-to-back (monolithic + modular each): - * - * Case 1 — AAVE Polygon → USDT0 Polygon (OpenOcean) → Arbitrum USDT (USDT0 OFT bridge) - * 1. OpenOcean swap_quote: AAVE → USDT0 on Polygon (router is sender + recipient of swap) - * 2. Approve AllowanceHolder (0x AH) for the AAVE input amount - * 3. Post-swap fee: FEE_BPS of the OpenOcean USDT0 output amount is transferred to signer EOA - * 4. OFT quote: quoteSend + quoteOFT on the USDT0 OFT Adapter (Polygon) to get LZ nativeFee + amountReceivedLD - * 5. Build send() calldata: amountLD = 0 placeholder, spliced at runtime (byte offset 196) - * 6. Execute via AllowanceHolder.exec(); msg.value = nativeFeeWithBuffer (5% buffer on LZ fee) - * - * Case 2 — USDT0 Polygon → Arbitrum USDT (direct OFT bridge, no swap) - * 1. Pre-bridge fee: FEE_BPS of input USDT0 transferred to signer EOA - * 2. OFT quote + send() calldata (same as above) - * 3. Execute; msg.value = nativeFeeWithBuffer - * - * OFT mechanics (Polygon USDT0 uses OFT_ADAPTER — approval required): - * - Call quoteSend() + quoteOFT() on USDT0_OFT_ADAPTER_POLYGON (dstEid = ARBITRUM_LZ_EID 30110) - * - Approve the OFT Adapter to spend TOKENS.USDT0_POLYGON before calling send() - * - Pass nativeFeeWithBuffer as msg.value (POL on Polygon) so the router forwards LZ fee to the adapter - * - amountLD in send() is spliced at byte offset 196 from the actual post-fee token balance - * - * sendParam.amountLD offset derivation (same as Stargate): - * ABI layout after 4-byte selector: - * sendParam_ptr (32) | fee.nativeFee (32) | fee.lzTokenFee (32) | refundAddress (32) | tail... - * Tail (sendParam body): - * dstEid (32) | to (32) | amountLD (32) ← byte 4 + 3*32 + 2*32 = 196 from calldata start - * - * LZ extraOptions for USDT0 OFT (addExecutorLzReceiveOption(65000, 0)): - * Generated at runtime via @layerzerolabs/lz-v2-utilities Options SDK. - * Equivalent to: type3(0x0003) | workerId(0x01) | optLen(0x0011) | optType(0x01) | uint128(65000) - * - * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaOft.ts all - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaOft.ts aave-usdt0-oft - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaOft.ts usdt0-direct - */ -import axios from 'axios'; -import { ethers } from 'ethers'; -import * as dotenv from 'dotenv'; -import { Options } from '@layerzerolabs/lz-v2-utilities'; -dotenv.config(); - -import { - CHAIN_IDS, - routerAddressForChain, - TOKENS, - FEE_BPS, - bpsOf, - RPC, - OPEN_OCEAN_API_KEY, - OO_SLIPPAGE_PERCENT, - ALLOWANCE_HOLDER, - ARBITRUM_LZ_EID, - USDT0_OFT_ADAPTER_POLYGON, -} from './config'; -import { - execViaAH, - ensureAllowanceForAllowanceHolder, -} from './utils/allowanceHolder'; -import { - encodeApprove, - encodeTransfer, - encodeBalanceOf, - getWalletErc20Balance, -} from './utils/erc20'; -import { ROUTER_ABI } from './utils/routerAbi'; -import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; -import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { - MonolithicExecutionCall, - NO_FEE, - NO_SWAP, - ZERO_BYTES32, - bridgeAmountPositionFlag, - monolithicArgs, -} from './utils/contractTypes'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; -import { - ensureRouterErc20Balance, - ensureRouterApproval, -} from './utils/reproducibility'; - -const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); - -// ─── Constants ──────────────────────────────────────────────────────────────── - -/** Byte offset of sendParam.amountLD within the OFT send() calldata (same as Stargate). */ -const OFT_AMOUNT_LD_OFFSET = 196; - -/** - * LZ executor options for the OFT bridge: TYPE_3 + addExecutorLzReceiveOption(gas=65000, value=0). - * Generated via the @layerzerolabs/lz-v2-utilities SDK (same as oft.service.ts in bungee-backend). - */ -const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); - -// ─── OFT ABI ───────────────────────────────────────────────────────────────── - -/** Minimal OFT / OFT Adapter ABI for quoting and sending. */ -const OFT_ABI = [ - 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', - 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', - 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', -]; - -const OFT_IFACE = new ethers.Interface(OFT_ABI); - -// ─── OpenOcean quote ────────────────────────────────────────────────────────── - -interface OoSwapQuoteResponse { - data: { - to: string; - data: string; - value: string; - outAmount: string; - minOutAmount: string; - }; -} - -/** - * Fetches an OpenOcean swap_quote for AAVE → USDT0 on Polygon. - * The router address is used as both sender and account so tokens land in the router. - */ -async function fetchOpenOceanQuote( - inputAmount: bigint, -): Promise<{ - ooRouter: string; - swapData: string; - estimatedOut: bigint; - minAmountOut: bigint; -}> { - const params: Record = { - inTokenAddress: TOKENS.AAVE_POLYGON, - outTokenAddress: TOKENS.USDT0_POLYGON, - amount: ethers.formatUnits(inputAmount, 18), // AAVE has 18 decimals - slippage: OO_SLIPPAGE_PERCENT, - sender: ROUTER_POLYGON, - account: ROUTER_POLYGON, - gasPrice: '1', - }; - if (OPEN_OCEAN_API_KEY) { - params.apikey = OPEN_OCEAN_API_KEY; - } - - const url = `https://open-api.openocean.finance/v3/${CHAIN_IDS.POLYGON}/swap_quote`; - const response = await axios.get(url, { params }); - const q = response.data.data; - return { - ooRouter: q.to, - swapData: q.data, - estimatedOut: BigInt(q.outAmount), - minAmountOut: BigInt(q.minOutAmount), - }; -} - -// ─── OFT quote ──────────────────────────────────────────────────────────────── - -interface OftQuoteResult { - nativeFee: bigint; - nativeFeeWithBuffer: bigint; - amountReceivedLD: bigint; -} - -/** - * Fetches the LZ nativeFee and expected received amount from the USDT0 OFT Adapter on Polygon. - * - * @param provider JSON-RPC provider for Polygon - * @param bridgeAmountLD Amount of USDT0 (6 decimals on Polygon) to bridge - * @param recipient Recipient address on Arbitrum (also used as refundAddress) - */ -async function fetchOftQuote( - provider: ethers.JsonRpcProvider, - bridgeAmountLD: bigint, - recipient: string, -): Promise { - const contract = new ethers.Contract( - USDT0_OFT_ADAPTER_POLYGON, - OFT_ABI, - provider, - ); - const to32 = ethers.zeroPadValue(recipient, 32); - - const sendParam = { - dstEid: ARBITRUM_LZ_EID, - to: to32, - amountLD: bridgeAmountLD, - minAmountLD: 0n, - extraOptions: LZ_EXTRA_OPTIONS, - composeMsg: '0x', - oftCmd: '0x', - }; - - const [fee, oft] = await Promise.all([ - contract.quoteSend(sendParam, false), - contract.quoteOFT(sendParam), - ]); - - const nativeFee = fee.nativeFee as bigint; - const nativeFeeWithBuffer = (nativeFee * 105n) / 100n; // 5% buffer - - return { - nativeFee, - nativeFeeWithBuffer, - amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, - }; -} - -// ─── OFT send() calldata builder ───────────────────────────────────────────── - -/** - * Encodes the OFT Adapter send() calldata. - * amountLD is set to 0 as a placeholder — the router splices the actual amount - * at byte offset 196 from the router's post-fee token balance at execution time. - * - * @param nativeFee LZ fee in POL (with 5% buffer already applied) - * @param recipient Recipient on Arbitrum (also used as refundAddress) - */ -function buildOftSendCalldata(nativeFee: bigint, recipient: string): string { - return OFT_IFACE.encodeFunctionData('send', [ - { - dstEid: ARBITRUM_LZ_EID, - to: ethers.zeroPadValue(recipient, 32), - amountLD: 0n, // placeholder — spliced at runtime at offset 196 - minAmountLD: 0n, - extraOptions: LZ_EXTRA_OPTIONS, - composeMsg: '0x', - oftCmd: '0x', - }, - { nativeFee, lzTokenFee: 0n }, - recipient, // refundAddress - ]); -} - -// ─── Case 1: AAVE → USDT0 (OpenOcean swap) → USDT0 Base (OFT bridge) ───────── - -/** - * Monolithic for Case 1: - * - Swap AAVE → USDT0 via OpenOcean (swap step) - * - Post-swap fee: FEE_BPS of estimated USDT0 output transferred to signer - * - Bridge remaining USDT0 via OFT Adapter (approval required) - * - bridge amount position flag splices actual balance into amountLD at byte 196 - * - bridge.value = nativeFeeWithBuffer (forwarded as LZ msg.value) - */ -function buildCase1Monolithic( - signer: string, - inputAmount: bigint, - feeAmount: bigint, - minAmountOut: bigint, - ooRouter: string, - swapData: string, - oftSendData: string, - nativeFeeWithBuffer: bigint, -): MonolithicExecutionCall { - return { - exec: { - input: { - user: signer, - inputToken: TOKENS.AAVE_POLYGON, - inputAmount, - }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ooRouter, - outputToken: TOKENS.USDT0_POLYGON, - value: 0n, - minOutput: minAmountOut, - returnDataWordOffset: 0n, - }, - postFee: { - receiver: signer, - amount: feeAmount, - }, - bridge: { - target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, // adapter needs ERC-20 approval - value: nativeFeeWithBuffer, // forwarded as LZ native fee - }, - flags: bridgeAmountPositionFlag(OFT_AMOUNT_LD_OFFSET), - }, - swapCallData: swapData, - bridgeCallData: oftSendData, - }; -} - -/** - * Modular for Case 1: - * [0] AH.transferFrom(AAVE, signer, router, inputAmount) - * [1] AAVE.approve(ooRouter, inputAmount) - * [2] ooRouter swap calldata — AAVE → USDT0 lands in router - * [3] USDT0.transfer(signer, feeAmount) — post-swap fee to signer - * [4] USDT0.approve(adapter, MaxUint256) — allow adapter to pull USDT0 - * [5] STATICCALL USDT0.balanceOf(router) — capture post-fee balance - * [6] nativeCall adapter.send(...) — spliceWord patches amountLD from [5] - */ -function buildCase1Modular( - signer: string, - inputAmount: bigint, - feeAmount: bigint, - ooRouter: string, - swapData: string, - oftSendData: string, - nativeFeeWithBuffer: bigint, -): ModularAction[] { - const ahIface = new ethers.Interface([ - 'function transferFrom(address token, address owner, address recipient, uint256 amount)', - ]); - const ahTransferFromData = ahIface.encodeFunctionData('transferFrom', [ - TOKENS.AAVE_POLYGON, - signer, - ROUTER_POLYGON, - inputAmount, - ]); - - const exec = new ModularActionsBuilder(); - exec.call(ALLOWANCE_HOLDER, ahTransferFromData); - exec.call(TOKENS.AAVE_POLYGON, encodeApprove(ooRouter, inputAmount)); - exec.call(ooRouter, swapData); // AAVE → USDT0 lands in router - exec.call(TOKENS.USDT0_POLYGON, encodeTransfer(signer, feeAmount)); // post-swap fee - exec.call( - TOKENS.USDT0_POLYGON, - encodeApprove(USDT0_OFT_ADAPTER_POLYGON, ethers.MaxUint256), - ); - const usdt0Balance = exec.staticCall( - TOKENS.USDT0_POLYGON, - encodeBalanceOf(ROUTER_POLYGON), - ); - exec - .nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) - .spliceWord(BigInt(OFT_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); - - return exec.toActions(); -} - -async function executeCase1Leg(args: { - label: string; - useModular: boolean; - signer: ethers.Wallet; - signerAddress: string; - provider: ethers.JsonRpcProvider; - inputAmount: bigint; - routerIface: ethers.Interface; -}): Promise { - const { - label, - useModular, - signer, - signerAddress, - provider, - inputAmount, - routerIface, - } = args; - console.log(`\n── ${label} (${useModular ? 'MODULAR' : 'MONOLITHIC'}) ──`); - - console.log('Fetching OpenOcean quote (Polygon AAVE → USDT0)...'); - const { ooRouter, swapData, estimatedOut, minAmountOut } = - await fetchOpenOceanQuote(inputAmount); - - const feeAmount = bpsOf(estimatedOut, FEE_BPS); - const bridgeAmount = estimatedOut - feeAmount; - - console.log(` OO router: ${ooRouter}`); - console.log(` Est. USDT0 out: ${ethers.formatUnits(estimatedOut, 6)}`); - console.log( - ` Post-swap fee: ${ethers.formatUnits(feeAmount, 6)} (${FEE_BPS} bps)`, - ); - console.log(` Min USDT0 out: ${ethers.formatUnits(minAmountOut, 6)}`); - console.log(` Bridge amount: ${ethers.formatUnits(bridgeAmount, 6)}`); - - console.log('Fetching USDT0 OFT quote (Polygon → Arbitrum)...'); - const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote( - provider, - bridgeAmount, - signerAddress, - ); - - console.log( - ` nativeFee +5%buf: ${ethers.formatEther(nativeFeeWithBuffer)} POL`, - ); - console.log( - ` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`, - ); - - await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); - await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); - - const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - - let execCalldata: string; - if (useModular) { - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - ZERO_BYTES32, - buildCase1Modular( - signerAddress, - inputAmount, - feeAmount, - ooRouter, - swapData, - oftSendData, - nativeFeeWithBuffer, - ), - ]); - } else { - execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs( - buildCase1Monolithic( - signerAddress, - inputAmount, - feeAmount, - minAmountOut, - ooRouter, - swapData, - oftSendData, - nativeFeeWithBuffer, - ), - )); - } - - await ensureAllowanceForAllowanceHolder( - signer, - TOKENS.AAVE_POLYGON, - inputAmount, - ); - - console.log( - `AllowanceHolder.exec (txValue = ${ethers.formatEther( - nativeFeeWithBuffer, - )} ETH)...`, - ); - const receipt = await execViaAH( - signer, - ROUTER_POLYGON, - TOKENS.AAVE_POLYGON, - inputAmount, - ROUTER_POLYGON, - execCalldata, - nativeFeeWithBuffer, - ); - - logTxnSummary( - `Arbitrum AAVE → USDT (OO swap) → Arbitrum USDT0 (OFT) — ${ - useModular ? 'Modular' : 'Monolithic' - }`, - CHAIN_IDS.POLYGON, - receipt, - ); -} - -// ─── Case 2: Arbitrum USDT → Base USDT0 (direct OFT bridge, no swap) ───────── - -/** - * Monolithic for Case 2: - * - No swap (NO_SWAP) - * - Pre-bridge fee: FEE_BPS of input USDT0 transferred to signer - * - Bridge remaining USDT0 via OFT Adapter (approval required) - * - bridge amount position flag splices actual balance at byte 196 - * - bridge.value = nativeFeeWithBuffer (forwarded as LZ msg.value) - */ -function buildCase2Monolithic( - signer: string, - inputAmount: bigint, - feeAmount: bigint, - oftSendData: string, - nativeFeeWithBuffer: bigint, -): MonolithicExecutionCall { - return { - exec: { - input: { - user: signer, - inputToken: TOKENS.USDT0_POLYGON, - inputAmount, - }, - preFee: { - receiver: signer, - amount: feeAmount, - }, - swap: NO_SWAP, - postFee: NO_FEE, - bridge: { - target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, - value: nativeFeeWithBuffer, - }, - flags: bridgeAmountPositionFlag(OFT_AMOUNT_LD_OFFSET), - }, - swapCallData: '0x', - bridgeCallData: oftSendData, - }; -} - -/** - * Modular for Case 2: - * [0] AH.transferFrom(USDT0, signer, router, inputAmount) - * [1] USDT0.transfer(signer, feeAmount) — pre-bridge fee to signer - * [2] USDT0.approve(adapter, MaxUint256) — allow adapter to pull USDT0 - * [3] STATICCALL USDT0.balanceOf(router) — capture post-fee balance - * [4] nativeCall adapter.send(...) — spliceWord patches amountLD from [3] - */ -function buildCase2Modular( - signer: string, - inputAmount: bigint, - feeAmount: bigint, - oftSendData: string, - nativeFeeWithBuffer: bigint, -): ModularAction[] { - const ahIface = new ethers.Interface([ - 'function transferFrom(address token, address owner, address recipient, uint256 amount)', - ]); - const ahTransferFromData = ahIface.encodeFunctionData('transferFrom', [ - TOKENS.USDT0_POLYGON, - signer, - ROUTER_POLYGON, - inputAmount, - ]); - - const exec = new ModularActionsBuilder(); - exec.call(ALLOWANCE_HOLDER, ahTransferFromData); - exec.call(TOKENS.USDT0_POLYGON, encodeTransfer(signer, feeAmount)); // pre-bridge fee - exec.call( - TOKENS.USDT0_POLYGON, - encodeApprove(USDT0_OFT_ADAPTER_POLYGON, ethers.MaxUint256), - ); - const usdt0Balance = exec.staticCall( - TOKENS.USDT0_POLYGON, - encodeBalanceOf(ROUTER_POLYGON), - ); - exec - .nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) - .spliceWord(BigInt(OFT_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); - - return exec.toActions(); -} - -async function executeCase2Leg(args: { - label: string; - useModular: boolean; - signer: ethers.Wallet; - signerAddress: string; - provider: ethers.JsonRpcProvider; - inputAmount: bigint; - routerIface: ethers.Interface; -}): Promise { - const { - label, - useModular, - signer, - signerAddress, - provider, - inputAmount, - routerIface, - } = args; - console.log(`\n── ${label} (${useModular ? 'MODULAR' : 'MONOLITHIC'}) ──`); - - const feeAmount = bpsOf(inputAmount, FEE_BPS); - const bridgeAmount = inputAmount - feeAmount; - - console.log(` Input USDT0: ${ethers.formatUnits(inputAmount, 6)}`); - console.log( - ` Pre-bridge fee: ${ethers.formatUnits(feeAmount, 6)} (${FEE_BPS} bps)`, - ); - console.log(` Net to bridge: ${ethers.formatUnits(bridgeAmount, 6)}`); - - console.log('Fetching USDT0 OFT quote (Polygon → Arbitrum)...'); - const { nativeFeeWithBuffer, amountReceivedLD } = await fetchOftQuote( - provider, - bridgeAmount, - signerAddress, - ); - - console.log( - ` nativeFee +5%buf: ${ethers.formatEther(nativeFeeWithBuffer)} POL`, - ); - console.log( - ` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`, - ); - - await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); - - const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - - let execCalldata: string; - if (useModular) { - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - ZERO_BYTES32, - buildCase2Modular( - signerAddress, - inputAmount, - feeAmount, - oftSendData, - nativeFeeWithBuffer, - ), - ]); - } else { - execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs( - buildCase2Monolithic( - signerAddress, - inputAmount, - feeAmount, - oftSendData, - nativeFeeWithBuffer, - ), - )); - } - - await ensureAllowanceForAllowanceHolder( - signer, - TOKENS.USDT0_POLYGON, - inputAmount, - ); - - console.log( - `AllowanceHolder.exec (txValue = ${ethers.formatEther( - nativeFeeWithBuffer, - )} ETH)...`, - ); - const receipt = await execViaAH( - signer, - ROUTER_POLYGON, - TOKENS.USDT0_POLYGON, - inputAmount, - ROUTER_POLYGON, - execCalldata, - nativeFeeWithBuffer, - ); - - logTxnSummary( - `Polygon USDT → Arbitrum USDT0 (OFT direct) — ${ - useModular ? 'Modular' : 'Monolithic' - }`, - CHAIN_IDS.POLYGON, - receipt, - ); -} - -// ─── Case runners ───────────────────────────────────────────────────────────── - -async function runCase1( - signer: ethers.Wallet, - signerAddress: string, - routerIface: ethers.Interface, -): Promise { - console.log(`\n${'═'.repeat(70)}`); - console.log( - 'CASE 1: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (OFT bridge)', - ); - console.log('═'.repeat(70)); - - const provider = new ethers.JsonRpcProvider(RPC.POLYGON); - const signerOnChain = signer.connect(provider); - - const { balance: walletBalance } = await getWalletErc20Balance( - TOKENS.AAVE_POLYGON, - signerAddress, - provider, - ); - if (walletBalance === 0n) { - throw new Error( - `Case 1: signer ${signerAddress} has zero AAVE on Polygon. Fund ${TOKENS.AAVE_POLYGON}.`, - ); - } - - const legAmount = (walletBalance - 20n) / 2n; - if (legAmount === 0n) { - throw new Error('Case 1: AAVE balance too small to split into two halves.'); - } - - console.log( - `Input token (AAVE): ${ethers.formatUnits( - walletBalance, - 18, - )} (full balance)`, - ); - console.log(`Per leg: ${ethers.formatUnits(legAmount, 18)}`); - - await executeCase1Leg({ - label: '1/2', - useModular: false, - signer: signerOnChain, - signerAddress, - provider, - inputAmount: legAmount, - routerIface, - }); - - console.log('\nSleeping 3s before modular leg...'); - await sleep(3000); - - await executeCase1Leg({ - label: '2/2', - useModular: true, - signer: signerOnChain, - signerAddress, - provider, - inputAmount: legAmount, - routerIface, - }); -} - -async function runCase2( - signer: ethers.Wallet, - signerAddress: string, - routerIface: ethers.Interface, -): Promise { - console.log(`\n${'═'.repeat(70)}`); - console.log( - 'CASE 2: Polygon USDT0 → Arbitrum USDT0 (direct OFT bridge, no swap)', - ); - console.log('═'.repeat(70)); - - const provider = new ethers.JsonRpcProvider(RPC.POLYGON); - const signerOnChain = signer.connect(provider); - - const { balance: walletBalance } = await getWalletErc20Balance( - TOKENS.USDT0_POLYGON, - signerAddress, - provider, - ); - if (walletBalance === 0n) { - throw new Error( - `Case 2: signer ${signerAddress} has zero USDT0 on Polygon. Fund ${TOKENS.USDT0_POLYGON}.`, - ); - } - - const legAmount = (walletBalance - 20n) / 2n; - if (legAmount === 0n) { - throw new Error( - 'Case 2: USDT0 balance too small to split into two halves.', - ); - } - - console.log( - `Input token (USDT0): ${ethers.formatUnits( - walletBalance, - 6, - )} (full balance)`, - ); - console.log(`Per leg: ${ethers.formatUnits(legAmount, 6)}`); - - await executeCase2Leg({ - label: '1/2', - useModular: false, - signer: signerOnChain, - signerAddress, - provider, - inputAmount: legAmount, - routerIface, - }); - - console.log('\nSleeping 3s before modular leg...'); - await sleep(3000); - - await executeCase2Leg({ - label: '2/2', - useModular: true, - signer: signerOnChain, - signerAddress, - provider, - inputAmount: legAmount, - routerIface, - }); -} - -// ─── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) { - throw new Error('PRIVATE_KEY env var required'); - } - - const signer = new ethers.Wallet(privateKey); - const signerAddress = await signer.getAddress(); - const routerIface = new ethers.Interface(ROUTER_ABI); - - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${ROUTER_POLYGON}`); - - const caseArg = process.argv[2]?.toLowerCase(); - - if (caseArg === 'usdt0-direct') { - await runCase2(signer, signerAddress, routerIface); - console.log( - '\nCase 2 complete — USDT0 arrives on Arbitrum once LZ delivers the message.', - ); - return; - } - if (caseArg === 'aave-usdt0-oft') { - await runCase1(signer, signerAddress, routerIface); - console.log( - '\nCase 1 complete — USDT0 arrives on Arbitrum once LZ delivers the message.', - ); - return; - } - - console.error( - `Unknown case: ${caseArg}. Use: all | aave-usdt0-oft | usdt0-direct`, - ); - process.exit(1); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/e2e/swapBridgeViaStargateNative.ts b/scripts/e2e/swapBridgeViaStargateNative.ts deleted file mode 100644 index 2aa3aea..0000000 --- a/scripts/e2e/swapBridgeViaStargateNative.ts +++ /dev/null @@ -1,1100 +0,0 @@ -/** - * Stargate e2e test script — three independent cases, each running a - * monolithic leg followed (after a 3-second pause) by a modular leg. - * - * Case 1 Arbitrum USDC → OO swap → native ETH → Stargate Native ETH Pool → Base ETH - * Case 2 Polygon USDC → (no swap) → Stargate USDC Pool → Base USDC - * Case 3 Base USDC → OO swap → native ETH → Stargate Native ETH Pool → Arb ETH - * Case 4 Polygon POL → OO swap → Polygon USDT0 → USDT0 OFT Adapter → Arbitrum USDT0 - * - * Native-pool mechanics (cases 1 & 3): - * send() requires msg.value >= amountLD + nativeFee (StargatePoolNative._assertMessagingFee). - * Monolithic: BRIDGE_VALUE_FLAG + BRIDGE_AMOUNT_POSITION_FLAG set. - * Router splices finalETH into amountLD at runtime; msg.value = finalETH + nativeFeeWithBuffer. - * finalETH + nativeFeeWithBuffer >= finalETH + nativeFee ✓; destination gets exact finalETH. - * Modular: amountLD = minAmountOut - fee - nativeFeeWithBuffer (static; no splice available). - * nativeCall Stargate with value = amountLD + nativeFeeWithBuffer = minAmountOut - fee. - * - * ERC20-pool mechanics (case 2): - * send() uses ERC20 transferFrom for USDC; msg.value = nativeFee only. - * Monolithic: bridge amount position flag set to 196, bridge.value=nativeFeeWithBuffer. - * Modular: staticCall USDC.balanceOf(router) → spliceWord(196n) into Stargate calldata. - * nativeCall Stargate with value = nativeFeeWithBuffer. - * - * Case selection (required) — same idea as `bridgeViaRelay.ts` / `swapBridgeViaCctp.ts`: - * Pass a scenario as the first CLI arg, or set `STARGATE_E2E_CASE` when your runner - * cannot pass argv. - * - * 1 / arb-usdc-base-eth Arbitrum USDC → OO → native ETH → Stargate native → Base ETH - * 2 / polygon-usdc-base Polygon USDC → Stargate USDC pool → Base USDC (no swap) - * 3 / base-usdc-arb-eth Base USDC → OO → native ETH → Stargate native → Arbitrum ETH - * 4 / polygon-pol-usdt0-arb Polygon POL → OO → Polygon USDT0 → LZ OFT Adapter → Arb USDT0 - * msg.value = inputPOL used in OO swap + LZ nativeFee (POL) - * - * Usage: - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaStargateNative.ts arb-usdc-base-eth - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaStargateNative.ts polygon-pol-usdt0-arb direct - * PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaStargateNative.ts polygon-pol-usdt0-arb allowance-holder - * STARGATE_E2E_CASE=4 STARGATE_ROUTER_EXEC=direct PRIVATE_KEY=0x... ts-node scripts/e2e/swapBridgeViaStargateNative.ts - * - * Router execution (`argv[3]` overrides `STARGATE_ROUTER_EXEC`): - * - * | Mode | Behaviour | - * |---------------------|-----------| - * | `direct` | Signer sends tx directly to router with `{ value }` | - * | `allowance-holder` | `AllowanceHolder.exec` wraps router (`msg.value` + ERC-2771 user suffix) | - * - * **Native-token input (case 4):** choose explicitly — either pass `direct` or `allowance-holder` as argv[3], - * or set `STARGATE_ROUTER_EXEC`. There is no default; ambiguous runs exit with usage. - * - * **ERC20 input (cases 1–3):** defaults to `allowance-holder`; `direct` is rejected (AH pull required). - * - * Router per source chain: `ROUTER_BY_CHAIN_ID` / `routerAddressForChain(chainId)` in config.ts (`ROUTER_CHAIN_` overrides). - * - * Bridge note: Case 4 uses LayerZero **`send`** on {@link USDT0_OFT_ADAPTER_POLYGON}, not Stargate. - * ABI matches Stargate pool `send`; `lzExtraOptions` uses TYPE_3 executor gas (same as `swapBridgeViaOft.ts`). - */ -import axios from 'axios'; -import { ethers, parseEther } from 'ethers'; -import * as dotenv from 'dotenv'; -import { Options } from '@layerzerolabs/lz-v2-utilities'; -dotenv.config(); - -import { - CHAIN_IDS, - routerAddressForChain, - TOKENS, - FEE_BPS, - bpsOf, - RPC, - OPEN_OCEAN_API_KEY, - OO_SLIPPAGE_PERCENT, - ALLOWANCE_HOLDER, - NATIVE_TOKEN_ADDRESS, - STARGATE_NATIVE_ARB, - STARGATE_NATIVE_BASE, - STARGATE_USDC_POLYGON, - USDT0_OFT_ADAPTER_POLYGON, - BASE_LZ_EID, - ARBITRUM_LZ_EID, - STARGATE_AMOUNT_LD_OFFSET, -} from './config'; -import { execViaAH, execDirect, ensureAllowanceForAllowanceHolder } from './utils/allowanceHolder'; -import { - encodeApprove, - encodeTransfer, - encodeBalanceOf, - getWalletErc20Balance, -} from './utils/erc20'; -import { ROUTER_ABI } from './utils/routerAbi'; -import { ModularActionsBuilder } from './utils/modularActionsBuilder/index'; -import type { ModularAction } from './utils/modularActionsBuilder/index'; -import { - BRIDGE_VALUE_FLAG, - MonolithicExecutionCall, - NO_FEE, - NO_SWAP, - ZERO_ADDRESS, - ZERO_BYTES32, - bridgeAmountPositionFlag, - monolithicArgs, -} from './utils/contractTypes'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; -import { - ensureRouterErc20Balance, - ensureRouterNativeBalance, - ensureRouterApproval, -} from './utils/reproducibility'; - -/** - * LZ extra options for Polygon USDT0 OFT Adapter `send()` (executor gas). - * Mirrors `swapBridgeViaOft.ts`. - */ -const LZ_EXTRA_OPTIONS_POLYGON_USDT0 = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); - -// ─── Case configuration ─────────────────────────────────────────────────────── - -/** - * Describes a Stargate test case. `ooSwap` being null means case 2 (no swap — input - * token goes directly to Stargate). `isNativePool` drives the bridge mechanics. - */ -interface OoSwapConfig { - inToken: string; - outToken: string; - inDecimals: number; - chainId: number; - gasPrice: string; -} - -interface CaseConfig { - name: string; - sourceChainId: number; - rpc: string; - inputToken: string; - inputDecimals: number; - /** true when inputToken is native (POL/ETH/BNB); exec mode must be set explicitly (`direct` | `allowance-holder`) */ - isNativeInput: boolean; - ooSwap: OoSwapConfig | null; // null → skip OO swap, bridge input token directly - /** Contract that receives LZ `send` calldata — Stargate pool or LZ OFT adapter (same ABI shape). */ - bridgeContract: string; - /** `extraOptions` in SendParam (`0x` for Stargate pools; encoded TYPE_3 for USDT0 OFT on Polygon). */ - lzExtraOptions: string; - isNativePool: boolean; - destLzEid: number; -} - -const CASES: CaseConfig[] = [ - { - name: 'Arbitrum USDC → ETH (OO) → Base ETH (Stargate Native Pool)', - sourceChainId: CHAIN_IDS.ARBITRUM, - rpc: RPC.ARBITRUM, - inputToken: TOKENS.USDC_ARB, - inputDecimals: 6, - isNativeInput: false, - ooSwap: { - inToken: TOKENS.USDC_ARB, - outToken: NATIVE_TOKEN_ADDRESS, - inDecimals: 6, - chainId: CHAIN_IDS.ARBITRUM, - gasPrice: '1', - }, - bridgeContract: STARGATE_NATIVE_ARB, - lzExtraOptions: '0x', - isNativePool: true, - destLzEid: BASE_LZ_EID, - }, - { - name: 'Polygon USDC → Base USDC (Stargate USDC Pool, no swap)', - sourceChainId: CHAIN_IDS.POLYGON, - rpc: RPC.POLYGON, - inputToken: TOKENS.USDC_POLYGON_CIRCLE, - inputDecimals: 6, - isNativeInput: false, - ooSwap: null, // skip OO swap — bridge USDC directly - bridgeContract: STARGATE_USDC_POLYGON, - lzExtraOptions: '0x', - isNativePool: false, - destLzEid: BASE_LZ_EID, - }, - { - name: 'Base USDC → ETH (OO) → Arbitrum ETH (Stargate Native Pool)', - sourceChainId: CHAIN_IDS.BASE, - rpc: RPC.BASE, - inputToken: TOKENS.USDC_BASE, - inputDecimals: 6, - isNativeInput: false, - ooSwap: { - inToken: TOKENS.USDC_BASE, - outToken: NATIVE_TOKEN_ADDRESS, - inDecimals: 6, - chainId: CHAIN_IDS.BASE, - gasPrice: '1', - }, - bridgeContract: STARGATE_NATIVE_BASE, - lzExtraOptions: '0x', - isNativePool: true, - destLzEid: ARBITRUM_LZ_EID, - }, - { - // Polygon native POL → USDT0 via OpenOcean → Arbitrum USDT0 via USDT0 OFT Adapter on Polygon (LayerZero `send`). - name: 'Polygon POL → USDT0 (OO) → Arbitrum USDT0 (LZ OFT Adapter)', - sourceChainId: CHAIN_IDS.POLYGON, - rpc: RPC.POLYGON, - inputToken: NATIVE_TOKEN_ADDRESS, - inputDecimals: 18, - isNativeInput: true, - ooSwap: { - inToken: NATIVE_TOKEN_ADDRESS, - outToken: TOKENS.USDT0_POLYGON, - inDecimals: 18, - chainId: CHAIN_IDS.POLYGON, - gasPrice: '1', - }, - bridgeContract: USDT0_OFT_ADAPTER_POLYGON, - lzExtraOptions: LZ_EXTRA_OPTIONS_POLYGON_USDT0, - isNativePool: false, - destLzEid: ARBITRUM_LZ_EID, - }, -]; - -/** Slug aliases (and `1`/`2`/`3`/`4`) → index in `CASES`. */ -const STARGATE_SCENARIO_ALIASES: Record = { - '1': 0, - 'arb-usdc-base-eth': 0, - 'arb-native-base': 0, - 'arbitrum-usdc-base-eth': 0, - - '2': 1, - 'polygon-usdc-base': 1, - 'usdc-polygon-base': 1, - - '3': 2, - 'base-usdc-arb-eth': 2, - 'base-native-arb': 2, - - '4': 3, - 'polygon-pol-usdt0-arb': 3, - 'polygon-pol-arb-usdt0': 3, - 'pol-native-usdt0-arb': 3, -}; - -/** - * Resolves scenario from CLI (`process.argv[2]`) or `STARGATE_E2E_CASE`, then - * returns the matching `CaseConfig`. Fails fast with a usage message if unset/unknown. - */ -function resolveScenarioConfig(): CaseConfig { - const raw = (process.argv[2] ?? process.env.STARGATE_E2E_CASE ?? '').trim().toLowerCase(); - if (!raw) { - console.error( - 'Missing scenario. Pass argv[2] or set STARGATE_E2E_CASE. Examples:\n' + - ' ts-node scripts/e2e/swapBridgeViaStargateNative.ts arb-usdc-base-eth\n' + - ' ts-node scripts/e2e/swapBridgeViaStargateNative.ts polygon-usdc-base\n' + - ' ts-node scripts/e2e/swapBridgeViaStargateNative.ts base-usdc-arb-eth\n' + - ' ts-node scripts/e2e/swapBridgeViaStargateNative.ts polygon-pol-usdt0-arb\n' + - 'Or use numeric slugs 1 | 2 | 3 | 4.', - ); - process.exit(1); - } - const idx = STARGATE_SCENARIO_ALIASES[raw]; - if (idx === undefined || !CASES[idx]) { - console.error(`Unknown Stargate e2e scenario "${raw}". Valid: ${Object.keys(STARGATE_SCENARIO_ALIASES).sort().join(', ')}`); - process.exit(1); - } - return CASES[idx]; -} - -/** How the signer reaches the router: direct `eth_sendTransaction`, or wrapped `AllowanceHolder.exec`. */ -type RouterExecRoute = 'direct' | 'allowance-holder'; - -/** argv[3] / `STARGATE_ROUTER_EXEC` tokens → canonical route. */ -const ROUTER_EXEC_ALIASES: Record = { - direct: 'direct', - dr: 'direct', - router: 'direct', - - 'allowance-holder': 'allowance-holder', - ah: 'allowance-holder', - exec: 'allowance-holder', -}; - -/** - * Resolves execution transport: **`argv[3]` overrides `STARGATE_ROUTER_EXEC`** when non-empty after trim. - * - * - **Native-token input (`isNativeInput`):** caller **must** set `direct` or `allowance-holder` explicitly - * — no silent default — so AH vs signer→router stays a deliberate choice. - * - **ERC20 input:** defaults to `allowance-holder`; `direct` is rejected (`AllowanceHolder.transferFrom` pull). - */ -function resolveRouterExecRoute(cfg: CaseConfig): RouterExecRoute { - const rawArg = typeof process.argv[3] === 'string' ? process.argv[3].trim().toLowerCase() : ''; - const rawEnv = (process.env.STARGATE_ROUTER_EXEC ?? '').trim().toLowerCase(); - const raw = rawArg || rawEnv; - - const resolveExplicit = (): RouterExecRoute | null => { - if (!raw) { - return null; - } - const route = ROUTER_EXEC_ALIASES[raw]; - if (!route) { - console.error( - `Unknown router exec "${raw}". Use argv[3] or STARGATE_ROUTER_EXEC: direct | allowance-holder (aliases dr, router, ah, exec).`, - ); - process.exit(1); - } - return route; - }; - - const route = resolveExplicit(); - if (route !== null) { - if (!cfg.isNativeInput && route === 'direct') { - console.error( - 'ERC20 input cases cannot use direct router txs: `_pullFromUser` invokes AllowanceHolder.transferFrom, which needs the ephemeral allowance set by AH.exec.', - ); - process.exit(1); - } - return route; - } - - if (cfg.isNativeInput) { - console.error( - [ - 'Native-token input scenarios require an explicit router exec mode (no default).', - '', - ' argv[3] STARGATE_ROUTER_EXEC', - ' ----------------------------- ------------------------------', - ' direct direct', - ' allowance-holder (aliases: ah, exec)', - '', - 'Examples:', - ' ts-node scripts/e2e/swapBridgeViaStargateNative.ts polygon-pol-usdt0-arb direct', - ' STARGATE_ROUTER_EXEC=allowance-holder ts-node scripts/e2e/swapBridgeViaStargateNative.ts 4', - ].join('\n'), - ); - process.exit(1); - } - - return 'allowance-holder'; -} - -// ─── Shared Stargate ABI ────────────────────────────────────────────────────── - -/** Minimal Stargate pool ABI fragments — identical for native and ERC20 pools. */ -const STARGATE_ABI = [ - 'function quoteSend(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, bool payInLzToken) external view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee)', - 'function quoteOFT(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam) external view returns (tuple(uint256 minAmountLD, uint256 maxAmountLD) oftLimit, tuple(int256 feeAmountLD, string description)[] oftFeeDetails, tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt)', - 'function send(tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) messagingFee, address refundAddress) external payable', -]; - -const STARGATE_IFACE = new ethers.Interface(STARGATE_ABI); - -// ─── OpenOcean quote ────────────────────────────────────────────────────────── - -interface OoQuoteResponse { - data: { - to: string; - data: string; - /** wei to forward with OO call for native-token sells (omit or "0" for ERC20 sells) */ - value?: string; - outAmount: string; - minOutAmount: string; - }; -} - -/** - * Fetches an OpenOcean swap_quote. - * `amount` is in the input token's native units (raw bigint). - */ -async function fetchOoQuote( - cfg: OoSwapConfig, - routerAddress: string, - amount: bigint, -): Promise<{ - ooRouter: string; - swapData: string; - estimatedOut: bigint; - minAmountOut: bigint; - /** OO-recommended wei for swap calldata (`value` field); prefer over raw `amount` when > 0 */ - nativeSwapWei: bigint; -}> { - const params: Record = { - inTokenAddress: cfg.inToken, - outTokenAddress: cfg.outToken, - amount: ethers.formatUnits(amount, cfg.inDecimals), - slippage: OO_SLIPPAGE_PERCENT, - sender: routerAddress, - account: routerAddress, - gasPrice: cfg.gasPrice, - }; - if (OPEN_OCEAN_API_KEY) { - params.apikey = OPEN_OCEAN_API_KEY; - } - const url = `https://open-api.openocean.finance/v3/${cfg.chainId}/swap_quote`; - const response = await axios.get(url, { params }); - const q = response.data.data; - const nativeSwapWeiRaw = q.value !== undefined && q.value !== '' ? BigInt(q.value) : 0n; - return { - ooRouter: q.to, - swapData: q.data, - estimatedOut: BigInt(q.outAmount), - minAmountOut: BigInt(q.minOutAmount), - nativeSwapWei: nativeSwapWeiRaw, - }; -} - -// ─── Stargate quote ─────────────────────────────────────────────────────────── - -/** - * Fetches the LZ nativeFee and expected receive amount from Stargate. - * - * @param pool Pool contract address on the source chain - * @param provider Provider for the source chain - * @param destLzEid LayerZero destination EID - * @param recipient Recipient on destination (also refundAddress) - * @param bridgeAmountLD Tentative bridge amount for the quote - * @param extraOptions `SendParam.extraOptions` — `'0x'` for Stargate pools; LZ TYPE_3 for USDT0 OFT adapter - */ -async function fetchStargateQuote( - pool: string, - provider: ethers.JsonRpcProvider, - destLzEid: number, - recipient: string, - bridgeAmountLD: bigint, - extraOptions: string, -): Promise<{ nativeFee: bigint; amountReceivedLD: bigint }> { - const contract = new ethers.Contract(pool, STARGATE_ABI, provider); - const to32 = ethers.zeroPadValue(recipient, 32); - const sendParam = { - dstEid: destLzEid, - to: to32, - amountLD: bridgeAmountLD, - minAmountLD: 0n, - extraOptions, - composeMsg: '0x', - oftCmd: '0x', - }; - const [fee, oft] = await Promise.all([ - contract.quoteSend(sendParam, false), - contract.quoteOFT(sendParam), - ]); - return { - nativeFee: fee.nativeFee as bigint, - amountReceivedLD: oft.oftReceipt.amountReceivedLD as bigint, - }; -} - -// ─── Stargate calldata builder ──────────────────────────────────────────────── - -/** - * Encodes Stargate send() calldata. - * - * For native pools: pass a pre-computed amountLD; no splice required. - * For ERC20 pools: pass amountLD=0 as a placeholder; caller splices the - * real amount at STARGATE_AMOUNT_LD_OFFSET (196 bytes). - * - * @param destLzEid Destination LZ endpoint ID - * @param nativeFee LZ fee in source-chain native token (with buffer) - * @param recipient Recipient address on destination chain - * @param amountLD Explicit amountLD (for native pools); 0n for ERC20 pools - * @param extraOptions `SendParam.extraOptions` - */ -function buildStargateCalldata( - destLzEid: number, - nativeFee: bigint, - recipient: string, - amountLD: bigint, - extraOptions: string, -): string { - return STARGATE_IFACE.encodeFunctionData('send', [ - { - dstEid: destLzEid, - to: ethers.zeroPadValue(recipient, 32), - amountLD, - minAmountLD: 0n, - extraOptions, - composeMsg: '0x', - oftCmd: '0x', - }, - { nativeFee, lzTokenFee: 0n }, - recipient, // refundAddress - ]); -} - -// ─── Monolithic builders ────────────────────────────────────────────────────── - -/** - * Monolithic for native-pool cases (cases 1 & 3): - * - OO swap input token → native ETH - * - BRIDGE_VALUE_FLAG + BRIDGE_AMOUNT_POSITION_FLAG: router splices finalETH into amountLD at - * runtime and forwards finalETH + nativeFeeWithBuffer as msg.value to Stargate - * - StargatePoolNative checks msg.value >= amountLD + nativeFee; satisfied since - * finalETH + nativeFeeWithBuffer >= finalETH + nativeFee ✓ - */ -function buildNativePoolMonolithic( - signer: string, - cfg: CaseConfig, - inputAmount: bigint, - feeAmount: bigint, - minAmountOut: bigint, - ooRouter: string, - swapData: string, - stargateData: string, - nativeFeeWithBuffer: bigint, -): MonolithicExecutionCall { - return { - exec: { - input: { user: signer, inputToken: cfg.inputToken, inputAmount }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ooRouter, - outputToken: NATIVE_TOKEN_ADDRESS, - value: 0n, - minOutput: minAmountOut, - returnDataWordOffset: 0n, - }, - postFee: { receiver: signer, amount: feeAmount }, - bridge: { - target: cfg.bridgeContract, - approvalSpender: ZERO_ADDRESS, // no ERC20 approval for native ETH - value: nativeFeeWithBuffer, // added to finalETH as msg.value by BRIDGE_VALUE_FLAG - }, - flags: BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), - }, - swapCallData: swapData, - bridgeCallData: stargateData, - }; -} - -/** - * Monolithic for ERC20-pool case (case 2): - * - No OO swap (NO_SWAP) — input USDC goes directly to bridge - * - USDC transferred via ERC20 approval - * - bridge amount position flag set to 196: router splices finalAmount into amountLD at runtime - * - bridge.value=nativeFeeWithBuffer: forwarded as msg.value for the LZ fee - */ -function buildErc20PoolMonolithic( - signer: string, - cfg: CaseConfig, - inputAmount: bigint, - feeAmount: bigint, - stargateData: string, - nativeFeeWithBuffer: bigint, -): MonolithicExecutionCall { - return { - exec: { - input: { user: signer, inputToken: cfg.inputToken, inputAmount }, - preFee: NO_FEE, - swap: NO_SWAP, // skip swap — finalToken = inputToken, finalAmount = inputAmount - preFee - postFee: { receiver: signer, amount: feeAmount }, - bridge: { - target: cfg.bridgeContract, - approvalSpender: cfg.bridgeContract, // router must approve USDC to pool - value: nativeFeeWithBuffer, // POL/native forwarded as LZ fee msg.value - }, - flags: bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), - }, - swapCallData: '0x', - bridgeCallData: stargateData, - }; -} - -// ─── Modular builders ───────────────────────────────────────────────────────── - -/** - * Modular for native-pool cases (cases 1 & 3): - * [0] AH.transferFrom input token - * [1] approve(ooRouter, inputAmount) - * [2] OO swap → native ETH lands in router - * [3] nativeCall: send fee ETH to signer - * [4] nativeCall: Stargate send() with value = amountLD + nativeFeeWithBuffer = minAmountOut - fee - * - * amountLD (from stargateData) = minAmountOut - fee - nativeFeeWithBuffer. - * StargatePoolNative check: msg.value >= amountLD + nativeFee; - * bridgeValue = amountLD + nativeFeeWithBuffer >= amountLD + nativeFee ✓ - * Any ETH surplus over minAmountOut stays in the router as unspent value. - */ -function buildNativePoolModularActions( - signer: string, - routerAddress: string, - cfg: CaseConfig, - inputAmount: bigint, - feeAmount: bigint, - nativeFeeWithBuffer: bigint, - minAmountOut: bigint, - ooRouter: string, - swapData: string, - stargateData: string, -): ModularAction[] { - const ahIface = new ethers.Interface([ - 'function transferFrom(address token, address owner, address recipient, uint256 amount)', - ]); - const exec = new ModularActionsBuilder(); - - exec.call( - ALLOWANCE_HOLDER, - ahIface.encodeFunctionData('transferFrom', [cfg.inputToken, signer, routerAddress, inputAmount]), - ); - exec.call(cfg.inputToken, encodeApprove(ooRouter, inputAmount)); - exec.call(ooRouter, swapData); // USDC → native ETH lands in router - exec.nativeCall(signer, '0x', feeAmount); // post-swap fee in ETH - // Bridge: value = amountLD + nativeFeeWithBuffer = minAmountOut - feeAmount - const bridgeValue = minAmountOut - feeAmount; - exec.nativeCall(cfg.bridgeContract, stargateData, bridgeValue); - - return exec.toActions(); -} - -/** - * Modular for ERC20-pool case (case 2): - * [0] AH.transferFrom USDC - * [1] USDC.transfer(signer, fee) - * [2] USDC.approve(stargatePool, MaxUint256) - * [3] STATICCALL USDC.balanceOf(router) — return value spliced into [4] - * [4] nativeCall: Stargate send() with nativeFeeWithBuffer POL; - * splicePayloadWord(STARGATE_AMOUNT_LD_OFFSET): CALL_WITH_NATIVE data is - * [32-byte native value prefix][ethers send calldata]; amountLD stays at +196 - * within the payload slice (matches OpenOceanStargateNativeOpenRouterPoC.t.sol). - */ -function buildErc20PoolModularActions( - signer: string, - routerAddress: string, - cfg: CaseConfig, - inputAmount: bigint, - feeAmount: bigint, - nativeFeeWithBuffer: bigint, - stargateData: string, -): ModularAction[] { - const ahIface = new ethers.Interface([ - 'function transferFrom(address token, address owner, address recipient, uint256 amount)', - ]); - const exec = new ModularActionsBuilder(); - - exec.call( - ALLOWANCE_HOLDER, - ahIface.encodeFunctionData('transferFrom', [cfg.inputToken, signer, routerAddress, inputAmount]), - ); - exec.call(cfg.inputToken, encodeTransfer(signer, feeAmount)); // USDC fee to signer - exec.call(cfg.inputToken, encodeApprove(cfg.bridgeContract, ethers.MaxUint256)); - const usdcBalance = exec.staticCall(cfg.inputToken, encodeBalanceOf(routerAddress)); - exec - .nativeCall(cfg.bridgeContract, stargateData, nativeFeeWithBuffer) - .splicePayloadWord(BigInt(STARGATE_AMOUNT_LD_OFFSET), usdcBalance.returnWord()); - - return exec.toActions(); -} - -/** - * Fallback gas reserve used in `runCase` when splitting the balance into leg amounts. - * The actual safety budget used in `executeLeg` is derived dynamically from the - * provider's current `maxFeePerGas` (see `NATIVE_INPUT_GAS_LIMIT_ESTIMATE`). - */ -const NATIVE_INPUT_GAS_RESERVE = parseEther('0.01'); - -/** - * Estimated gas units for a native-input leg (generous upper bound covering the - * modular path with OO multi-hop swap + LZ OFT send on Polygon). - * Actual usage is ~1M–1.1M; using 2M × maxFeePerGas gives a comfortable ceiling. - */ -const NATIVE_INPUT_GAS_LIMIT_ESTIMATE = 2_000_000n; - -// ─── Monolithic/modular builders for case 4 ─────────────────────────────────── - -/** - * Monolithic for case 4 (native gas token input → OO swap to bridged ERC-20 → LZ `send` on adapter/pool): - * - inputToken = NATIVE_TOKEN_ADDRESS; swap.approvalSpender = 0 (no ERC20 approve needed) - * - swap.value = `ooSwapNativeWei` (OpenOcean `value` field when present; else quoted input wei) - * - postFee: router sends feeAmount of OO output token (e.g. USDT0) to signer - * - bridge: ERC-20 pool mechanics — splice post-fee balance into amountLD at offset 196 - * - * msg.value ≈ ooSwapNativeWei + nativeFeeWithBuffer (signer attaches full `txValue`; OO consumes POL/ETH swap leg). - */ -function buildNativeInErc20BridgeMonolithic( - signer: string, - cfg: CaseConfig, - inputAmount: bigint, - feeAmount: bigint, - minAmountOut: bigint, - ooRouter: string, - swapData: string, - stargateData: string, - nativeFeeWithBuffer: bigint, - ooSwapNativeWei: bigint, -): MonolithicExecutionCall { - const rawOoWei = ooSwapNativeWei > 0n ? ooSwapNativeWei : inputAmount; - const polOrEthToOo = rawOoWei <= inputAmount ? rawOoWei : inputAmount; - return { - exec: { - input: { user: signer, inputToken: NATIVE_TOKEN_ADDRESS, inputAmount }, - preFee: NO_FEE, - swap: { - target: ooRouter, - approvalSpender: ZERO_ADDRESS, // no ERC20 approve for native ETH input - outputToken: cfg.ooSwap!.outToken, - value: polOrEthToOo, - minOutput: minAmountOut, - returnDataWordOffset: 0n, - }, - postFee: { receiver: signer, amount: feeAmount }, // fee in OO output token (USDC/USDT0) - bridge: { - target: cfg.bridgeContract, - approvalSpender: cfg.bridgeContract, // router approves bridge contract to pull ERC20 - value: nativeFeeWithBuffer, // LZ fee in native gas token only - }, - flags: bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET), - }, - swapCallData: swapData, - bridgeCallData: stargateData, - }; -} - -/** - * Modular for case 4 (native gas token in → OO → ERC-20 out → LZ `send`): - * [0] nativeCall(ooRouter, swapData, ooSwapWei) — OO `value` when present else leg `inputAmount` - * … same ERC-20 fee / approve / splice as monolithic ERC20-pool bridge path. - * - * Input native is forwarded on the enclosing tx (`txValue`); no AH.transferFrom pull. - */ -function buildNativeInErc20BridgeModularActions( - signer: string, - routerAddress: string, - cfg: CaseConfig, - inputAmount: bigint, - feeAmount: bigint, - nativeFeeWithBuffer: bigint, - ooRouter: string, - swapData: string, - stargateData: string, - ooSwapNativeWei: bigint, -): ModularAction[] { - const exec = new ModularActionsBuilder(); - const outTokenAddr = cfg.ooSwap!.outToken; - const rawOoWei = ooSwapNativeWei > 0n ? ooSwapNativeWei : inputAmount; - const polOrEthToOo = rawOoWei <= inputAmount ? rawOoWei : inputAmount; - - exec.nativeCall(ooRouter, swapData, polOrEthToOo); - exec.call(outTokenAddr, encodeTransfer(signer, feeAmount)); - exec.call(outTokenAddr, encodeApprove(cfg.bridgeContract, ethers.MaxUint256)); - const tokenBal = exec.staticCall(outTokenAddr, encodeBalanceOf(routerAddress)); - exec - .nativeCall(cfg.bridgeContract, stargateData, nativeFeeWithBuffer) - .splicePayloadWord(BigInt(STARGATE_AMOUNT_LD_OFFSET), tokenBal.returnWord()); - - return exec.toActions(); -} - -// ─── Execution leg ──────────────────────────────────────────────────────────── - -/** - * Dispatches tx to router either as a signer→router `{ value }` call or wrapped in - * `AllowanceHolder.exec` (ERC-2771 suffix so `_msgSender()` resolves inside router). - * - * ERC20 `_pullFromUser` requires ephemeral AH allowance ⇒ `allowance-holder` only for non-native inputs. - */ -async function dispatchRouterTransaction( - route: RouterExecRoute, - cfg: CaseConfig, - signer: ethers.Signer, - routerAddress: string, - execCalldata: string, - inputAmount: bigint, - txValue: bigint, - nativeSymbol: string, -): Promise { - if (route === 'direct') { - console.log(`[exec=direct] ${ethers.formatEther(txValue)} ${nativeSymbol}`); - return execDirect(signer, routerAddress, execCalldata, txValue); - } - // allowance-holder — persistent ERC20→AH approval except for pure native pulls - if (!cfg.isNativeInput) { - await ensureAllowanceForAllowanceHolder(signer, cfg.inputToken, inputAmount); - } - console.log(`[exec=allowance-holder] ${ethers.formatEther(txValue)} ${nativeSymbol}`); - return execViaAH( - signer, - routerAddress, - cfg.inputToken, - inputAmount, - routerAddress, - execCalldata, - txValue, - ); -} - -/** - * Runs one monolithic or modular leg for a case. - * Fetches quotes, builds calldata, and executes via {@link dispatchRouterTransaction}. - */ -async function executeLeg( - legLabel: string, - useModular: boolean, - cfg: CaseConfig, - routerAddress: string, - signer: ethers.Wallet, - signerAddress: string, - provider: ethers.JsonRpcProvider, - inputAmount: bigint, - routerIface: ethers.Interface, - routerExec: RouterExecRoute, -): Promise { - console.log(`\n── ${legLabel} (${useModular ? 'MODULAR' : 'MONOLITHIC'}) ──`); - - const nativeSymbol = cfg.sourceChainId === CHAIN_IDS.POLYGON ? 'POL' : 'ETH'; - - let inputAmountWei = inputAmount; - - let feeAmount = 0n; - let minAmountOut = 0n; - let estimatedBridgeAmount = 0n; - let ooRouter = ''; - let swapData = ''; - let ooSwapNativeWei = 0n; - let nativeFeeWithBuffer = 0n; - let amountReceivedLD = 0n; - /** Last raw quote fee (logged before buffer). */ - let nativeFeeQuoted = 0n; - - /** - * Dynamic gas reserve for native-input cases: current maxFeePerGas × generous gas limit estimate. - * Fetched once before the loop so we don't hammer the RPC on each cap iteration. - * Falls back to a hardcoded minimum if fee data is unavailable. - */ - let gasReserve = NATIVE_INPUT_GAS_RESERVE; - if (cfg.isNativeInput) { - const feeData = await provider.getFeeData(); - const maxFeePerGas = feeData.maxFeePerGas ?? feeData.gasPrice ?? 500_000_000n; - gasReserve = maxFeePerGas * NATIVE_INPUT_GAS_LIMIT_ESTIMATE; - console.log( - ` Gas reserve (${NATIVE_INPUT_GAS_LIMIT_ESTIMATE / 1_000_000n}M gas × ${ethers.formatUnits(maxFeePerGas, 'gwei')} Gwei): ` + - `${ethers.formatEther(gasReserve)} ${nativeSymbol}`, - ); - } - - const MAX_NATIVE_INPUT_CAP_ITER = 6; - - let iter = 0; - for (;;) { - iter++; - if (iter > MAX_NATIVE_INPUT_CAP_ITER) { - throw new Error( - `${cfg.name}: native swap budgeting hit ${MAX_NATIVE_INPUT_CAP_ITER} re-quote iterations; top up native balance or widen gas reserve.`, - ); - } - - if (cfg.ooSwap !== null) { - const swapOutIsNative = cfg.ooSwap.outToken.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); - const swapOutIsUsdt0 = cfg.ooSwap.outToken.toLowerCase() === TOKENS.USDT0_POLYGON.toLowerCase(); - const swapOutLabel = swapOutIsNative - ? cfg.sourceChainId === CHAIN_IDS.POLYGON - ? 'POL' - : 'ETH' - : swapOutIsUsdt0 - ? 'USDT0' - : 'USDC'; - const swapOutDecimals = swapOutIsNative ? 18 : 6; - const fmtSwapOut = (v: bigint) => - swapOutIsNative ? ethers.formatEther(v) : ethers.formatUnits(v, swapOutDecimals); - - console.log(`Fetching OpenOcean quote (${cfg.ooSwap.inToken} → ${swapOutLabel})...`); - const q = await fetchOoQuote(cfg.ooSwap, routerAddress, inputAmountWei); - ooRouter = q.ooRouter; - swapData = q.swapData; - ooSwapNativeWei = q.nativeSwapWei; - feeAmount = bpsOf(q.estimatedOut, FEE_BPS); - estimatedBridgeAmount = q.estimatedOut - feeAmount; - minAmountOut = q.minAmountOut; - - console.log(` OO router: ${ooRouter}`); - console.log(` Est. out: ${fmtSwapOut(q.estimatedOut)} ${swapOutLabel}`); - console.log(` Fee: ${fmtSwapOut(feeAmount)} ${swapOutLabel} (${FEE_BPS} bps)`); - console.log(` Min out: ${fmtSwapOut(minAmountOut)} ${swapOutLabel}`); - if (ooSwapNativeWei > 0n) { - console.log(` OO swap value wei: ${ooSwapNativeWei.toString()} (attached to OO call)`); - } - } else { - // Case 2: no OO swap — bridge entire balance minus fee - feeAmount = bpsOf(inputAmountWei, FEE_BPS); - estimatedBridgeAmount = inputAmountWei - feeAmount; - console.log(` Fee: ${ethers.formatUnits(feeAmount, 6)} USDC (${FEE_BPS} bps)`); - } - - console.log(`Fetching bridge quoteSend (${cfg.bridgeContract}, extraOpts ${cfg.lzExtraOptions.slice(0, 18)}...) ...`); - const lzQuote = await fetchStargateQuote( - cfg.bridgeContract, - provider, - cfg.destLzEid, - signerAddress, - estimatedBridgeAmount, - cfg.lzExtraOptions, - ); - nativeFeeQuoted = lzQuote.nativeFee; - nativeFeeWithBuffer = (lzQuote.nativeFee * 105n) / 100n; - amountReceivedLD = lzQuote.amountReceivedLD; - - console.log(` nativeFee: ${ethers.formatEther(nativeFeeQuoted)} ${nativeSymbol}`); - console.log(` nativeFee +5%buf: ${ethers.formatEther(nativeFeeWithBuffer)} ${nativeSymbol}`); - console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, cfg.isNativePool ? 18 : 6)}`); - - if (!cfg.isNativeInput) { - break; - } - - const balNow = await provider.getBalance(signerAddress); - // maxAffordableSwapIn = balance we can put into the swap leg so that - // txValue (= inputAmountWei + nativeFeeWithBuffer) + gas cost ≤ balance - const maxAffordableSwapIn = balNow - nativeFeeWithBuffer - gasReserve; - if (maxAffordableSwapIn <= 0n) { - throw new Error( - `${cfg.name}: signer native balance (${ethers.formatEther(balNow)} ${nativeSymbol}) cannot cover lz fee ` + - `(${ethers.formatEther(nativeFeeWithBuffer)} ${nativeSymbol}) plus gas reserve ` + - `(${ethers.formatEther(gasReserve)} ${nativeSymbol}).`, - ); - } - if (inputAmountWei <= maxAffordableSwapIn) { - break; - } - - console.warn( - `[${legLabel}] capping ${nativeSymbol} swap input: planned ${ethers.formatEther(inputAmountWei)} ` + - `exceeds max affordable ${ethers.formatEther(maxAffordableSwapIn)} (balance − lz fee − gas reserve). Re-quoting.`, - ); - inputAmountWei = maxAffordableSwapIn; - } - - // ── State prep for reproducible gas ───────────────────────────────────────── - // Determine the token the router will approve to the bridge contract (null for native pools). - const bridgeToken: string | null = - cfg.isNativePool - ? null - : cfg.ooSwap !== null - ? cfg.ooSwap.outToken - : cfg.inputToken; - - if (!cfg.isNativeInput) { - await ensureRouterErc20Balance(signer, cfg.inputToken, routerAddress); - } - if (cfg.isNativeInput || cfg.isNativePool || (cfg.ooSwap && cfg.ooSwap.outToken.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase())) { - await ensureRouterNativeBalance(signer, routerAddress); - } - if (cfg.ooSwap && cfg.ooSwap.outToken.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()) { - await ensureRouterErc20Balance(signer, cfg.ooSwap.outToken, routerAddress); - } - if (cfg.ooSwap && !cfg.isNativeInput) { - await ensureRouterApproval(signer, routerAddress, cfg.inputToken, ooRouter); - } - if (bridgeToken && bridgeToken.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()) { - await ensureRouterApproval(signer, routerAddress, bridgeToken, cfg.bridgeContract); - } - // ──────────────────────────────────────────────────────────────────────────── - - // Native pool: use estimatedBridgeAmount as placeholder; router splices actual finalETH at runtime. - // ERC20 pool: 0n placeholder; router splices actual post-fee balance at runtime. - const amountLD = cfg.isNativePool ? estimatedBridgeAmount : 0n; - - const stargateData = buildStargateCalldata(cfg.destLzEid, nativeFeeWithBuffer, signerAddress, amountLD, cfg.lzExtraOptions); - - let execCalldata: string; - if (useModular) { - let actions: ModularAction[]; - if (cfg.isNativePool) { - actions = buildNativePoolModularActions( - signerAddress, routerAddress, cfg, inputAmountWei, feeAmount, nativeFeeWithBuffer, - minAmountOut, ooRouter, swapData, stargateData, - ); - } else if (cfg.isNativeInput) { - actions = buildNativeInErc20BridgeModularActions( - signerAddress, routerAddress, cfg, inputAmountWei, feeAmount, nativeFeeWithBuffer, - ooRouter, swapData, stargateData, ooSwapNativeWei, - ); - } else { - actions = buildErc20PoolModularActions( - signerAddress, routerAddress, cfg, inputAmountWei, feeAmount, nativeFeeWithBuffer, stargateData, - ); - } - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ZERO_BYTES32, actions]); - } else { - let mono: MonolithicExecutionCall; - if (cfg.isNativePool) { - mono = buildNativePoolMonolithic( - signerAddress, cfg, inputAmountWei, feeAmount, minAmountOut, - ooRouter, swapData, stargateData, nativeFeeWithBuffer, - ); - } else if (cfg.isNativeInput) { - mono = buildNativeInErc20BridgeMonolithic( - signerAddress, cfg, inputAmountWei, feeAmount, minAmountOut, - ooRouter, swapData, stargateData, nativeFeeWithBuffer, ooSwapNativeWei, - ); - } else { - mono = buildErc20PoolMonolithic( - signerAddress, cfg, inputAmountWei, feeAmount, stargateData, nativeFeeWithBuffer, - ); - } - execCalldata = routerIface.encodeFunctionData('performExecution', monolithicArgs(mono)); - } - - const txValue = cfg.isNativeInput ? inputAmountWei + nativeFeeWithBuffer : nativeFeeWithBuffer; - - const receipt = await dispatchRouterTransaction( - routerExec, - cfg, - signer, - routerAddress, - execCalldata, - inputAmountWei, - txValue, - nativeSymbol, - ); - - logTxnSummary(`${cfg.name} — ${useModular ? 'Modular' : 'Monolithic'}`, cfg.sourceChainId, receipt); -} - -// ─── Run one case (monolithic + sleep + modular) ────────────────────────────── - -async function runCase( - cfg: CaseConfig, - signer: ethers.Wallet, - signerAddress: string, - routerIface: ethers.Interface, - routerExec: RouterExecRoute, -): Promise { - const routerAddress = routerAddressForChain(cfg.sourceChainId); - console.log(`\n${'═'.repeat(70)}`); - console.log(`CASE: ${cfg.name}`); - console.log('═'.repeat(70)); - console.log(`Router (chain ${cfg.sourceChainId}): ${routerAddress}`); - console.log(`Router exec route: ${routerExec}`); - - const provider = new ethers.JsonRpcProvider(cfg.rpc); - const signerOnChain = signer.connect(provider); - - let walletBalance: bigint; - let decimals: number; - if (cfg.isNativeInput) { - const raw = await provider.getBalance(signerAddress); - if (raw <= NATIVE_INPUT_GAS_RESERVE) { - const sym = cfg.sourceChainId === CHAIN_IDS.POLYGON ? 'POL' : 'ETH'; - throw new Error( - `${cfg.name}: native balance ${ethers.formatEther(raw)} ${sym} is below reserve of ${ethers.formatEther(NATIVE_INPUT_GAS_RESERVE)} ${sym}.`, - ); - } - // Reserve wei for signer gas; lz fee itself is deducted inside executeLeg (`txValue = swap + fee`). - walletBalance = raw - NATIVE_INPUT_GAS_RESERVE - 20n; - decimals = 18; - } else { - ({ balance: walletBalance, decimals } = await getWalletErc20Balance( - cfg.inputToken, - signerAddress, - provider, - )); - } - if (walletBalance === 0n) { - throw new Error( - `${cfg.name}: signer ${signerAddress} has zero usable balance of ${cfg.inputToken} on chain ${cfg.sourceChainId}.`, - ); - } - - const legAmount = cfg.isNativeInput ? walletBalance / 2n : (walletBalance - 20n) / 2n; - if (legAmount === 0n) { - throw new Error(`${cfg.name}: balance too small to split into two halves.`); - } - - console.log(`Input token balance: ${ethers.formatUnits(walletBalance, decimals)} (${cfg.inputToken})`); - console.log(`Per leg (½): ${ethers.formatUnits(legAmount, decimals)}`); - - await executeLeg('1/2', false, cfg, routerAddress, signerOnChain, signerAddress, provider, legAmount, routerIface, routerExec); - - console.log('\nSleeping 3s before modular leg...'); - await sleep(3000); - - await executeLeg('2/2', true, cfg, routerAddress, signerOnChain, signerAddress, provider, legAmount, routerIface, routerExec); -} - -// ─── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - const privateKey = process.env.PRIVATE_KEY; - if (!privateKey) { - throw new Error('PRIVATE_KEY env var required'); - } - - const cfg = resolveScenarioConfig(); - const routerExec = resolveRouterExecRoute(cfg); - - // Use any provider to create the wallet; the case reconnects via `runCase`. - const signer = new ethers.Wallet(privateKey); - const signerAddress = await signer.getAddress(); - const routerIface = new ethers.Interface(ROUTER_ABI); - - console.log(`Signer: ${signerAddress}`); - console.log(`Router: ${routerAddressForChain(cfg.sourceChainId)} (chain ${cfg.sourceChainId})`); - console.log(`Scenario: ${process.argv[2] ?? process.env.STARGATE_E2E_CASE ?? '(resolved)'}`); - console.log(`Exec: ${routerExec} (argv[3] overrides STARGATE_ROUTER_EXEC; required for native input)`); - - await runCase(cfg, signer, signerAddress, routerIface, routerExec); - - console.log('\n✓ Stargate case completed.'); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/e2e/utils/allowanceHolder.ts b/scripts/e2e/utils/allowanceHolder.ts index 3119ba7..aab6a1e 100644 --- a/scripts/e2e/utils/allowanceHolder.ts +++ b/scripts/e2e/utils/allowanceHolder.ts @@ -114,11 +114,11 @@ export async function execViaAH( * AllowanceHolder. Use this when the input token is native ETH/POL — the router's * `_pullFromUser` path for native tokens only checks `msg.value >= amount` and does * NOT enforce `_msgSender() == user` nor call `AH.transferFrom`. For modular - * execution (`performModularExecution`) there is no `_pullFromUser` at all. + * execution (`performActions`) there is no `_pullFromUser` at all. * * @param signer - EOA signing and paying for the tx * @param target - Router contract address - * @param callData - Encoded `performExecution` or `performModularExecution` calldata + * @param callData - Encoded router entrypoint calldata (`swap`, `bridge`, `performActions`, etc.) * @param txValue - ETH to forward (inputAmount + nativeFeeWithBuffer for native input) */ export async function execDirect( diff --git a/scripts/e2e/utils/contractTypes.ts b/scripts/e2e/utils/contractTypes.ts index 9f5dfac..a55c4cb 100644 --- a/scripts/e2e/utils/contractTypes.ts +++ b/scripts/e2e/utils/contractTypes.ts @@ -1,11 +1,8 @@ /** - * TypeScript interfaces that mirror every Solidity struct in - * Combined unchecked router. The order and field names must match the ABI - * produced by the compiler so that ethers.js can encode them correctly. + * TypeScript interfaces mirroring BungeeOpenRouter Solidity structs. + * Field names and order must match the compiler ABI encoding. */ -// ─── Monolithic execution types ─────────────────────────────────────────────── - export interface InputData { user: string; inputToken: string; @@ -32,30 +29,22 @@ export interface BridgeData { value: bigint; } -export interface MonolithicExecution { - input: InputData; - preFee: FeeData; - swap: SwapData; - postFee: FeeData; - bridge: BridgeData; - flags: bigint; -} - -export interface MonolithicExecutionCall { - exec: MonolithicExecution; - swapCallData: string; - bridgeCallData: string; -} - -export const BRIDGE_VALUE_FLAG = 4n; -export const BRIDGE_AMOUNT_POSITION_FLAG = 8n; +export const POST_FEE_FLAG = 0x01n; +export const BALANCE_FLAG = 0x02n; +export const BRIDGE_VALUE_FLAG = 0x04n; +export const BRIDGE_AMOUNT_POSITION_FLAG = 0x08n; export const BRIDGE_AMOUNT_POSITION_SHIFT = 16n; export const MAX_BRIDGE_AMOUNT_POSITION = 0xffffn; -/** 32-byte zero; use as `requestHash` when scripts do not assign a request id. */ +/** 32-byte zero; use as `quoteId` when scripts do not assign a correlation id. */ export const ZERO_BYTES32 = '0x0000000000000000000000000000000000000000000000000000000000000000' as const; +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +/** Convenience: empty fee (no fee taken) */ +export const NO_FEE: FeeData = { receiver: ZERO_ADDRESS, amount: 0n }; + export function bridgeAmountPositionFlag(position: bigint | number): bigint { const positionBigInt = BigInt(position); if (positionBigInt < 0n || positionBigInt > MAX_BRIDGE_AMOUNT_POSITION) { @@ -64,26 +53,53 @@ export function bridgeAmountPositionFlag(position: bigint | number): bigint { return BRIDGE_AMOUNT_POSITION_FLAG | (positionBigInt << BRIDGE_AMOUNT_POSITION_SHIFT); } -export function monolithicArgs( - call: MonolithicExecutionCall, - requestHash: string = ZERO_BYTES32, -): readonly [string, MonolithicExecution, string, string] { - return [requestHash, call.exec, call.swapCallData, call.bridgeCallData] as const; +export function swapArgs( + quoteId: string, + flags: bigint, + input: InputData, + fee: FeeData, + swapData: SwapData, + swapCallData: string, + receiver: string, +): readonly [string, bigint, InputData, FeeData, SwapData, string, string] { + return [quoteId, flags, input, fee, swapData, swapCallData, receiver] as const; } -// ─── Sentinel / zero helpers ────────────────────────────────────────────────── - -export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +export function swapAndBridgeArgs( + quoteId: string, + flags: bigint, + input: InputData, + fee: FeeData, + swapData: SwapData, + swapCallData: string, + bridgeData: BridgeData, + bridgeCallData: string, +): readonly [ + string, + bigint, + InputData, + FeeData, + SwapData, + string, + BridgeData, + string, +] { + return [quoteId, flags, input, fee, swapData, swapCallData, bridgeData, bridgeCallData] as const; +} -/** Convenience: empty fee (no fee taken) */ -export const NO_FEE: FeeData = { receiver: ZERO_ADDRESS, amount: 0n }; +export function bridgeArgs( + quoteId: string, + input: InputData, + fee: FeeData, + bridgeData: BridgeData, + bridgeCallData: string, +): readonly [string, InputData, FeeData, BridgeData, string] { + return [quoteId, input, fee, bridgeData, bridgeCallData] as const; +} -/** Convenience: empty swap (skip swap step) */ -export const NO_SWAP: SwapData = { - target: ZERO_ADDRESS, - approvalSpender: ZERO_ADDRESS, - outputToken: ZERO_ADDRESS, - value: 0n, - minOutput: 0n, - returnDataWordOffset: 0n, -}; +export function performActionsArgs( + quoteId: string, + actions: { actionInfo: bigint | string; data: string; splices: (bigint | string)[] }[], +): readonly [string, typeof actions] { + return [quoteId, actions] as const; +} diff --git a/scripts/e2e/utils/modularActionsBuilder/README.md b/scripts/e2e/utils/modularActionsBuilder/README.md index 81211e3..3768bc6 100644 --- a/scripts/e2e/utils/modularActionsBuilder/README.md +++ b/scripts/e2e/utils/modularActionsBuilder/README.md @@ -1,6 +1,6 @@ # Modular Actions Builder -Dependency-free helper for formatting packed `performModularExecution(Action[])` +Dependency-free helper for formatting packed `performActions(Action[])` payloads from provider SDK/API calldata. ```js @@ -102,4 +102,4 @@ exec Use `toActions()` when the caller already has an ABI encoder for the packed modular action tuple. Use `toLogicalActions()` for the readable builder shape. Use `toCalldata()` when you need raw -`performModularExecution(Action[])` calldata. +`performActions(Action[])` calldata. diff --git a/scripts/e2e/utils/modularActionsBuilder/index.d.ts b/scripts/e2e/utils/modularActionsBuilder/index.d.ts index 06b6121..22a492e 100644 --- a/scripts/e2e/utils/modularActionsBuilder/index.d.ts +++ b/scripts/e2e/utils/modularActionsBuilder/index.d.ts @@ -35,7 +35,9 @@ export interface ModularAction { export type Action = LogicalAction; -export declare const PERFORM_MODULAR_EXECUTION_SELECTOR: "0x4f85c3a5"; +export declare const PERFORM_ACTIONS_SELECTOR: "0x197aa51e"; +/** @deprecated Use PERFORM_ACTIONS_SELECTOR */ +export declare const PERFORM_MODULAR_EXECUTION_SELECTOR: "0x197aa51e"; export declare const CallType: Readonly<{ CALL: 0; @@ -97,6 +99,8 @@ export declare class ActionRef { } export declare function concatHex(values: Hex[]): Hex; +export declare function encodePerformActionsArgs(actions: Array): Hex; +/** @deprecated Use encodePerformActionsArgs */ export declare function encodePerformModularExecutionArgs(actions: Array): Hex; export declare function encodeWord(value: BigNumberish): Hex; export declare function packActionInfo(action: Pick): bigint; diff --git a/scripts/e2e/utils/modularActionsBuilder/index.js b/scripts/e2e/utils/modularActionsBuilder/index.js index b0ba0d2..4c52612 100644 --- a/scripts/e2e/utils/modularActionsBuilder/index.js +++ b/scripts/e2e/utils/modularActionsBuilder/index.js @@ -1,6 +1,8 @@ "use strict"; -const PERFORM_MODULAR_EXECUTION_SELECTOR = "0x4f85c3a5"; +const PERFORM_ACTIONS_SELECTOR = "0x197aa51e"; +/** @deprecated Use PERFORM_ACTIONS_SELECTOR */ +const PERFORM_MODULAR_EXECUTION_SELECTOR = PERFORM_ACTIONS_SELECTOR; const WORD_BYTES = 32; const WORD_HEX_CHARS = WORD_BYTES * 2; const UINT256_MAX = (1n << 256n) - 1n; @@ -108,7 +110,7 @@ class ModularActionsBuilder { toCalldata() { this._markSpliceSources(); - return concatHex([PERFORM_MODULAR_EXECUTION_SELECTOR, encodePerformModularExecutionArgs(this._actions)]); + return concatHex([PERFORM_ACTIONS_SELECTOR, encodePerformActionsArgs(this._actions)]); } _label(index, label) { @@ -225,10 +227,15 @@ class ActionRef { } } -function encodePerformModularExecutionArgs(actions) { +function encodePerformActionsArgs(actions) { return concatHex([encodeWord(WORD_BYTES), encodeActionArray(prepareActionsForEncoding(actions))]); } +/** @deprecated Use encodePerformActionsArgs */ +function encodePerformModularExecutionArgs(actions) { + return encodePerformActionsArgs(actions); +} + function encodeActionArray(actions) { const encodedActions = actions.map(encodeActionTuple); let nextOffset = WORD_BYTES * actions.length; @@ -434,10 +441,12 @@ module.exports = { ActionHandle, ActionRef, CallType, + PERFORM_ACTIONS_SELECTOR, PERFORM_MODULAR_EXECUTION_SELECTOR, Offset, ModularActionsBuilder, concatHex, + encodePerformActionsArgs, encodePerformModularExecutionArgs, encodeWord, packActionInfo, diff --git a/scripts/e2e/utils/reproducibility.ts b/scripts/e2e/utils/reproducibility.ts index 9db88d0..6e34f9f 100644 --- a/scripts/e2e/utils/reproducibility.ts +++ b/scripts/e2e/utils/reproducibility.ts @@ -6,24 +6,21 @@ * * Before each test leg these ensure: * 1. The router holds ≥ 20 wei of every token whose balance slot will be written. - * 2. The router has a non-zero ERC-20 allowance for every external spender it will call. * - * Seeding slots to non-zero means subsequent SSTORE writes cost ~2 900 gas + * Router→spender ERC-20 approvals are NOT pre-seeded here. `BungeeOpenRouter` + * sets max allowance inside `swap`, `bridge`, and `swapAndBridge` when needed. + * Modular `performActions` legs may still include inline `approve` actions in the + * same transaction when testing raw modular flows. + * + * Seeding balance slots to non-zero means subsequent SSTORE writes cost ~2 900 gas * (non-zero → non-zero) rather than ~20 000 gas (zero → non-zero), giving * consistent gas readings across repeated runs. */ import { ethers } from 'ethers'; -import { getErc20Contract, encodeApprove } from './erc20'; -import { execViaAH } from './allowanceHolder'; -import { ROUTER_ABI } from './routerAbi'; -import { ZERO_BYTES32 } from './contractTypes'; +import { getErc20Contract } from './erc20'; const SEED_WEI = 20n; -function packCallAction(target: string): bigint { - return BigInt(target) << 16n; // CallType.CALL=0, storeResult=false -} - /** * Transfers {@link SEED_WEI} of `token` from `signer` to the deployed open router only * when that router already holds zero — never to Relay/deposit/spender contracts. @@ -70,43 +67,16 @@ export async function ensureRouterNativeBalance( } /** - * Issues `token.approve(spender, MaxUint256)` FROM the router (via - * `performModularExecution`) when the current router→spender allowance is zero. + * No-op: router→spender approvals are handled by the contract on `swap` / + * `bridge` / `swapAndBridge`. Kept so existing e2e scripts do not need rewrites. * - * Guarantees the allowance slot is non-zero before the test txn so that the - * approval write inside the test costs ~2 900 gas (non-zero → non-zero). + * @deprecated Pre-approval via a separate `performActions` tx is intentionally disabled. */ export async function ensureRouterApproval( - signer: ethers.Wallet, - openRouterAddress: string, - token: string, - spender: string, + _signer: ethers.Wallet, + _openRouterAddress: string, + _token: string, + _spender: string, ): Promise { - const openRouter = ethers.getAddress(openRouterAddress); - const tokenResolved = ethers.getAddress(token); - const spenderResolved = ethers.getAddress(spender); - const tokenRo = getErc20Contract(tokenResolved, signer.provider!); - const allowance = BigInt(await tokenRo.allowance(openRouter, spenderResolved)); - if (allowance > 0n) { - return; - } - - console.log( - ` [state-prep] open router ${openRouter} token ${tokenResolved} allowance for ${spenderResolved}=0 — pre-approving MaxUint256 via open router`, - ); - const routerIface = new ethers.Interface(ROUTER_ABI); - const actions = [ - { - actionInfo: packCallAction(tokenResolved), - data: encodeApprove(spenderResolved, ethers.MaxUint256), - splices: [], - }, - ]; - const calldata = routerIface.encodeFunctionData('performModularExecution', [ - ZERO_BYTES32, - actions, - ]); - // Route through AllowanceHolder so _msgSender() resolves correctly inside the router. - // amount=0 because we are not pulling user tokens — we only need AH to forward the call. - await execViaAH(signer, openRouter, tokenResolved, 0n, openRouter, calldata); + // Intentionally empty — see module header. } diff --git a/scripts/e2e/utils/routerAbi.ts b/scripts/e2e/utils/routerAbi.ts index 2caa0e0..03dc419 100644 --- a/scripts/e2e/utils/routerAbi.ts +++ b/scripts/e2e/utils/routerAbi.ts @@ -1,45 +1,27 @@ /** - * ABI fragment for the combined unchecked router — only the two entrypoints - * called from e2e scripts. Structs must exactly match the Solidity definitions. + * ABI fragments for BungeeOpenRouter entrypoints used by e2e scripts. + * Struct field order must match the Solidity definitions. */ export const ROUTER_ABI = [ - // Monolithic path — `requestHash` is first for indexer-friendly calldata layout - `function performExecution( - bytes32 requestHash, - ( - (address user, address inputToken, uint256 inputAmount) input, - (address receiver, uint256 amount) preFee, - (address target, address approvalSpender, address outputToken, uint256 value, uint256 minOutput, uint256 returnDataWordOffset) swap, - (address receiver, uint256 amount) postFee, - (address target, address approvalSpender, uint256 value) bridge, - uint256 flags - ) exec, - bytes swapCallData, - bytes bridgeCallData - ) external payable`, - - // Modular path - `function performModularExecution( - bytes32 requestHash, + `function performActions( + bytes32 quoteId, (uint256 actionInfo, bytes data, uint256[] splices)[] actions ) external payable`, - // Standalone swap — pull, optional fee, swap; returns finalAmount `function swap( - bytes32 requestHash, - (address user, address inputToken, uint256 inputAmount) input, - address receiver, + bytes32 quoteId, uint256 flags, + (address user, address inputToken, uint256 inputAmount) input, (address receiver, uint256 amount) fee, (address target, address approvalSpender, address outputToken, uint256 value, uint256 minOutput, uint256 returnDataWordOffset) swapData, - bytes swapCallData + bytes swapCallData, + address receiver ) external payable returns (uint256)`, - // Swap + bridge — pull, optional fee, swap, then bridge with optional amount splicing `function swapAndBridge( - bytes32 requestHash, - (address user, address inputToken, uint256 inputAmount) input, + bytes32 quoteId, uint256 flags, + (address user, address inputToken, uint256 inputAmount) input, (address receiver, uint256 amount) fee, (address target, address approvalSpender, address outputToken, uint256 value, uint256 minOutput, uint256 returnDataWordOffset) swapData, bytes swapCallData, @@ -47,9 +29,8 @@ export const ROUTER_ABI = [ bytes bridgeCallData ) external payable`, - // Simple bridge path (no swap, no splicing — caller pre-encodes finalAmount into data) `function bridge( - bytes32 requestHash, + bytes32 quoteId, (address user, address inputToken, uint256 inputAmount) input, (address receiver, uint256 amount) fee, (address target, address approvalSpender, uint256 value) bridgeData, diff --git a/test/poc/OneInchCctpOpenRouterPoC.t.sol b/test/poc/OneInchCctpOpenRouterPoC.t.sol index 82e7f61..8065379 100644 --- a/test/poc/OneInchCctpOpenRouterPoC.t.sol +++ b/test/poc/OneInchCctpOpenRouterPoC.t.sol @@ -41,6 +41,7 @@ contract OneInchCctpOpenRouterPoCTest is Test { uint32 internal constant BASE_CCTP_DOMAIN = 6; uint256 internal constant CCTP_MAX_FEE = 0x2710; uint32 internal constant CCTP_MIN_FINALITY_THRESHOLD = 1000; + string internal constant SOCKET_GATEWAY_REFERENCE_CALLDATA_PREFIX = "0x000001ad4db9cf6a00000000000000000000000000000000000000000000000000000000000001a60000000000000000000000000000000000000000000000000000000000000120000000000000000000000000b0bbff6311b7f245761A7846d3Ce7B1b100C1836000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000021050000000000000000000000000000000000000000000000000000000000007530000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000013e4ee8f0b86000000000000000000000000d6df932a45c0f255f85145f286ea0b292b21c90b0000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000002d169fe80174000000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000001304"; string internal constant SOCKET_GATEWAY_REFERENCE_CALLDATA_SUFFIX = @@ -91,7 +92,7 @@ contract OneInchCctpOpenRouterPoCTest is Test { _assertPocResult(router, feeRecipientUsdcBefore, usdcSupplyBefore); } - function test_oneInchSwapCctpBridgeMonolithic_polygonFork() public { + function test_oneInchSwapCctpBridgeSwapAndBridge_polygonFork() public { string memory rpcUrl = vm.envOr("POLYGON_RPC", string("")); if (bytes(rpcUrl).length != 0) { uint256 forkBlock = vm.envOr("POLYGON_FORK_BLOCK", FORK_BLOCK_NUMBER); @@ -127,7 +128,7 @@ contract OneInchCctpOpenRouterPoCTest is Test { uint256 executeGasUsed = gasBeforeExecute - gasleft(); emit log_named_uint("AllowanceHolder.exec -> router.swapAndBridge gas used", executeGasUsed); - _assertMonolithicPocResult(router, feeRecipientUsdcBefore, usdcSupplyBefore); + _assertPocResult(router, feeRecipientUsdcBefore, usdcSupplyBefore); } function test_oneInchSwapCctpBridgeSocketGatewayReference_polygonFork() public { @@ -262,17 +263,6 @@ contract OneInchCctpOpenRouterPoCTest is Test { assertEq(ERC20(POLYGON_USDC).balanceOf(address(router)), 0); } - function _assertMonolithicPocResult(Router router, uint256 feeRecipientUsdcBefore, uint256 usdcSupplyBefore) - internal - view - { - assertEq(ERC20(POLYGON_USDC).balanceOf(FEE_RECIPIENT) - feeRecipientUsdcBefore, ROUTE_FEE_USDC); - assertEq(ERC20(POLYGON_USDC).totalSupply(), usdcSupplyBefore - EXPECTED_CCTP_BURN_AMOUNT); - assertEq(ERC20(POLYGON_AAVE).balanceOf(FIXTURE_RECIPIENT), 0); - assertEq(ERC20(POLYGON_AAVE).balanceOf(address(router)), 0); - assertEq(ERC20(POLYGON_USDC).balanceOf(address(router)), 0); - } - function _assertSocketGatewayPocResult(uint256 feeRecipientUsdcBefore, uint256 usdcSupplyBefore) internal view { assertEq( ERC20(POLYGON_AAVE).allowance(FIXTURE_RECIPIENT, FIXTURE_ROUTER), REFERENCE_GATEWAY_REMAINING_AAVE_ALLOWANCE diff --git a/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol b/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol index 74e951b..691ae10 100644 --- a/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol +++ b/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol @@ -38,6 +38,7 @@ contract OpenOceanAcrossOpenRouterPoCTest is Test { uint256 internal constant FORK_BLOCK_TIMESTAMP = 0x6a01d6d1; uint256 internal constant SWAP_INPUT_USDC = 0x1640325; uint256 internal constant DEFAULT_ACROSS_BRIDGE_FEE = 1; + string internal constant OPENOCEAN_SWAP_CALLDATA = "0x0a9704d5000000000000000000000000b100a5b2591dd099040a5ab76efe682a6d8a48a2000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000b100a5b2591dd099040a5ab76efe682a6d8a48a20000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000000000000000000000000000000000000001640325000000000000000000000000000000000000000000000000002002d5154237f3000000000000000000000000000000000000000000000000000000000000000200000000000000000000000038c7720238a2c123814aaf1a3d0e31e0093af04600000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000104e5b07cdb0000000000000000000000007fcdc35463e3770c2fb992716cd070b63540b94700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001640325000000000000000000000000b100a5b2591dd099040a5ab76efe682a6d8a48a200000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002eaf88d065e77c8cc2239327c5edb3a432268e583100006482af49447d8a07e3bd95bd0d56f35241523fbab100000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000648a6a1e8500000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000922164bbbd36acf9e854acbbf32facc949fcaeef00020000000000000000000000000000000000000000000000239364a56cb36600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001a49f86542200000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000001000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000064d1660f9900000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab10000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; From 5a2b25ac65932272840cb0736b2cab2ada9d19aa Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 20:29:38 +0530 Subject: [PATCH 32/42] test: set approvalSpender zero if enough allowance --- .../e2e/arbitrum/performExecution.postFee.ts | 14 ++- .../performModularExecution.postFee.ts | 8 +- scripts/e2e/cctp/bridge.preFee.ts | 14 ++- scripts/e2e/cctp/performExecution.postFee.ts | 24 +++- scripts/e2e/cctp/performExecution.preFee.ts | 14 ++- .../cctp/performModularExecution.postFee.ts | 19 ++- .../cctp/performModularExecution.preFee.ts | 17 ++- .../cctp/swapAndBridge.postFee.balanceOf.ts | 23 ++-- ...pAndBridge.postFee.returndata.kyberswap.ts | 23 ++-- .../cctp/swapAndBridge.postFee.returndata.ts | 23 ++-- .../cctp/swapAndBridge.preFee.balanceOf.ts | 23 ++-- .../cctp/swapAndBridge.preFee.returndata.ts | 23 ++-- scripts/e2e/config.ts | 2 +- scripts/e2e/oft/bridge.preFee.ts | 14 ++- scripts/e2e/oft/performExecution.postFee.ts | 23 ++-- scripts/e2e/oft/performExecution.preFee.ts | 14 ++- .../oft/performModularExecution.postFee.ts | 19 ++- .../e2e/oft/performModularExecution.preFee.ts | 16 ++- .../oft/swapAndBridge.postFee.balanceOf.ts | 23 ++-- .../oft/swapAndBridge.postFee.returndata.ts | 23 ++-- .../e2e/oft/swapAndBridge.preFee.balanceOf.ts | 23 ++-- .../oft/swapAndBridge.preFee.returndata.ts | 23 ++-- scripts/e2e/relay/aave.bridge.preFee.ts | 14 ++- .../e2e/relay/aave.performExecution.preFee.ts | 14 ++- .../aave.performModularExecution.preFee.ts | 8 +- scripts/e2e/relay/usdc.bridge.preFee.ts | 14 ++- .../e2e/relay/usdc.performExecution.preFee.ts | 14 ++- .../usdc.performModularExecution.preFee.ts | 8 +- ...arbUsdcBaseEth.performExecution.postFee.ts | 14 ++- ...BaseEth.performModularExecution.postFee.ts | 8 +- ...baseUsdcArbEth.performExecution.postFee.ts | 15 ++- ...cArbEth.performModularExecution.postFee.ts | 8 +- ...gonPolUsdt0Arb.performExecution.postFee.ts | 14 ++- ...sdt0Arb.performModularExecution.postFee.ts | 16 ++- .../stargate/polygonUsdcBase.bridge.preFee.ts | 14 ++- ...olygonUsdcBase.performExecution.postFee.ts | 14 ++- ...sdcBase.performModularExecution.postFee.ts | 16 ++- .../swapAndBridge.postFee.balanceOf.ts | 15 ++- .../swapAndBridge.postFee.returndata.ts | 15 ++- .../swapAndBridge.preFee.balanceOf.ts | 15 ++- .../swapAndBridge.preFee.returndata.ts | 15 ++- .../e2e/swap/kyberswap.postFee.balanceOf.ts | 12 +- .../e2e/swap/kyberswap.postFee.returndata.ts | 12 +- .../e2e/swap/kyberswap.preFee.balanceOf.ts | 12 +- .../e2e/swap/kyberswap.preFee.returndata.ts | 12 +- scripts/e2e/swap/swap.postFee.balanceOf.ts | 14 ++- scripts/e2e/swap/swap.postFee.returndata.ts | 14 ++- scripts/e2e/swap/swap.preFee.balanceOf.ts | 14 ++- scripts/e2e/swap/swap.preFee.returndata.ts | 14 ++- scripts/e2e/swap/zerox.postFee.balanceOf.ts | 12 +- scripts/e2e/swap/zerox.postFee.returndata.ts | 12 +- scripts/e2e/swap/zerox.preFee.balanceOf.ts | 13 ++- scripts/e2e/swap/zerox.preFee.returndata.ts | 14 +-- scripts/e2e/utils/reproducibility.ts | 22 +--- scripts/e2e/utils/routerAllowance.ts | 110 ++++++++++++++++++ 55 files changed, 644 insertions(+), 299 deletions(-) create mode 100644 scripts/e2e/utils/routerAllowance.ts diff --git a/scripts/e2e/arbitrum/performExecution.postFee.ts b/scripts/e2e/arbitrum/performExecution.postFee.ts index 4c009f1..3e97d1e 100644 --- a/scripts/e2e/arbitrum/performExecution.postFee.ts +++ b/scripts/e2e/arbitrum/performExecution.postFee.ts @@ -36,7 +36,8 @@ import { swapAndBridgeArgs, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const FLAGS = POST_FEE_FLAG | BRIDGE_VALUE_FLAG; const ROUTER_ETH = routerAddressForChain(CHAIN_IDS.ETHEREUM); @@ -108,7 +109,14 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_ETH, ROUTER_ETH); await ensureRouterNativeBalance(signer, ROUTER_ETH); - await ensureRouterApproval(signer, ROUTER_ETH, TOKENS.AAVE_ETH, ooRouter); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_ETH, + TOKENS.AAVE_ETH, + ooRouter, + inputAmount, + ); const callData = routerIface.encodeFunctionData( 'swapAndBridge', @@ -119,7 +127,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: NATIVE_TOKEN_ADDRESS, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/arbitrum/performModularExecution.postFee.ts b/scripts/e2e/arbitrum/performModularExecution.postFee.ts index 78a0dcf..069e29d 100644 --- a/scripts/e2e/arbitrum/performModularExecution.postFee.ts +++ b/scripts/e2e/arbitrum/performModularExecution.postFee.ts @@ -34,12 +34,13 @@ import { NATIVE_TOKEN_ADDRESS, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, getWalletErc20Balance } from '../utils/erc20'; +import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_ETH = routerAddressForChain(CHAIN_IDS.ETHEREUM); @@ -133,14 +134,13 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_ETH, ROUTER_ETH); await ensureRouterNativeBalance(signer, ROUTER_ETH); - await ensureRouterApproval(signer, ROUTER_ETH, TOKENS.AAVE_ETH, ooRouter); const ahIface = new ethers.Interface([ 'function transferFrom(address token, address owner, address recipient, uint256 amount)', ]); const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.AAVE_ETH, signerAddress, ROUTER_ETH, inputAmount])); - exec.call(TOKENS.AAVE_ETH, encodeApprove(ooRouter, inputAmount)); + await modularApproveIfNeeded(exec, provider, ROUTER_ETH, TOKENS.AAVE_ETH, ooRouter, inputAmount, inputAmount); exec.call(ooRouter, swapData); exec.nativeCall(signerAddress, '0x', feeAmount); exec.nativeCall(ARBITRUM_INBOX, buildDepositEthCalldata(), bridgeValue); diff --git a/scripts/e2e/cctp/bridge.preFee.ts b/scripts/e2e/cctp/bridge.preFee.ts index ce85682..bbac9a1 100644 --- a/scripts/e2e/cctp/bridge.preFee.ts +++ b/scripts/e2e/cctp/bridge.preFee.ts @@ -27,7 +27,8 @@ import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -93,15 +94,22 @@ async function main(): Promise { bridgeAmount, ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + polyCctp.tokenMessenger, + bridgeAmount, + ); + const input: InputData = { user: signerAddress, inputToken, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; - const bridgeData: BridgeData = { target: polyCctp.tokenMessenger, approvalSpender: polyCctp.tokenMessenger, value: 0n }; + const bridgeData: BridgeData = { target: polyCctp.tokenMessenger, approvalSpender: bridgeApprovalSpender, value: 0n }; const routerIface = new ethers.Interface(ROUTER_ABI); const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, depositData]); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, polyCctp.tokenMessenger); await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); console.log('Sending AllowanceHolder.exec → router.bridge...'); diff --git a/scripts/e2e/cctp/performExecution.postFee.ts b/scripts/e2e/cctp/performExecution.postFee.ts index 645346a..36d0a50 100644 --- a/scripts/e2e/cctp/performExecution.postFee.ts +++ b/scripts/e2e/cctp/performExecution.postFee.ts @@ -32,7 +32,8 @@ import { swapAndBridgeArgs, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const FLAGS = POST_FEE_FLAG | bridgeAmountPositionFlag(4); const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -121,8 +122,21 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount, + ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger, + estimatedOut - feeAmount, + ); const callData = routerIface.encodeFunctionData( 'swapAndBridge', @@ -133,14 +147,14 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value: 0n, minOutput: minAmountOut, returnDataWordOffset: 0n, }, swapData, - { target: polyCctp.tokenMessenger, approvalSpender: polyCctp.tokenMessenger, value: 0n }, + { target: polyCctp.tokenMessenger, approvalSpender: bridgeApprovalSpender, value: 0n }, depositForBurnData, ), ); diff --git a/scripts/e2e/cctp/performExecution.preFee.ts b/scripts/e2e/cctp/performExecution.preFee.ts index 5065564..cc53631 100644 --- a/scripts/e2e/cctp/performExecution.preFee.ts +++ b/scripts/e2e/cctp/performExecution.preFee.ts @@ -30,7 +30,8 @@ import { type InputData, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -88,14 +89,21 @@ async function main() { const input: InputData = { user: signerAddress, inputToken: TOKENS.USDC_POLYGON_CIRCLE, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger, + bridgeAmount, + ); + const bridgeData: BridgeData = { target: polyCctp.tokenMessenger, - approvalSpender: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, value: 0n, }; await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); const routerIface = new ethers.Interface(ROUTER_ABI); const callData = routerIface.encodeFunctionData('bridge', bridgeArgs(ZERO_BYTES32, input, fee, bridgeData, depositForBurnData)); diff --git a/scripts/e2e/cctp/performModularExecution.postFee.ts b/scripts/e2e/cctp/performModularExecution.postFee.ts index 7d37635..c040a37 100644 --- a/scripts/e2e/cctp/performModularExecution.postFee.ts +++ b/scripts/e2e/cctp/performModularExecution.postFee.ts @@ -33,12 +33,13 @@ import { ALLOWANCE_HOLDER, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -126,18 +127,24 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); const ahIface = new ethers.Interface([ 'function transferFrom(address token, address owner, address recipient, uint256 amount)', ]); const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.AAVE_POLYGON, signerAddress, ROUTER_POLYGON, inputAmount])); - exec.call(TOKENS.AAVE_POLYGON, encodeApprove(ooRouter, inputAmount)); + await modularApproveIfNeeded(exec, provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter, inputAmount, inputAmount); exec.call(ooRouter, swapData); exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeApprove(polyCctp.tokenMessenger, ethers.MaxUint256)); + await modularApproveIfNeeded( + exec, + provider, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger, + estimatedOut - feeAmount, + ethers.MaxUint256, + ); const usdcBalance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(ROUTER_POLYGON)); exec.call(polyCctp.tokenMessenger, depositForBurnData).spliceArg(0, usdcBalance.returnWord()); diff --git a/scripts/e2e/cctp/performModularExecution.preFee.ts b/scripts/e2e/cctp/performModularExecution.preFee.ts index 89582a2..163391f 100644 --- a/scripts/e2e/cctp/performModularExecution.preFee.ts +++ b/scripts/e2e/cctp/performModularExecution.preFee.ts @@ -28,12 +28,13 @@ import { ALLOWANCE_HOLDER, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -71,6 +72,7 @@ async function main() { if (inputAmount === 0n) throw new Error('Balance too small'); const feeAmount = bpsOf(inputAmount, FEE_BPS); + const bridgeAmount = inputAmount - feeAmount; console.log(`Signer: ${signerAddress}`); console.log(`Router: ${ROUTER_POLYGON}`); @@ -85,7 +87,6 @@ async function main() { const depositForBurnData = buildDepositForBurnCalldata(signerAddress, polyCctp.usdcAddress, baseCctp.cctpDomain); await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, polyCctp.tokenMessenger); const ahIface = new ethers.Interface([ 'function transferFrom(address token, address owner, address recipient, uint256 amount)', @@ -93,7 +94,15 @@ async function main() { const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDC_POLYGON_CIRCLE, signerAddress, ROUTER_POLYGON, inputAmount])); exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeApprove(polyCctp.tokenMessenger, ethers.MaxUint256)); + await modularApproveIfNeeded( + exec, + provider, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger, + bridgeAmount, + ethers.MaxUint256, + ); const usdcBalance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(ROUTER_POLYGON)); exec.call(polyCctp.tokenMessenger, depositForBurnData).spliceArg(0, usdcBalance.returnWord()); diff --git a/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts index 1a7fe75..243e0a7 100644 --- a/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts +++ b/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts @@ -35,8 +35,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | balance-of (0x02) | bridge amount at byte offset 4 (depositForBurn amount param) const FLAGS = 0x03n | bridgeAmountPositionFlag(4); @@ -149,17 +149,20 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, - polyCctp.tokenMessenger + polyCctp.tokenMessenger, + estimatedOut - feeAmount, ); const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( @@ -173,7 +176,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value: 0n, minOutput: minAmountOut, @@ -182,7 +185,7 @@ async function main() { swapData, { target: polyCctp.tokenMessenger, - approvalSpender: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, value: 0n, }, depositForBurnData, diff --git a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts index 3a8abf6..0d9c5b0 100644 --- a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts +++ b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts @@ -35,8 +35,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | bridge amount at byte offset 4 (depositForBurn amount param) const FLAGS = 0x01n | bridgeAmountPositionFlag(4); @@ -203,17 +203,20 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ksRouter + ksRouter, + inputAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, - polyCctp.tokenMessenger + polyCctp.tokenMessenger, + estimatedOut - feeAmount, ); const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( @@ -227,7 +230,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, - approvalSpender: ksRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value, minOutput: minAmountOut, @@ -236,7 +239,7 @@ async function main() { swapData, { target: polyCctp.tokenMessenger, - approvalSpender: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, value: 0n, }, depositForBurnData, diff --git a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts index dffa1df..415e714 100644 --- a/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts +++ b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts @@ -35,8 +35,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | bridge amount at byte offset 4 (depositForBurn amount param) const FLAGS = 0x01n | bridgeAmountPositionFlag(4); @@ -149,17 +149,20 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, - polyCctp.tokenMessenger + polyCctp.tokenMessenger, + estimatedOut - feeAmount, ); const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( @@ -173,7 +176,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value: 0n, minOutput: minAmountOut, @@ -182,7 +185,7 @@ async function main() { swapData, { target: polyCctp.tokenMessenger, - approvalSpender: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, value: 0n, }, depositForBurnData, diff --git a/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts index 7d0b75c..1e2449a 100644 --- a/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts +++ b/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts @@ -35,8 +35,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | balance-of (0x02) | bridge amount at byte offset 4 (depositForBurn amount param) const FLAGS = 0x02n | bridgeAmountPositionFlag(4); @@ -151,17 +151,20 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + swapInputAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, - polyCctp.tokenMessenger + polyCctp.tokenMessenger, + minAmountOut, ); const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( @@ -175,7 +178,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value: 0n, minOutput: minAmountOut, @@ -184,7 +187,7 @@ async function main() { swapData, { target: polyCctp.tokenMessenger, - approvalSpender: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, value: 0n, }, depositForBurnData, diff --git a/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts b/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts index f9729ac..72050de 100644 --- a/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts +++ b/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts @@ -35,8 +35,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | bridge amount at byte offset 4 (depositForBurn amount param) const FLAGS = bridgeAmountPositionFlag(4); @@ -151,17 +151,20 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + swapInputAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, - polyCctp.tokenMessenger + polyCctp.tokenMessenger, + minAmountOut, ); const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( @@ -175,7 +178,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value: 0n, minOutput: minAmountOut, @@ -184,7 +187,7 @@ async function main() { swapData, { target: polyCctp.tokenMessenger, - approvalSpender: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, value: 0n, }, depositForBurnData, diff --git a/scripts/e2e/config.ts b/scripts/e2e/config.ts index 24f4dc6..2a25808 100644 --- a/scripts/e2e/config.ts +++ b/scripts/e2e/config.ts @@ -34,7 +34,7 @@ export const ALLOWANCE_HOLDER = '0x0000000000001fF3684f28c67538d4D072C22734'; * Chains without an entry fall back to legacy `ROUTER_ADDRESS` when set. */ export const ROUTER_BY_CHAIN_ID: Record = { - [CHAIN_IDS.POLYGON]: '0x7894c2c93e8952867e2fA4C0778296fEE77074Ea', + [CHAIN_IDS.POLYGON]: '0x33654252CEA9c95220Aa1d434a3631d5c0843AA4', [CHAIN_IDS.ARBITRUM]: '0xe1788A0374EF5D4C35e62478FdB35F37CeE5B951', [CHAIN_IDS.BASE]: '0x91b536E79cd3607b593f3011937862609D608253', [CHAIN_IDS.ETHEREUM]: '0xeB5ae85Fe7e3E272Ac77fd316079589C0Ed91648', diff --git a/scripts/e2e/oft/bridge.preFee.ts b/scripts/e2e/oft/bridge.preFee.ts index 49a909f..756ed5f 100644 --- a/scripts/e2e/oft/bridge.preFee.ts +++ b/scripts/e2e/oft/bridge.preFee.ts @@ -30,7 +30,8 @@ import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); @@ -125,9 +126,17 @@ async function main(): Promise { const input: InputData = { user: signerAddress, inputToken, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, + ); + const bridgeData: BridgeData = { target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer, }; @@ -135,7 +144,6 @@ async function main(): Promise { const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, sendData]); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, USDT0_OFT_ADAPTER_POLYGON); await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); console.log('Sending AllowanceHolder.exec → router.bridge...'); diff --git a/scripts/e2e/oft/performExecution.postFee.ts b/scripts/e2e/oft/performExecution.postFee.ts index 67c0bbb..b8e668f 100644 --- a/scripts/e2e/oft/performExecution.postFee.ts +++ b/scripts/e2e/oft/performExecution.postFee.ts @@ -37,8 +37,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | bridge amount at byte offset 196 (sendParam.amountLD) const FLAGS = 0x01n | bridgeAmountPositionFlag(196); @@ -191,17 +191,20 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, - USDT0_OFT_ADAPTER_POLYGON + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, ); const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); @@ -217,7 +220,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDT0_POLYGON, value: 0n, minOutput: minAmountOut, @@ -226,7 +229,7 @@ async function main() { swapData, { target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer, }, oftSendData, diff --git a/scripts/e2e/oft/performExecution.preFee.ts b/scripts/e2e/oft/performExecution.preFee.ts index ebb6ab8..cc16455 100644 --- a/scripts/e2e/oft/performExecution.preFee.ts +++ b/scripts/e2e/oft/performExecution.preFee.ts @@ -30,7 +30,8 @@ import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); @@ -125,9 +126,17 @@ async function main(): Promise { const input: InputData = { user: signerAddress, inputToken, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, + ); + const bridgeData: BridgeData = { target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer, }; @@ -135,7 +144,6 @@ async function main(): Promise { const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, sendData]); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, USDT0_OFT_ADAPTER_POLYGON); await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); console.log('Sending AllowanceHolder.exec → router.bridge...'); diff --git a/scripts/e2e/oft/performModularExecution.postFee.ts b/scripts/e2e/oft/performModularExecution.postFee.ts index 74e7e59..be9538b 100644 --- a/scripts/e2e/oft/performModularExecution.postFee.ts +++ b/scripts/e2e/oft/performModularExecution.postFee.ts @@ -35,12 +35,13 @@ import { USDT0_OFT_ADAPTER_POLYGON, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); @@ -143,8 +144,6 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); @@ -153,10 +152,18 @@ async function main() { ]); const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.AAVE_POLYGON, signerAddress, ROUTER_POLYGON, inputAmount])); - exec.call(TOKENS.AAVE_POLYGON, encodeApprove(ooRouter, inputAmount)); + await modularApproveIfNeeded(exec, provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter, inputAmount, inputAmount); exec.call(ooRouter, swapData); exec.call(TOKENS.USDT0_POLYGON, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.USDT0_POLYGON, encodeApprove(USDT0_OFT_ADAPTER_POLYGON, ethers.MaxUint256)); + await modularApproveIfNeeded( + exec, + provider, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, + ethers.MaxUint256, + ); const usdt0Balance = exec.staticCall(TOKENS.USDT0_POLYGON, encodeBalanceOf(ROUTER_POLYGON)); exec.nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) .spliceWord(BigInt(OFT_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); diff --git a/scripts/e2e/oft/performModularExecution.preFee.ts b/scripts/e2e/oft/performModularExecution.preFee.ts index 4f02cdc..297226e 100644 --- a/scripts/e2e/oft/performModularExecution.preFee.ts +++ b/scripts/e2e/oft/performModularExecution.preFee.ts @@ -30,12 +30,13 @@ import { USDT0_OFT_ADAPTER_POLYGON, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); @@ -102,7 +103,6 @@ async function main() { console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDT0`); await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); @@ -112,7 +112,15 @@ async function main() { const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDT0_POLYGON, signerAddress, ROUTER_POLYGON, inputAmount])); exec.call(TOKENS.USDT0_POLYGON, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.USDT0_POLYGON, encodeApprove(USDT0_OFT_ADAPTER_POLYGON, ethers.MaxUint256)); + await modularApproveIfNeeded( + exec, + provider, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, + ethers.MaxUint256, + ); const usdt0Balance = exec.staticCall(TOKENS.USDT0_POLYGON, encodeBalanceOf(ROUTER_POLYGON)); exec.nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) .spliceWord(BigInt(OFT_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); diff --git a/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts index d865f0e..96f4944 100644 --- a/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts +++ b/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts @@ -37,8 +37,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | balance-of (0x02) | bridge amount at byte offset 196 (sendParam.amountLD) const FLAGS = 0x03n | bridgeAmountPositionFlag(196); @@ -191,17 +191,20 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, - USDT0_OFT_ADAPTER_POLYGON + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, ); const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); @@ -217,7 +220,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDT0_POLYGON, value: 0n, minOutput: minAmountOut, @@ -226,7 +229,7 @@ async function main() { swapData, { target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer, }, oftSendData, diff --git a/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts b/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts index fad5ebb..4980ec7 100644 --- a/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts +++ b/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts @@ -37,8 +37,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | bridge amount at byte offset 196 (sendParam.amountLD) const FLAGS = 0x01n | bridgeAmountPositionFlag(196); @@ -191,17 +191,20 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, - USDT0_OFT_ADAPTER_POLYGON + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, ); const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); @@ -217,7 +220,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDT0_POLYGON, value: 0n, minOutput: minAmountOut, @@ -226,7 +229,7 @@ async function main() { swapData, { target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer, }, oftSendData, diff --git a/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts index 789858f..8415223 100644 --- a/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts +++ b/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts @@ -37,8 +37,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | balance-of (0x02) | bridge amount at byte offset 196 (sendParam.amountLD) const FLAGS = 0x02n | bridgeAmountPositionFlag(196); @@ -190,17 +190,20 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount - feeAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, - USDT0_OFT_ADAPTER_POLYGON + USDT0_OFT_ADAPTER_POLYGON, + minAmountOut, ); const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); @@ -216,7 +219,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDT0_POLYGON, value: 0n, minOutput: minAmountOut, @@ -225,7 +228,7 @@ async function main() { swapData, { target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer, }, oftSendData, diff --git a/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts b/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts index e8a60ae..5e5dcb7 100644 --- a/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts +++ b/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts @@ -37,8 +37,8 @@ import { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../ut import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | bridge amount at byte offset 196 (sendParam.amountLD) const FLAGS = bridgeAmountPositionFlag(196); @@ -190,17 +190,20 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount - feeAmount, ); - await ensureRouterApproval( - signer, + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, - USDT0_OFT_ADAPTER_POLYGON + USDT0_OFT_ADAPTER_POLYGON, + minAmountOut, ); const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); @@ -216,7 +219,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDT0_POLYGON, value: 0n, minOutput: minAmountOut, @@ -225,7 +228,7 @@ async function main() { swapData, { target: USDT0_OFT_ADAPTER_POLYGON, - approvalSpender: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer, }, oftSendData, diff --git a/scripts/e2e/relay/aave.bridge.preFee.ts b/scripts/e2e/relay/aave.bridge.preFee.ts index 2d90da3..73f4955 100644 --- a/scripts/e2e/relay/aave.bridge.preFee.ts +++ b/scripts/e2e/relay/aave.bridge.preFee.ts @@ -27,7 +27,8 @@ import { ROUTER_ABI } from '../utils/routerAbi'; import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -74,11 +75,18 @@ async function main(): Promise { console.log(`Deposit target: ${depositTarget}`); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); + + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + relaySpender, + bridgeAmount, + ); const input: InputData = { user: signerAddress, inputToken, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; - const bridgeData: BridgeData = { target: depositTarget, approvalSpender: relaySpender, value: 0n }; + const bridgeData: BridgeData = { target: depositTarget, approvalSpender: bridgeApprovalSpender, value: 0n }; const routerIface = new ethers.Interface(ROUTER_ABI); const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, depositData]); diff --git a/scripts/e2e/relay/aave.performExecution.preFee.ts b/scripts/e2e/relay/aave.performExecution.preFee.ts index 188ba82..70098a0 100644 --- a/scripts/e2e/relay/aave.performExecution.preFee.ts +++ b/scripts/e2e/relay/aave.performExecution.preFee.ts @@ -24,7 +24,8 @@ import { ROUTER_ABI } from '../utils/routerAbi'; import { ZERO_BYTES32, bridgeArgs, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -71,11 +72,18 @@ async function main(): Promise { console.log(`Deposit target: ${depositTarget}`); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); const input: InputData = { user: signerAddress, inputToken, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; - const bridgeData: BridgeData = { target: depositTarget, approvalSpender: relaySpender, value: 0n }; + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + relaySpender, + bridgeAmount, + ); + + const bridgeData: BridgeData = { target: depositTarget, approvalSpender: bridgeApprovalSpender, value: 0n }; const routerIface = new ethers.Interface(ROUTER_ABI); const execCalldata = routerIface.encodeFunctionData('bridge', bridgeArgs(ZERO_BYTES32, input, fee, bridgeData, depositData)); diff --git a/scripts/e2e/relay/aave.performModularExecution.preFee.ts b/scripts/e2e/relay/aave.performModularExecution.preFee.ts index 42076a0..dd9207e 100644 --- a/scripts/e2e/relay/aave.performModularExecution.preFee.ts +++ b/scripts/e2e/relay/aave.performModularExecution.preFee.ts @@ -26,13 +26,14 @@ import { ALLOWANCE_HOLDER, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, encodeTransfer, getWalletErc20Balance } from '../utils/erc20'; +import { encodeTransfer, getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -79,7 +80,6 @@ async function main(): Promise { console.log(`Deposit target: ${depositTarget}`); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); const ahIface = new ethers.Interface([ 'function transferFrom(address token, address owner, address recipient, uint256 amount)', @@ -87,7 +87,7 @@ async function main(): Promise { const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [inputToken, signerAddress, ROUTER_POLYGON, inputAmount])); exec.call(inputToken, encodeTransfer(signerAddress, feeAmount)); - exec.call(inputToken, encodeApprove(relaySpender, bridgeAmount)); + await modularApproveIfNeeded(exec, provider, ROUTER_POLYGON, inputToken, relaySpender, bridgeAmount, bridgeAmount); exec.call(depositTarget, depositData); const routerIface = new ethers.Interface(ROUTER_ABI); diff --git a/scripts/e2e/relay/usdc.bridge.preFee.ts b/scripts/e2e/relay/usdc.bridge.preFee.ts index 438d915..2fc5cbb 100644 --- a/scripts/e2e/relay/usdc.bridge.preFee.ts +++ b/scripts/e2e/relay/usdc.bridge.preFee.ts @@ -27,7 +27,8 @@ import { ROUTER_ABI } from '../utils/routerAbi'; import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -74,11 +75,18 @@ async function main(): Promise { console.log(`Deposit target: ${depositTarget}`); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); + + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + relaySpender, + bridgeAmount, + ); const input: InputData = { user: signerAddress, inputToken, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; - const bridgeData: BridgeData = { target: depositTarget, approvalSpender: relaySpender, value: 0n }; + const bridgeData: BridgeData = { target: depositTarget, approvalSpender: bridgeApprovalSpender, value: 0n }; const routerIface = new ethers.Interface(ROUTER_ABI); const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, depositData]); diff --git a/scripts/e2e/relay/usdc.performExecution.preFee.ts b/scripts/e2e/relay/usdc.performExecution.preFee.ts index baf4aff..e5ed43d 100644 --- a/scripts/e2e/relay/usdc.performExecution.preFee.ts +++ b/scripts/e2e/relay/usdc.performExecution.preFee.ts @@ -24,7 +24,8 @@ import { ROUTER_ABI } from '../utils/routerAbi'; import { ZERO_BYTES32, bridgeArgs, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -71,11 +72,18 @@ async function main(): Promise { console.log(`Deposit target: ${depositTarget}`); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); const input: InputData = { user: signerAddress, inputToken, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; - const bridgeData: BridgeData = { target: depositTarget, approvalSpender: relaySpender, value: 0n }; + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + relaySpender, + bridgeAmount, + ); + + const bridgeData: BridgeData = { target: depositTarget, approvalSpender: bridgeApprovalSpender, value: 0n }; const routerIface = new ethers.Interface(ROUTER_ABI); const execCalldata = routerIface.encodeFunctionData('bridge', bridgeArgs(ZERO_BYTES32, input, fee, bridgeData, depositData)); diff --git a/scripts/e2e/relay/usdc.performModularExecution.preFee.ts b/scripts/e2e/relay/usdc.performModularExecution.preFee.ts index d070ae0..9dc70e5 100644 --- a/scripts/e2e/relay/usdc.performModularExecution.preFee.ts +++ b/scripts/e2e/relay/usdc.performModularExecution.preFee.ts @@ -26,13 +26,14 @@ import { ALLOWANCE_HOLDER, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, encodeTransfer, getWalletErc20Balance } from '../utils/erc20'; +import { encodeTransfer, getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { fetchRelayQuoteV2, parseRelayQuote } from '../utils/relayLinkQuote'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -79,7 +80,6 @@ async function main(): Promise { console.log(`Deposit target: ${depositTarget}`); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, relaySpender); const ahIface = new ethers.Interface([ 'function transferFrom(address token, address owner, address recipient, uint256 amount)', @@ -87,7 +87,7 @@ async function main(): Promise { const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [inputToken, signerAddress, ROUTER_POLYGON, inputAmount])); exec.call(inputToken, encodeTransfer(signerAddress, feeAmount)); - exec.call(inputToken, encodeApprove(relaySpender, bridgeAmount)); + await modularApproveIfNeeded(exec, provider, ROUTER_POLYGON, inputToken, relaySpender, bridgeAmount, bridgeAmount); exec.call(depositTarget, depositData); const routerIface = new ethers.Interface(ROUTER_ABI); diff --git a/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts index adaf4f6..81e4f8e 100644 --- a/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts @@ -41,7 +41,8 @@ import { swapAndBridgeArgs, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); const FLAGS = POST_FEE_FLAG | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); @@ -148,7 +149,14 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); await ensureRouterNativeBalance(signer, ROUTER_ARB); - await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_ARB, + TOKENS.USDC_ARB, + ooRouter, + inputAmount, + ); const stargateData = buildStargateCalldata(nativeFeeWithBuffer, signerAddress, amountLD); @@ -161,7 +169,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: NATIVE_TOKEN_ADDRESS, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts b/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts index 2b8a109..aa0b93c 100644 --- a/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performModularExecution.postFee.ts @@ -36,12 +36,13 @@ import { BASE_LZ_EID, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, getWalletErc20Balance } from '../utils/erc20'; +import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_ARB = routerAddressForChain(CHAIN_IDS.ARBITRUM); @@ -147,7 +148,6 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDC_ARB, ROUTER_ARB); await ensureRouterNativeBalance(signer, ROUTER_ARB); - await ensureRouterApproval(signer, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter); const stargateData = buildStargateCalldata(nativeFeeWithBuffer, amountLD, signerAddress); @@ -156,7 +156,7 @@ async function main() { ]); const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDC_ARB, signerAddress, ROUTER_ARB, inputAmount])); - exec.call(TOKENS.USDC_ARB, encodeApprove(ooRouter, inputAmount)); + await modularApproveIfNeeded(exec, provider, ROUTER_ARB, TOKENS.USDC_ARB, ooRouter, inputAmount, inputAmount); exec.call(ooRouter, swapData); exec.nativeCall(signerAddress, '0x', feeAmount); exec.nativeCall(STARGATE_NATIVE_ARB, stargateData, bridgeValue); diff --git a/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts index ed16e8c..05d0c3f 100644 --- a/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts +++ b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts @@ -48,8 +48,8 @@ import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, ensureRouterNativeBalance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | bridge-value (0x04) | bridge-amount-position (0x08 + offset): splice finalETH into amountLD + forward with nativeFee const FLAGS = 0x01n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); @@ -203,7 +203,14 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); await ensureRouterNativeBalance(signer, ROUTER_BASE); - await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + inputAmount, + ); const stargateData = buildStargateCalldata( nativeFeeWithBuffer, @@ -222,7 +229,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: NATIVE_TOKEN_ADDRESS, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts b/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts index 7afc9c8..7521bcb 100644 --- a/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/baseUsdcArbEth.performModularExecution.postFee.ts @@ -33,12 +33,13 @@ import { ARBITRUM_LZ_EID, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, getWalletErc20Balance } from '../utils/erc20'; +import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_BASE = routerAddressForChain(CHAIN_IDS.BASE); @@ -143,7 +144,6 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); await ensureRouterNativeBalance(signer, ROUTER_BASE); - await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); const stargateData = buildStargateCalldata(nativeFeeWithBuffer, amountLD, signerAddress); @@ -152,7 +152,7 @@ async function main() { ]); const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDC_BASE, signerAddress, ROUTER_BASE, inputAmount])); - exec.call(TOKENS.USDC_BASE, encodeApprove(ooRouter, inputAmount)); + await modularApproveIfNeeded(exec, provider, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter, inputAmount, inputAmount); exec.call(ooRouter, swapData); exec.nativeCall(signerAddress, '0x', feeAmount); exec.nativeCall(STARGATE_NATIVE_BASE, stargateData, bridgeValue); diff --git a/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts b/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts index cd5fc38..87ca5eb 100644 --- a/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts @@ -42,7 +42,8 @@ import { swapAndBridgeArgs, } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); const FLAGS = POST_FEE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); @@ -182,7 +183,14 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); await ensureRouterNativeBalance(signer, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); + + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON, + estimatedBridgeAmount, + ); const rawOoWei = nativeSwapWei > 0n ? nativeSwapWei : inputAmountWei; const polOrEthToOo = rawOoWei <= inputAmountWei ? rawOoWei : inputAmountWei; @@ -206,7 +214,7 @@ async function main() { returnDataWordOffset: 0n, }, swapData, - { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: USDT0_OFT_ADAPTER_POLYGON, value: nativeFeeWithBuffer }, + { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer }, oftSendData, ), ); diff --git a/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts b/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts index b65902f..d46436b 100644 --- a/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts @@ -36,12 +36,13 @@ import { STARGATE_AMOUNT_LD_OFFSET, } from '../config'; import { execViaAH } from '../utils/allowanceHolder'; -import { encodeApprove, encodeTransfer, encodeBalanceOf } from '../utils/erc20'; +import { encodeTransfer, encodeBalanceOf } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterNativeBalance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance, ensureRouterNativeBalance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); const LZ_EXTRA_OPTIONS = Options.newOptions().addExecutorLzReceiveOption(65000, 0).toHex(); @@ -176,7 +177,6 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDT0_POLYGON, ROUTER_POLYGON); await ensureRouterNativeBalance(signer, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDT0_POLYGON, USDT0_OFT_ADAPTER_POLYGON); const rawOoWei = nativeSwapWei > 0n ? nativeSwapWei : inputAmountWei; const polOrEthToOo = rawOoWei <= inputAmountWei ? rawOoWei : inputAmountWei; @@ -186,7 +186,15 @@ async function main() { const exec = new ModularActionsBuilder(); exec.nativeCall(ooRouter, swapData, polOrEthToOo); exec.call(TOKENS.USDT0_POLYGON, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.USDT0_POLYGON, encodeApprove(USDT0_OFT_ADAPTER_POLYGON, ethers.MaxUint256)); + await modularApproveIfNeeded( + exec, + provider, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON, + estimatedBridgeAmount, + ethers.MaxUint256, + ); const usdt0Balance = exec.staticCall(TOKENS.USDT0_POLYGON, encodeBalanceOf(ROUTER_POLYGON)); exec.nativeCall(USDT0_OFT_ADAPTER_POLYGON, oftSendData, nativeFeeWithBuffer) .splicePayloadWord(BigInt(STARGATE_AMOUNT_LD_OFFSET), usdt0Balance.returnWord()); diff --git a/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts b/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts index 80b62be..c2f6c2b 100644 --- a/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts +++ b/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts @@ -29,7 +29,8 @@ import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -123,9 +124,17 @@ async function main(): Promise { const input: InputData = { user: signerAddress, inputToken, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + STARGATE_USDC_POLYGON, + bridgeAmount, + ); + const bridgeData: BridgeData = { target: STARGATE_USDC_POLYGON, - approvalSpender: STARGATE_USDC_POLYGON, + approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer, }; @@ -133,7 +142,6 @@ async function main(): Promise { const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, sendData]); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, STARGATE_USDC_POLYGON); await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); console.log('Sending AllowanceHolder.exec → router.bridge...'); diff --git a/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts index b4256dc..0009435 100644 --- a/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts @@ -29,7 +29,8 @@ import { getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -123,9 +124,17 @@ async function main(): Promise { const input: InputData = { user: signerAddress, inputToken, inputAmount }; const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + STARGATE_USDC_POLYGON, + bridgeAmount, + ); + const bridgeData: BridgeData = { target: STARGATE_USDC_POLYGON, - approvalSpender: STARGATE_USDC_POLYGON, + approvalSpender: bridgeApprovalSpender, value: nativeFeeWithBuffer, }; @@ -133,7 +142,6 @@ async function main(): Promise { const execCalldata = routerIface.encodeFunctionData('bridge', [ZERO_BYTES32, input, fee, bridgeData, sendData]); await ensureRouterErc20Balance(signer, inputToken, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, inputToken, STARGATE_USDC_POLYGON); await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); console.log('Sending AllowanceHolder.exec → router.bridge...'); diff --git a/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts index 1a5e457..ebb355a 100644 --- a/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts +++ b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts @@ -31,12 +31,13 @@ import { STARGATE_AMOUNT_LD_OFFSET, } from '../config'; import { execViaAH, ensureAllowanceForAllowanceHolder } from '../utils/allowanceHolder'; -import { encodeApprove, encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; +import { encodeTransfer, encodeBalanceOf, getWalletErc20Balance } from '../utils/erc20'; import { ROUTER_ABI } from '../utils/routerAbi'; import { ModularActionsBuilder } from '../utils/modularActionsBuilder/index'; import { ZERO_BYTES32 } from '../utils/contractTypes'; import { logTxnSummary } from '../utils/txnLogSummary'; -import { ensureRouterErc20Balance, ensureRouterApproval } from '../utils/reproducibility'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); @@ -101,7 +102,6 @@ async function main() { console.log(` Est. received: ${ethers.formatUnits(amountReceivedLD, 6)} USDC`); await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); - await ensureRouterApproval(signer, ROUTER_POLYGON, TOKENS.USDC_POLYGON_CIRCLE, STARGATE_USDC_POLYGON); const stargateData = buildStargateCalldata(nativeFeeWithBuffer, signerAddress); @@ -111,7 +111,15 @@ async function main() { const exec = new ModularActionsBuilder(); exec.call(ALLOWANCE_HOLDER, ahIface.encodeFunctionData('transferFrom', [TOKENS.USDC_POLYGON_CIRCLE, signerAddress, ROUTER_POLYGON, inputAmount])); exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeTransfer(signerAddress, feeAmount)); - exec.call(TOKENS.USDC_POLYGON_CIRCLE, encodeApprove(STARGATE_USDC_POLYGON, ethers.MaxUint256)); + await modularApproveIfNeeded( + exec, + provider, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + STARGATE_USDC_POLYGON, + estimatedBridgeAmount, + ethers.MaxUint256, + ); const usdcBalance = exec.staticCall(TOKENS.USDC_POLYGON_CIRCLE, encodeBalanceOf(ROUTER_POLYGON)); exec.nativeCall(STARGATE_USDC_POLYGON, stargateData, nativeFeeWithBuffer) .splicePayloadWord(BigInt(STARGATE_AMOUNT_LD_OFFSET), usdcBalance.returnWord()); diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts index 0b3a295..8be7ccb 100644 --- a/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts +++ b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts @@ -48,8 +48,8 @@ import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, ensureRouterNativeBalance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | balance-of (0x02) | bridge-value (0x04) | bridge-amount-position (0x08 + offset) const FLAGS = 0x03n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); @@ -202,7 +202,14 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); await ensureRouterNativeBalance(signer, ROUTER_BASE); - await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + inputAmount, + ); const stargateData = buildStargateCalldata( nativeFeeWithBuffer, @@ -221,7 +228,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: NATIVE_TOKEN_ADDRESS, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts index 10f84e2..ba1c483 100644 --- a/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts +++ b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts @@ -48,8 +48,8 @@ import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, ensureRouterNativeBalance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | bridge-value (0x04) | bridge-amount-position (0x08 + offset): splice finalETH into amountLD + forward with nativeFee const FLAGS = 0x01n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); @@ -203,7 +203,14 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); await ensureRouterNativeBalance(signer, ROUTER_BASE); - await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + inputAmount, + ); const stargateData = buildStargateCalldata( nativeFeeWithBuffer, @@ -222,7 +229,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: NATIVE_TOKEN_ADDRESS, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts index 00e5659..462ed18 100644 --- a/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts +++ b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts @@ -48,8 +48,8 @@ import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, ensureRouterNativeBalance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | balance-of (0x02) | bridge-value (0x04) | bridge-amount-position (0x08 + offset) const FLAGS = 0x02n | BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); @@ -202,7 +202,14 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); await ensureRouterNativeBalance(signer, ROUTER_BASE); - await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + swapInput, + ); const stargateData = buildStargateCalldata( nativeFeeWithBuffer, @@ -221,7 +228,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: NATIVE_TOKEN_ADDRESS, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts index 2324ea5..971437f 100644 --- a/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts +++ b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts @@ -48,8 +48,8 @@ import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, ensureRouterNativeBalance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | bridge-value (0x04) | bridge-amount-position (0x08 + offset): splice finalETH into amountLD + forward with nativeFee const FLAGS = BRIDGE_VALUE_FLAG | bridgeAmountPositionFlag(STARGATE_AMOUNT_LD_OFFSET); @@ -202,7 +202,14 @@ async function main() { await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); await ensureRouterNativeBalance(signer, ROUTER_BASE); - await ensureRouterApproval(signer, ROUTER_BASE, TOKENS.USDC_BASE, ooRouter); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + swapInput, + ); const stargateData = buildStargateCalldata( nativeFeeWithBuffer, @@ -221,7 +228,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: NATIVE_TOKEN_ADDRESS, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts b/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts index 570c08e..e1a0380 100644 --- a/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts +++ b/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts @@ -37,8 +37,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | balance-of (0x02) const FLAGS = 0x03n; @@ -176,11 +176,13 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON, ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ksRouter, + inputAmount, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -194,7 +196,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, - approvalSpender: ksRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value, minOutput: minAmountOut, diff --git a/scripts/e2e/swap/kyberswap.postFee.returndata.ts b/scripts/e2e/swap/kyberswap.postFee.returndata.ts index 6da3af8..5deafac 100644 --- a/scripts/e2e/swap/kyberswap.postFee.returndata.ts +++ b/scripts/e2e/swap/kyberswap.postFee.returndata.ts @@ -38,8 +38,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | returndata (bit1=0 ⇒ no 0x02) const FLAGS = 0x01n; @@ -171,11 +171,13 @@ async function main() { console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ksRouter, + inputAmount, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -189,7 +191,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, - approvalSpender: ksRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value, minOutput: minAmountOut, diff --git a/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts b/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts index 9f9312d..fbd0787 100644 --- a/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts +++ b/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts @@ -39,8 +39,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | balance-of (0x02) const FLAGS = 0x02n; @@ -182,11 +182,13 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON, ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ksRouter, + swapInput, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -200,7 +202,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, - approvalSpender: ksRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value, minOutput: minAmountOut, diff --git a/scripts/e2e/swap/kyberswap.preFee.returndata.ts b/scripts/e2e/swap/kyberswap.preFee.returndata.ts index 59c9302..c9b84e4 100644 --- a/scripts/e2e/swap/kyberswap.preFee.returndata.ts +++ b/scripts/e2e/swap/kyberswap.preFee.returndata.ts @@ -40,8 +40,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | returndata (bit1=0 ⇒ no 0x02) const FLAGS = 0x00n; @@ -178,11 +178,13 @@ async function main() { console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ksRouter, + swapInput, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -196,7 +198,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ksRouter, - approvalSpender: ksRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value, minOutput: minAmountOut, diff --git a/scripts/e2e/swap/swap.postFee.balanceOf.ts b/scripts/e2e/swap/swap.postFee.balanceOf.ts index 06e8377..70a71af 100644 --- a/scripts/e2e/swap/swap.postFee.balanceOf.ts +++ b/scripts/e2e/swap/swap.postFee.balanceOf.ts @@ -35,8 +35,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | balance-of (0x02) const FLAGS = 0x03n; @@ -118,11 +118,13 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -136,7 +138,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/swap/swap.postFee.returndata.ts b/scripts/e2e/swap/swap.postFee.returndata.ts index 9fa27fa..7fde5db 100644 --- a/scripts/e2e/swap/swap.postFee.returndata.ts +++ b/scripts/e2e/swap/swap.postFee.returndata.ts @@ -35,8 +35,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | returndata (0x00) const FLAGS = 0x01n; @@ -113,11 +113,13 @@ async function main() { console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -131,7 +133,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/swap/swap.preFee.balanceOf.ts b/scripts/e2e/swap/swap.preFee.balanceOf.ts index 4e4c5c0..d557b50 100644 --- a/scripts/e2e/swap/swap.preFee.balanceOf.ts +++ b/scripts/e2e/swap/swap.preFee.balanceOf.ts @@ -35,8 +35,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | balance-of (0x02) const FLAGS = 0x02n; @@ -118,11 +118,13 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount - feeAmount, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -136,7 +138,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/swap/swap.preFee.returndata.ts b/scripts/e2e/swap/swap.preFee.returndata.ts index bd38078..094a548 100644 --- a/scripts/e2e/swap/swap.preFee.returndata.ts +++ b/scripts/e2e/swap/swap.preFee.returndata.ts @@ -35,8 +35,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | returndata (0x00) const FLAGS = 0x00n; @@ -113,11 +113,13 @@ async function main() { console.log(` Min USDC: ${ethers.formatUnits(minAmountOut, 6)}`); await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, - ooRouter + ooRouter, + inputAmount - feeAmount, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -131,7 +133,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: ooRouter, - approvalSpender: ooRouter, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value: 0n, minOutput: minAmountOut, diff --git a/scripts/e2e/swap/zerox.postFee.balanceOf.ts b/scripts/e2e/swap/zerox.postFee.balanceOf.ts index 0e1e7e0..7137d81 100644 --- a/scripts/e2e/swap/zerox.postFee.balanceOf.ts +++ b/scripts/e2e/swap/zerox.postFee.balanceOf.ts @@ -40,8 +40,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | balance-of (0x02) const FLAGS = 0x03n; @@ -172,11 +172,13 @@ async function main() { TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON, ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, approvalSpender, + inputAmount, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -190,7 +192,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: swapTarget, - approvalSpender, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value, minOutput: minBuyAmount, diff --git a/scripts/e2e/swap/zerox.postFee.returndata.ts b/scripts/e2e/swap/zerox.postFee.returndata.ts index 12253ef..8ec6e73 100644 --- a/scripts/e2e/swap/zerox.postFee.returndata.ts +++ b/scripts/e2e/swap/zerox.postFee.returndata.ts @@ -39,8 +39,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // post-fee (0x01) | returndata (no 0x02) const FLAGS = 0x01n; @@ -161,11 +161,13 @@ async function main() { const approvalSpender = ALLOWANCE_HOLDER; await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, approvalSpender, + inputAmount, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -179,7 +181,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: swapTarget, - approvalSpender, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value, minOutput: minBuyAmount, diff --git a/scripts/e2e/swap/zerox.preFee.balanceOf.ts b/scripts/e2e/swap/zerox.preFee.balanceOf.ts index 75b18a1..d3ec6f8 100644 --- a/scripts/e2e/swap/zerox.preFee.balanceOf.ts +++ b/scripts/e2e/swap/zerox.preFee.balanceOf.ts @@ -43,8 +43,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | balance-of (0x02) const FLAGS = 0x02n; @@ -172,18 +172,19 @@ async function main() { // The 0x AllowanceHolder is the approval spender; swapTarget should equal ALLOWANCE_HOLDER const approvalSpender = ALLOWANCE_HOLDER; - await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); await ensureRouterErc20Balance( signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON, ); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, approvalSpender, + swapInput, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -197,7 +198,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: swapTarget, - approvalSpender, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value, minOutput: minBuyAmount, diff --git a/scripts/e2e/swap/zerox.preFee.returndata.ts b/scripts/e2e/swap/zerox.preFee.returndata.ts index dfa9057..b955005 100644 --- a/scripts/e2e/swap/zerox.preFee.returndata.ts +++ b/scripts/e2e/swap/zerox.preFee.returndata.ts @@ -41,8 +41,8 @@ import { ZERO_BYTES32, swapArgs } from "../utils/contractTypes"; import { logTxnSummary } from "../utils/txnLogSummary"; import { ensureRouterErc20Balance, - ensureRouterApproval, -} from "../utils/reproducibility"; +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; // pre-fee (0x00) | returndata (no 0x02) const FLAGS = 0x00n; @@ -167,15 +167,15 @@ async function main() { console.log(` 0x target: ${swapTarget}`); console.log(` Est. USDC: ${ethers.formatUnits(buyAmount, 6)}`); console.log(` Min USDC: ${ethers.formatUnits(minBuyAmount, 6)}`); - const approvalSpender = ALLOWANCE_HOLDER; - await ensureRouterErc20Balance(signer, TOKENS.AAVE_POLYGON, ROUTER_POLYGON); - await ensureRouterApproval( - signer, + + const swapApprovalSpender = await resolveApprovalSpender( + provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, approvalSpender, + swapInput, ); const callData = routerIface.encodeFunctionData("swap", swapArgs( @@ -189,7 +189,7 @@ async function main() { { receiver: signerAddress, amount: feeAmount }, { target: swapTarget, - approvalSpender, + approvalSpender: swapApprovalSpender, outputToken: TOKENS.USDC_POLYGON_CIRCLE, value, minOutput: minBuyAmount, diff --git a/scripts/e2e/utils/reproducibility.ts b/scripts/e2e/utils/reproducibility.ts index 6e34f9f..62e49da 100644 --- a/scripts/e2e/utils/reproducibility.ts +++ b/scripts/e2e/utils/reproducibility.ts @@ -7,10 +7,9 @@ * Before each test leg these ensure: * 1. The router holds ≥ 20 wei of every token whose balance slot will be written. * - * Router→spender ERC-20 approvals are NOT pre-seeded here. `BungeeOpenRouter` - * sets max allowance inside `swap`, `bridge`, and `swapAndBridge` when needed. - * Modular `performActions` legs may still include inline `approve` actions in the - * same transaction when testing raw modular flows. + * Router→spender approvals are handled per-script via `routerAllowance.ts` (check allowance, + * then set `approvalSpender` or modular `approve` only when insufficient). The contract also + * approves inside `swap` / `bridge` / `swapAndBridge` when `approvalSpender` is non-zero. * * Seeding balance slots to non-zero means subsequent SSTORE writes cost ~2 900 gas * (non-zero → non-zero) rather than ~20 000 gas (zero → non-zero), giving @@ -65,18 +64,3 @@ export async function ensureRouterNativeBalance( const tx = await signer.sendTransaction({ to: openRouter, value: SEED_WEI }); await tx.wait(); } - -/** - * No-op: router→spender approvals are handled by the contract on `swap` / - * `bridge` / `swapAndBridge`. Kept so existing e2e scripts do not need rewrites. - * - * @deprecated Pre-approval via a separate `performActions` tx is intentionally disabled. - */ -export async function ensureRouterApproval( - _signer: ethers.Wallet, - _openRouterAddress: string, - _token: string, - _spender: string, -): Promise { - // Intentionally empty — see module header. -} diff --git a/scripts/e2e/utils/routerAllowance.ts b/scripts/e2e/utils/routerAllowance.ts new file mode 100644 index 0000000..3129eeb --- /dev/null +++ b/scripts/e2e/utils/routerAllowance.ts @@ -0,0 +1,110 @@ +/** + * Router ERC-20 allowance helpers for e2e scripts. + * + * `BungeeOpenRouter` only calls `approve` when `approvalSpender != 0` and + * `requiredAmount > allowance(router, spender)`. Scripts mirror that: check on-chain + * allowance first, omit modular approve actions when sufficient, and pass + * `ZERO_ADDRESS` as `approvalSpender` on `swap` / `bridge` / `swapAndBridge` when not needed. + */ +import { ethers } from 'ethers'; + +import { NATIVE_TOKEN_ADDRESS } from '../config'; +import { ZERO_ADDRESS } from './contractTypes'; +import { encodeApprove, getErc20Contract } from './erc20'; + +export interface ModularActionsExec { + call(target: string, data: string): unknown; +} + +/** + * Reads `token.allowance(router, spender)`. + */ +export async function readRouterAllowance( + provider: ethers.Provider, + routerAddress: string, + tokenAddress: string, + spenderAddress: string, +): Promise { + const router = ethers.getAddress(routerAddress); + const token = ethers.getAddress(tokenAddress); + const spender = ethers.getAddress(spenderAddress); + const erc20 = getErc20Contract(token, provider); + const allowanceRaw = await erc20.allowance(router, spender); + return typeof allowanceRaw === 'bigint' ? allowanceRaw : BigInt(allowanceRaw.toString()); +} + +/** + * Matches contract logic: approval is skipped when `allowance >= requiredAmount`. + */ +export function routerAllowanceSufficient(allowance: bigint, requiredAmount: bigint): boolean { + return allowance >= requiredAmount; +} + +function isNativeToken(tokenAddress: string): boolean { + return ethers.getAddress(tokenAddress) === ethers.getAddress(NATIVE_TOKEN_ADDRESS); +} + +function isZeroSpender(spenderAddress: string): boolean { + return ethers.getAddress(spenderAddress) === ethers.getAddress(ZERO_ADDRESS); +} + +/** + * Returns `spender` for `SwapData` / `BridgeData` when the router must approve, else `ZERO_ADDRESS`. + */ +export async function resolveApprovalSpender( + provider: ethers.Provider, + routerAddress: string, + tokenAddress: string, + spenderAddress: string, + requiredAmount: bigint, +): Promise { + if (isNativeToken(tokenAddress) || isZeroSpender(spenderAddress)) { + return ZERO_ADDRESS; + } + + const allowance = await readRouterAllowance(provider, routerAddress, tokenAddress, spenderAddress); + if (routerAllowanceSufficient(allowance, requiredAmount)) { + console.log( + ` [allowance] sufficient: token=${tokenAddress} spender=${spenderAddress} allowance=${allowance} required=${requiredAmount} → approvalSpender=0`, + ); + return ZERO_ADDRESS; + } + + console.log( + ` [allowance] insufficient: token=${tokenAddress} spender=${spenderAddress} allowance=${allowance} required=${requiredAmount} → approvalSpender set`, + ); + return ethers.getAddress(spenderAddress); +} + +/** + * Appends a modular `approve` action only when router allowance is below `requiredAmount`. + * + * @returns true when an approve action was added. + */ +export async function modularApproveIfNeeded( + exec: ModularActionsExec, + provider: ethers.Provider, + routerAddress: string, + tokenAddress: string, + spenderAddress: string, + requiredAmount: bigint, + approveAmount: bigint = ethers.MaxUint256, +): Promise { + if (isNativeToken(tokenAddress) || isZeroSpender(spenderAddress)) { + return false; + } + + const allowance = await readRouterAllowance(provider, routerAddress, tokenAddress, spenderAddress); + if (routerAllowanceSufficient(allowance, requiredAmount)) { + console.log( + ` [allowance] skipping modular approve: token=${tokenAddress} spender=${spenderAddress} allowance=${allowance} required=${requiredAmount}`, + ); + return false; + } + + console.log( + ` [allowance] modular approve: token=${tokenAddress} spender=${spenderAddress} amount=${approveAmount}`, + ); + exec.call(tokenAddress, encodeApprove(spenderAddress, approveAmount)); + return true; +} From b51be8969606ef7ede53b421152c8bac99785dae Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 23:31:02 +0530 Subject: [PATCH 33/42] ci: remove fmt --- .github/workflows/test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b79c8d4..3b4b0b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,9 +28,6 @@ jobs: - name: Show Forge version run: forge --version - - name: Run Forge fmt - run: forge fmt --check - - name: Run Forge build run: forge build --sizes From d05a66b7dd644d0ef9bc4f9f0f6d0ff1b1b49a72 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Tue, 19 May 2026 23:32:10 +0530 Subject: [PATCH 34/42] ci: run on push to main only --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b4b0b5..7422605 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,8 @@ permissions: {} on: push: + branches: + - main pull_request: workflow_dispatch: From d313ed2b8d97e74431757dde449482dc8e3b0faf Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Wed, 20 May 2026 00:20:38 +0530 Subject: [PATCH 35/42] refactor: rename contract name --- .env.example | 1 - AGENTS.md | 2 +- OPENROUTER.md | 32 +++++++++---------- OPENROUTER_ASSUMPTIONS.md | 14 ++++---- OPENROUTER_CONTEXT.md | 12 +++---- package.json | 4 +-- ...ungeeOpenRouter.ts => deployOpenRouter.ts} | 12 +++---- scripts/e2e/approveViaModular.ts | 2 +- scripts/e2e/config.ts | 2 +- .../e2e/misc/routerUsdc.withdraw.modular.ts | 2 +- scripts/e2e/utils/contractTypes.ts | 2 +- scripts/e2e/utils/routerAbi.ts | 2 +- scripts/e2e/utils/routerAllowance.ts | 2 +- src/{BungeeOpenRouter.sol => OpenRouter.sol} | 4 +-- ....sol => OpenRouterV2UncheckedBridge.t.sol} | 6 ++-- ....t.sol => OpenRouterV2UncheckedSwap.t.sol} | 6 ++-- ... OpenRouterV2UncheckedSwapAndBridge.t.sol} | 6 ++-- ....sol => OpenRouterV2UncheckedTestBase.sol} | 4 +-- test/poc/OneInchCctpOpenRouterPoC.t.sol | 2 +- test/poc/OpenOceanAcrossOpenRouterPoC.t.sol | 2 +- ...OpenOceanStargateNativeOpenRouterPoC.t.sol | 2 +- 21 files changed, 60 insertions(+), 61 deletions(-) rename scripts/deploy/{deployBungeeOpenRouter.ts => deployOpenRouter.ts} (74%) rename src/{BungeeOpenRouter.sol => OpenRouter.sol} (99%) rename test/combined/{BungeeOpenRouterV2UncheckedBridge.t.sol => OpenRouterV2UncheckedBridge.t.sol} (95%) rename test/combined/{BungeeOpenRouterV2UncheckedSwap.t.sol => OpenRouterV2UncheckedSwap.t.sol} (97%) rename test/combined/{BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol => OpenRouterV2UncheckedSwapAndBridge.t.sol} (96%) rename test/combined/{BungeeOpenRouterV2UncheckedTestBase.sol => OpenRouterV2UncheckedTestBase.sol} (98%) diff --git a/.env.example b/.env.example index 528f41e..74fa06b 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,6 @@ PRIVATE_KEY= # Constructor arguments OWNER_ADDRESS= -OPEN_ROUTER_SIGNER_ADDRESS= # only needed for BungeeOpenRouterV2 (not Unchecked) # External API keys RELAY_API_KEY= # optional, relay.link x-api-key header diff --git a/AGENTS.md b/AGENTS.md index f714adb..7e55c30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,4 +3,4 @@ For OpenRouter contract work read files which are relevant for the task. general context - `OPENROUTER_CONTEXT.md` assumptions - `OPENROUTER_ASSUMPTIONS.md` first. -Main ship target is `src/combined/BungeeOpenRouterV2Unchecked.sol`. If its ABI changes, update the backend encoders in `bungee-backend/src/modules/dex/utils.ts` and `bungee-backend/src/modules/router/utils/directQuotesOpenRouter.ts`. +Main ship target is `src/combined/OpenRouterV2Unchecked.sol`. If its ABI changes, update the backend encoders in `bungee-backend/src/modules/dex/utils.ts` and `bungee-backend/src/modules/router/utils/directQuotesOpenRouter.ts`. diff --git a/OPENROUTER.md b/OPENROUTER.md index ced6eec..a705e80 100644 --- a/OPENROUTER.md +++ b/OPENROUTER.md @@ -1,4 +1,4 @@ -# BungeeOpenRouter — Contract Variants +# OpenRouter — Contract Variants > **Monolithic** — non-generic; purpose-built with fees, swap, bridge functionality @@ -24,14 +24,14 @@ src/ interfaces/IAllowanceHolder.sol allowance/AllowanceHolderContext.sol monolithic/ - BungeeOpenRouter.sol - BungeeOpenRouterAH.sol + OpenRouter.sol + OpenRouterAH.sol modular/ - BungeeOpenRouterModular.sol - BungeeOpenRouterModularAH.sol + OpenRouterModular.sol + OpenRouterModularAH.sol minimal/ - BungeeOpenRouterMinimal.sol - BungeeOpenRouterMinimalAH.sol + OpenRouterMinimal.sol + OpenRouterMinimalAH.sol ``` Each variant subdirectory holds the ERC20-facing contract and its AllowanceHolder sibling; imports reach into `../common/`. @@ -74,9 +74,9 @@ The contract has no reentrancy guard, matching `Solver` and `StakedRouterReceive --- -## v1 — BungeeOpenRouter (monolithic) +## v1 — OpenRouter (monolithic) -**File:** [`src/monolithic/BungeeOpenRouter.sol`](src/monolithic/BungeeOpenRouter.sol). AllowanceHolder variant: [`src/monolithic/BungeeOpenRouterAH.sol`](src/monolithic/BungeeOpenRouterAH.sol). +**File:** [`src/monolithic/OpenRouter.sol`](src/monolithic/OpenRouter.sol). AllowanceHolder variant: [`src/monolithic/OpenRouterAH.sol`](src/monolithic/OpenRouterAH.sol). This version encodes the full execution pipeline directly in the contract. The steps are explicit, ordered, and named. The signed payload is a single `Execution` struct: @@ -130,13 +130,13 @@ assembly ("memory-safe") { **When to use this.** Routes where the shape of the flow is always the same: pull → optional pre-fee → optional swap → optional post-fee → bridge. The contract knows the meaning of every field and enforces sensible preconditions (e.g. `finalAmount` cannot underflow below a fee). Adding a step that does not fit this shape — like a second bridge call, a pre-swap approval to a different address, or an intermediate hop — is not possible without deploying a new version of the contract. -**AllowanceHolder variant (`BungeeOpenRouterAH`).** Instead of pulling with ERC20 `transferFrom` from the user to the router, the pull step calls 0x `AllowanceHolder.transferFrom` so funds move under that contract’s transient allowance (user approves AllowanceHolder, user calls `AllowanceHolder.exec` with `target = this router` and calldata invoking `performExecution`). The AH entry decodes `_msgSender()` as the original user appended by AllowanceHolder; `_pullFromUser` requires `_msgSender() == user`, so only the signer-named user matches the ephemeral allowance binding. Like Settler + AH patterns, `AllowanceHolderContext` exposes a harmless `balanceOf` on the router so AllowanceHolder’s confused-deputy probe succeeds; the rest of the pipeline is unchanged. +**AllowanceHolder variant (`OpenRouterAH`).** Instead of pulling with ERC20 `transferFrom` from the user to the router, the pull step calls 0x `AllowanceHolder.transferFrom` so funds move under that contract’s transient allowance (user approves AllowanceHolder, user calls `AllowanceHolder.exec` with `target = this router` and calldata invoking `performExecution`). The AH entry decodes `_msgSender()` as the original user appended by AllowanceHolder; `_pullFromUser` requires `_msgSender() == user`, so only the signer-named user matches the ephemeral allowance binding. Like Settler + AH patterns, `AllowanceHolderContext` exposes a harmless `balanceOf` on the router so AllowanceHolder’s confused-deputy probe succeeds; the rest of the pipeline is unchanged. --- -## v2 — BungeeOpenRouterModular (generic actions + returndata splicing) +## v2 — OpenRouterModular (generic actions + returndata splicing) -**File:** [`src/modular/BungeeOpenRouterModular.sol`](src/modular/BungeeOpenRouterModular.sol). AllowanceHolder variant: [`src/modular/BungeeOpenRouterModularAH.sol`](src/modular/BungeeOpenRouterModularAH.sol). +**File:** [`src/modular/OpenRouterModular.sol`](src/modular/OpenRouterModular.sol). AllowanceHolder variant: [`src/modular/OpenRouterModularAH.sol`](src/modular/OpenRouterModularAH.sol). This version removes all domain-specific knowledge from the contract. The only signed payload is a list of `Action`s: @@ -191,13 +191,13 @@ Both source and destination offsets are bounds-checked before the copy; zero-len **When to use this.** Any route where the exact amount flowing between steps is not known until runtime and must be piped into the next step's calldata. The canonical motivating case is an integration like Across, where two separate fields in the bridge calldata both need to reflect the swap output amount. With `GenericStakedRoute` you can only patch one offset; with this contract you declare as many splices as needed, each targeting a different offset. -**AllowanceHolder variant (`BungeeOpenRouterModularAH`).** The action loop is identical after verification: no built-in pull. You choose how to compose an AllowanceHolder `transferFrom` (or delegatecall shim) as one or more ordinary `CALL` actions signed with everything else; `performExecutionAH` wraps that by binding the signature to `(chainId, this, signedUser, exec)` instead of omitting `signedUser`. It asserts `_msgSender() == signedUser` so nobody can burn another user’s nonce by submitting their payload inside a stranger’s `AH.exec`; real fund safety still comes from AllowanceHolder’s operator/owner/token scoping; `AllowanceHolderContext` only supplies the dummy `balanceOf` for AH’s probing. +**AllowanceHolder variant (`OpenRouterModularAH`).** The action loop is identical after verification: no built-in pull. You choose how to compose an AllowanceHolder `transferFrom` (or delegatecall shim) as one or more ordinary `CALL` actions signed with everything else; `performExecutionAH` wraps that by binding the signature to `(chainId, this, signedUser, exec)` instead of omitting `signedUser`. It asserts `_msgSender() == signedUser` so nobody can burn another user’s nonce by submitting their payload inside a stranger’s `AH.exec`; real fund safety still comes from AllowanceHolder’s operator/owner/token scoping; `AllowanceHolderContext` only supplies the dummy `balanceOf` for AH’s probing. --- -## v3 — BungeeOpenRouterMinimal (generic actions, no splicing) +## v3 — OpenRouterMinimal (generic actions, no splicing) -**File:** [`src/minimal/BungeeOpenRouterMinimal.sol`](src/minimal/BungeeOpenRouterMinimal.sol). AllowanceHolder variant: [`src/minimal/BungeeOpenRouterMinimalAH.sol`](src/minimal/BungeeOpenRouterMinimalAH.sol). +**File:** [`src/minimal/OpenRouterMinimal.sol`](src/minimal/OpenRouterMinimal.sol). AllowanceHolder variant: [`src/minimal/OpenRouterMinimalAH.sol`](src/minimal/OpenRouterMinimalAH.sol). This version is the stripped-down sibling of v2. The `Action` struct has no `splices` field: @@ -224,7 +224,7 @@ This is exactly how `BaseRouterSingleOutput` works: it measures the swap output **When to use this.** Routes where every action is self-contained — the called contracts know what token to look at, query their own balance, and use that as their amount. This covers most `GenericStakedRoute` flows today, since those contracts already contain the offset-patching and balance-reading logic. v3 is the right choice when you do not need cross-action data passing at the router layer, and you want the smallest possible trusted surface in the router contract itself. -**AllowanceHolder variant (`BungeeOpenRouterMinimalAH`).** Same idea as the modular AH: use `performExecutionAH` plus `AllowanceHolderContext`’s `balanceOf`; sign over `signedUser` and require `_msgSender() == signedUser` for nonce-binding; compose the AH pull as ordinary actions in `exec.actions`. +**AllowanceHolder variant (`OpenRouterMinimalAH`).** Same idea as the modular AH: use `performExecutionAH` plus `AllowanceHolderContext`’s `balanceOf`; sign over `signedUser` and require `_msgSender() == signedUser` for nonce-binding; compose the AH pull as ordinary actions in `exec.actions`. --- diff --git a/OPENROUTER_ASSUMPTIONS.md b/OPENROUTER_ASSUMPTIONS.md index 7ba02a6..98bff2a 100644 --- a/OPENROUTER_ASSUMPTIONS.md +++ b/OPENROUTER_ASSUMPTIONS.md @@ -2,20 +2,20 @@ Last reviewed: 2026-05-19. -Scope: `src/combined/BungeeOpenRouterV2Unchecked.sol`. +Scope: `src/combined/OpenRouterV2Unchecked.sol`. This document captures the assumptions that make the unchecked OpenRouter safe to operate. Many of these are business and integration assumptions, not guarantees enforced by the contract. ## Source Of Truth -`BungeeOpenRouterV2Unchecked` intentionally removes backend signature verification, nonces, and deadlines. Public entrypoints can be called by anyone. +`OpenRouterV2Unchecked` intentionally removes backend signature verification, nonces, and deadlines. Public entrypoints can be called by anyone. Current checked-in public surface: - `swap(...)` - `swapAndBridge(...)` - `bridge(...)` -- `performModularExecution(...)` +- `performActions()(...)` - `rescueFunds(...)` `OPENROUTER_CONTEXT.md` and `scripts/e2e/utils/routerAbi.ts` may mention `performExecution(...)`; verify against the Solidity file before relying on that ABI. @@ -34,7 +34,7 @@ Use this distinction when reviewing any route or integration: The router may temporarily hold funds during one transaction, but it should not end routes with meaningful token or native balances. -Failure mode: `performModularExecution` lets any caller make the router call arbitrary contracts. If the router holds ERC20s, native ETH, bridged refunds, swap dust, rebates, or protocol refunds, a public caller can move or approve those assets through modular actions before owner rescue. +Failure mode: `performActions()` lets any caller make the router call arbitrary contracts. If the router holds ERC20s, native ETH, bridged refunds, swap dust, rebates, or protocol refunds, a public caller can move or approve those assets through modular actions before owner rescue. Operational requirements: @@ -47,7 +47,7 @@ Operational requirements: Users must not give persistent ERC20, Permit2, ERC721, ERC1155, or protocol-specific approvals directly to the router. -Failure mode: if a user directly approves the router, any caller can use `performModularExecution` to make the router call `transferFrom`, `approve`, or equivalent privileged token functions against that user allowance. +Failure mode: if a user directly approves the router, any caller can use `performActions()` to make the router call `transferFrom`, `approve`, or equivalent privileged token functions against that user allowance. Operational requirements: @@ -194,7 +194,7 @@ Failure modes: ## Modular Execution Assumptions -`performModularExecution` is the broadest surface. It makes the router a public generic call executor. +`performActions()` is the broadest surface. It makes the router a public generic call executor. Assumptions: @@ -245,4 +245,4 @@ Before enabling a route or integration, confirm: - Excess native value and bridge refunds do not end up on the router. - Monitoring exists for router balances, direct allowances to router, and unexpected downstream roles. -If any critical business assumption is false, do not rely on `BungeeOpenRouterV2Unchecked` as-is. Add access control, use a signed variant, or remove the downstream privilege/funds/allowance that makes the public call surface dangerous. +If any critical business assumption is false, do not rely on `OpenRouterV2Unchecked` as-is. Add access control, use a signed variant, or remove the downstream privilege/funds/allowance that makes the public call surface dangerous. diff --git a/OPENROUTER_CONTEXT.md b/OPENROUTER_CONTEXT.md index 94a4248..2736eb4 100644 --- a/OPENROUTER_CONTEXT.md +++ b/OPENROUTER_CONTEXT.md @@ -4,13 +4,13 @@ Last researched: 2026-05-18. Main ship target: -- `src/combined/BungeeOpenRouterV2Unchecked.sol` +- `src/combined/OpenRouterV2Unchecked.sol` -Use `src/combined/BungeeOpenRouterV2.sol` as the signed sibling/reference, but the backend branch researched here targets the unchecked ABI. +Use `src/combined/OpenRouterV2.sol` as the signed sibling/reference, but the backend branch researched here targets the unchecked ABI. ## V2Unchecked Surface -`BungeeOpenRouterV2Unchecked` removes backend signature verification, nonce, and deadline fields. Fund safety for ERC20 inputs depends on 0x AllowanceHolder transient approvals plus `_msgSender() == input.user` in `_pullFromUser`. +`OpenRouterV2Unchecked` removes backend signature verification, nonce, and deadline fields. Fund safety for ERC20 inputs depends on 0x AllowanceHolder transient approvals plus `_msgSender() == input.user` in `_pullFromUser`. External entrypoints: @@ -29,12 +29,12 @@ External entrypoints: - `bridge(bytes32 requestHash, InputData input, FeeData fee, BridgeData bridgeData, bytes bridgeCallData)` - Direct bridge, no swap. - No runtime splice; bridge amount must already be encoded in `bridgeCallData`. -- `performModularExecution(bytes32 requestHash, Action[] actions)` +- `performActions()(bytes32 requestHash, Action[] actions)` - Generic action loop with packed action metadata and packed splices. ## Flags -Flag constants in `BungeeOpenRouterV2Unchecked.sol`: +Flag constants in `OpenRouterV2Unchecked.sol`: - `0x01` - post-swap fee for `swap` and `swapAndBridge`; clear means pre-fee from input. Ignored by `performExecution`. - `0x02` - measure swap output by `balanceOf` delta; clear means decode return word at `SwapData.returnDataWordOffset`. @@ -105,4 +105,4 @@ If the Solidity ABI changes, update those hard-coded ABI strings first. Direct D - `bridge()` cannot splice runtime amounts. Use `swapAndBridge()` when bridge calldata needs the live swap output. - `swapAndBridge()` uses balance-delta output measurement in backend builders today. - `performExecution` and `swapAndBridge` share helpers but have different fee semantics. -- Production use of `BungeeOpenRouterV2Unchecked` needs an operational access-control decision; the contract itself has no signature or nonce checks. +- Production use of `OpenRouterV2Unchecked` needs an operational access-control decision; the contract itself has no signature or nonce checks. diff --git a/package.json b/package.json index e3000a6..b1a1749 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "compile": "hardhat compile", - "deploy": "hardhat run scripts/deploy/deployBungeeOpenRouter.ts --network", - "deploy:v2": "hardhat run scripts/deploy/deployBungeeOpenRouterV2.ts --network", + "deploy": "hardhat run scripts/deploy/deployOpenRouter.ts --network", + "deploy:v2": "hardhat run scripts/deploy/deployOpenRouterV2.ts --network", "typechain": "hardhat typechain", "slither": "bash scripts/docker-slither.sh" }, diff --git a/scripts/deploy/deployBungeeOpenRouter.ts b/scripts/deploy/deployOpenRouter.ts similarity index 74% rename from scripts/deploy/deployBungeeOpenRouter.ts rename to scripts/deploy/deployOpenRouter.ts index d5db918..6afcb60 100644 --- a/scripts/deploy/deployBungeeOpenRouter.ts +++ b/scripts/deploy/deployOpenRouter.ts @@ -1,8 +1,8 @@ /** - * Deployment script for BungeeOpenRouter. + * Deployment script for OpenRouter. * * Usage: - * npx hardhat run scripts/deploy/deployBungeeOpenRouter.ts --network + * npx hardhat run scripts/deploy/deployOpenRouter.ts --network * * Required env vars: * DEPLOYER_PRIVATE_KEY — deployer wallet private key @@ -23,15 +23,15 @@ async function main() { console.log('Network: ', networkName); console.log(''); - console.log('Deploying BungeeOpenRouter...'); - const factory = await ethers.getContractFactory('BungeeOpenRouter'); + console.log('Deploying OpenRouter...'); + const factory = await ethers.getContractFactory('OpenRouter'); const router = await factory.deploy(owner); await router.waitForDeployment(); const routerAddress = await router.getAddress(); - console.log('BungeeOpenRouter deployed to:', routerAddress); + console.log('OpenRouter deployed to:', routerAddress); console.log('\n=== Deployment Summary ==='); - console.log(`BungeeOpenRouter: ${routerAddress}`); + console.log(`OpenRouter: ${routerAddress}`); const chainId = (await ethers.provider.getNetwork()).chainId; if (chainId !== 31337n) { diff --git a/scripts/e2e/approveViaModular.ts b/scripts/e2e/approveViaModular.ts index 8a62bdb..891a7e2 100644 --- a/scripts/e2e/approveViaModular.ts +++ b/scripts/e2e/approveViaModular.ts @@ -2,7 +2,7 @@ * Script — Call ERC-20 approve(spender, amount) through the router using * `performActions(Action[])`. * - * DISABLED by default: `BungeeOpenRouter` now sets max allowance inside + * DISABLED by default: `OpenRouter` now sets max allowance inside * `swap`, `bridge`, and `swapAndBridge`. Use those entrypoints instead of a * standalone approval tx. Set `E2E_ENABLE_MODULAR_PRE_APPROVE=1` only if you * need this legacy helper for manual modular debugging. diff --git a/scripts/e2e/config.ts b/scripts/e2e/config.ts index 2a25808..5aca8d4 100644 --- a/scripts/e2e/config.ts +++ b/scripts/e2e/config.ts @@ -29,7 +29,7 @@ export const BLOCK_EXPLORER_TX_PREFIX: Record = { export const ALLOWANCE_HOLDER = '0x0000000000001fF3684f28c67538d4D072C22734'; /** - * Deployed `BungeeOpenRouterV2Unchecked` — one address per chain used by e2e scripts. + * Deployed `OpenRouterV2Unchecked` — one address per chain used by e2e scripts. * Override per chain with env `ROUTER_CHAIN_` (e.g. ROUTER_CHAIN_1 for Ethereum). * Chains without an entry fall back to legacy `ROUTER_ADDRESS` when set. */ diff --git a/scripts/e2e/misc/routerUsdc.withdraw.modular.ts b/scripts/e2e/misc/routerUsdc.withdraw.modular.ts index 4df585d..6ea546f 100644 --- a/scripts/e2e/misc/routerUsdc.withdraw.modular.ts +++ b/scripts/e2e/misc/routerUsdc.withdraw.modular.ts @@ -1,5 +1,5 @@ /** - * Polygon: sweep USDC from `BungeeOpenRouter` to the tx sender using + * Polygon: sweep USDC from `OpenRouter` to the tx sender using * `performActions` only — no AllowanceHolder, no pull step. * * Actions: diff --git a/scripts/e2e/utils/contractTypes.ts b/scripts/e2e/utils/contractTypes.ts index a55c4cb..9bf07c2 100644 --- a/scripts/e2e/utils/contractTypes.ts +++ b/scripts/e2e/utils/contractTypes.ts @@ -1,5 +1,5 @@ /** - * TypeScript interfaces mirroring BungeeOpenRouter Solidity structs. + * TypeScript interfaces mirroring OpenRouter Solidity structs. * Field names and order must match the compiler ABI encoding. */ diff --git a/scripts/e2e/utils/routerAbi.ts b/scripts/e2e/utils/routerAbi.ts index 03dc419..7e2e702 100644 --- a/scripts/e2e/utils/routerAbi.ts +++ b/scripts/e2e/utils/routerAbi.ts @@ -1,5 +1,5 @@ /** - * ABI fragments for BungeeOpenRouter entrypoints used by e2e scripts. + * ABI fragments for OpenRouter entrypoints used by e2e scripts. * Struct field order must match the Solidity definitions. */ export const ROUTER_ABI = [ diff --git a/scripts/e2e/utils/routerAllowance.ts b/scripts/e2e/utils/routerAllowance.ts index 3129eeb..15c382b 100644 --- a/scripts/e2e/utils/routerAllowance.ts +++ b/scripts/e2e/utils/routerAllowance.ts @@ -1,7 +1,7 @@ /** * Router ERC-20 allowance helpers for e2e scripts. * - * `BungeeOpenRouter` only calls `approve` when `approvalSpender != 0` and + * `OpenRouter` only calls `approve` when `approvalSpender != 0` and * `requiredAmount > allowance(router, spender)`. Scripts mirror that: check on-chain * allowance first, omit modular approve actions when sufficient, and pass * `ZERO_ADDRESS` as `approvalSpender` on `swap` / `bridge` / `swapAndBridge` when not needed. diff --git a/src/BungeeOpenRouter.sol b/src/OpenRouter.sol similarity index 99% rename from src/BungeeOpenRouter.sol rename to src/OpenRouter.sol index 685a81b..858c00d 100644 --- a/src/BungeeOpenRouter.sol +++ b/src/OpenRouter.sol @@ -12,12 +12,12 @@ import {CurrencyLib} from "./common/lib/CurrencyLib.sol"; import {RescueFundsLib} from "./common/lib/RescueFundsLib.sol"; import {RESCUE_ROLE} from "./common/AccessRoles.sol"; -/// @title BungeeOpenRouter +/// @title OpenRouter /// @notice Pull → optional fee → swap/bridge execution without backend signature verification. /// Fund safety rests on AllowanceHolder's transient allowance scoping (operator + owner + token): /// only the user whose address was passed to `AllowanceHolder.exec` can authorise a pull of /// their own funds. The `_msgSender() == user` check in `_pullFromUser` enforces this. -contract BungeeOpenRouter is AccessControl, AllowanceHolderContext { +contract OpenRouter is AccessControl, AllowanceHolderContext { using SafeTransferLib for address; // ========================================================================= diff --git a/test/combined/BungeeOpenRouterV2UncheckedBridge.t.sol b/test/combined/OpenRouterV2UncheckedBridge.t.sol similarity index 95% rename from test/combined/BungeeOpenRouterV2UncheckedBridge.t.sol rename to test/combined/OpenRouterV2UncheckedBridge.t.sol index 50635d0..e6c5f81 100644 --- a/test/combined/BungeeOpenRouterV2UncheckedBridge.t.sol +++ b/test/combined/OpenRouterV2UncheckedBridge.t.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.34; -import {BungeeOpenRouter as Router} from "../../src/BungeeOpenRouter.sol"; -import {BungeeOpenRouterV2UncheckedTestBase} from "./BungeeOpenRouterV2UncheckedTestBase.sol"; +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; +import {OpenRouterV2UncheckedTestBase} from "./OpenRouterV2UncheckedTestBase.sol"; -contract BungeeOpenRouterV2UncheckedBridgeTest is BungeeOpenRouterV2UncheckedTestBase { +contract OpenRouterV2UncheckedBridgeTest is OpenRouterV2UncheckedTestBase { function test_bridge_erc20() public { _deal(address(inputToken), USER, INPUT_AMOUNT); _approveInputToken(INPUT_AMOUNT); diff --git a/test/combined/BungeeOpenRouterV2UncheckedSwap.t.sol b/test/combined/OpenRouterV2UncheckedSwap.t.sol similarity index 97% rename from test/combined/BungeeOpenRouterV2UncheckedSwap.t.sol rename to test/combined/OpenRouterV2UncheckedSwap.t.sol index bf60bb4..b36aca1 100644 --- a/test/combined/BungeeOpenRouterV2UncheckedSwap.t.sol +++ b/test/combined/OpenRouterV2UncheckedSwap.t.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.34; -import {BungeeOpenRouter as Router} from "../../src/BungeeOpenRouter.sol"; -import {BungeeOpenRouterV2UncheckedTestBase} from "./BungeeOpenRouterV2UncheckedTestBase.sol"; +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; +import {OpenRouterV2UncheckedTestBase} from "./OpenRouterV2UncheckedTestBase.sol"; -contract BungeeOpenRouterV2UncheckedSwapTest is BungeeOpenRouterV2UncheckedTestBase { +contract OpenRouterV2UncheckedSwapTest is OpenRouterV2UncheckedTestBase { function test_swapWithReturnData() public { _deal(address(inputToken), USER, INPUT_AMOUNT); _deal(address(outputToken), address(swapTarget), SWAP_OUTPUT_AMOUNT); diff --git a/test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol b/test/combined/OpenRouterV2UncheckedSwapAndBridge.t.sol similarity index 96% rename from test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol rename to test/combined/OpenRouterV2UncheckedSwapAndBridge.t.sol index f9376b8..3eddd22 100644 --- a/test/combined/BungeeOpenRouterV2UncheckedSwapAndBridge.t.sol +++ b/test/combined/OpenRouterV2UncheckedSwapAndBridge.t.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.34; -import {BungeeOpenRouter as Router} from "../../src/BungeeOpenRouter.sol"; -import {BungeeOpenRouterV2UncheckedTestBase} from "./BungeeOpenRouterV2UncheckedTestBase.sol"; +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; +import {OpenRouterV2UncheckedTestBase} from "./OpenRouterV2UncheckedTestBase.sol"; -contract BungeeOpenRouterV2UncheckedSwapAndBridgeTest is BungeeOpenRouterV2UncheckedTestBase { +contract OpenRouterV2UncheckedSwapAndBridgeTest is OpenRouterV2UncheckedTestBase { enum FeeMode { None, Pre, diff --git a/test/combined/BungeeOpenRouterV2UncheckedTestBase.sol b/test/combined/OpenRouterV2UncheckedTestBase.sol similarity index 98% rename from test/combined/BungeeOpenRouterV2UncheckedTestBase.sol rename to test/combined/OpenRouterV2UncheckedTestBase.sol index 1704097..1b3fda1 100644 --- a/test/combined/BungeeOpenRouterV2UncheckedTestBase.sol +++ b/test/combined/OpenRouterV2UncheckedTestBase.sol @@ -4,10 +4,10 @@ pragma solidity 0.8.34; import {Test} from "forge-std/Test.sol"; import {ERC20} from "solady/src/tokens/ERC20.sol"; -import {BungeeOpenRouter as Router} from "../../src/BungeeOpenRouter.sol"; +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; import {ALLOWANCE_HOLDER, IAllowanceHolder} from "../../src/common/interfaces/IAllowanceHolder.sol"; -abstract contract BungeeOpenRouterV2UncheckedTestBase is Test { +abstract contract OpenRouterV2UncheckedTestBase is Test { uint256 internal constant FEE_FLAG_BIT_MASK = 0x01; uint256 internal constant BALANCE_FLAG_BIT_MASK = 0x02; uint256 internal constant BRIDGE_VALUE_FLAG_BIT_MASK = 0x04; diff --git a/test/poc/OneInchCctpOpenRouterPoC.t.sol b/test/poc/OneInchCctpOpenRouterPoC.t.sol index 8065379..d5f939e 100644 --- a/test/poc/OneInchCctpOpenRouterPoC.t.sol +++ b/test/poc/OneInchCctpOpenRouterPoC.t.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.34; import {Test} from "forge-std/Test.sol"; import {ERC20} from "solady/src/tokens/ERC20.sol"; -import {BungeeOpenRouter as Router} from "../../src/BungeeOpenRouter.sol"; +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; import {ALLOWANCE_HOLDER, IAllowanceHolder} from "../../src/common/interfaces/IAllowanceHolder.sol"; interface ITokenMessengerV2 { diff --git a/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol b/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol index 691ae10..57522fd 100644 --- a/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol +++ b/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.34; import {Test} from "forge-std/Test.sol"; import {ERC20} from "solady/src/tokens/ERC20.sol"; -import {BungeeOpenRouter as Router} from "../../src/BungeeOpenRouter.sol"; +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; import {AcrossERC20AmountManipulator} from "../../src/manipulators/AcrossERC20AmountManipulator.sol"; interface ISpokePool { diff --git a/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol b/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol index 74b9545..f9ae56a 100644 --- a/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol +++ b/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.34; import {Test} from "forge-std/Test.sol"; import {ERC20} from "solady/src/tokens/ERC20.sol"; -import {BungeeOpenRouter as Router} from "../../src/BungeeOpenRouter.sol"; +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; import {MathManipulator} from "../../src/manipulators/MathManipulator.sol"; interface IOpenOceanExchangeV2 { From 02494d13b702312b309b0127a509f086976a49de Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Wed, 20 May 2026 00:25:11 +0530 Subject: [PATCH 36/42] docs: update --- OPENROUTER.md | 332 ++++++++++++++++++++++---------------------------- 1 file changed, 149 insertions(+), 183 deletions(-) diff --git a/OPENROUTER.md b/OPENROUTER.md index a705e80..17b94a4 100644 --- a/OPENROUTER.md +++ b/OPENROUTER.md @@ -1,272 +1,238 @@ -# OpenRouter — Contract Variants +# OpenRouter -> **Monolithic** — non-generic; purpose-built with fees, swap, bridge functionality +**Contract:** [`src/OpenRouter.sol`](src/OpenRouter.sol) -> **Modular** — generic; supports arbitrary actions; uses returndata from previous calls and modifies parts of next calldata +OpenRouter is a single on-chain executor that combines two earlier designs: -> **Minimal** — generic; supports arbitrary actions; but no calldata modification; each subsequent action destination contract can read state eg. balanceOf() and uses them as needed; +1. **Structured (monolithic) routes** — fixed pull → fee → swap → bridge semantics, exposed as separate entrypoints (`swap`, `swapAndBridge`, `bridge`) instead of one giant `Execution` struct and `performExecution`. +2. **Generic (modular) routes** — an ordered `performActions` loop with returndata splicing between steps, for flows that do not fit the structured pipeline. -All versions uses signature verification. +There is **no backend signature verification**, **no nonce**, and **no deadline** on this contract. ERC-20 fund safety for structured pulls relies on [0x AllowanceHolder](https://github.com/0xProject/0x-settler) transient allowances plus `_msgSender() == input.user` in `_pullFromUser`. Native input uses `msg.value` on the outer call. -Three versions of the OpenRouter contract exist, each making a different trade-off between rigidity and generality. All three share the same authentication model; they differ only in how the execution steps are expressed and how outputs flow between steps. +--- -**Source layout** (under `src/`): +## Source layout ```text src/ - Counter.sol # scaffold only - common/ # shared by every variant + AH offshoots - OpenRouterAuthBase.sol - lib/AuthenticationLib.sol + OpenRouter.sol # ship target + common/ + allowance/AllowanceHolderContext.sol + interfaces/IAllowanceHolder.sol lib/BytesSpliceLib.sol lib/CurrencyLib.sol - utils/Ownable.sol - interfaces/IAllowanceHolder.sol - allowance/AllowanceHolderContext.sol - monolithic/ - OpenRouter.sol - OpenRouterAH.sol - modular/ - OpenRouterModular.sol - OpenRouterModularAH.sol - minimal/ - OpenRouterMinimal.sol - OpenRouterMinimalAH.sol + lib/RescueFundsLib.sol + utils/AccessControl.sol + manipulators/ # optional off-router helpers for PoCs (Across, math) ``` -Each variant subdirectory holds the ERC20-facing contract and its AllowanceHolder sibling; imports reach into `../common/`. - --- -## What is shared across all three +## How users call the router -Every version inherits `OpenRouterAuthBase` from [`src/common/OpenRouterAuthBase.sol`](src/common/OpenRouterAuthBase.sol). The only things hard-wired in the contract are: +ERC-20 inputs must be submitted through **AllowanceHolder**, not by calling OpenRouter directly: -- **A single trusted signer** (`OPEN_ROUTER_SIGNER`), rotatable by the owner via two-step `Ownable`. This is the backend solver/orchestration service address. -- **Per-nonce replay protection.** A `nonceUsed` mapping is written with an assembly `sstore` the moment a valid signature is verified. Any attempt to resubmit the same nonce reverts with `InvalidNonce()` before touching any funds. -- **A deadline field.** The signature carries a `deadline` (unix timestamp). Expired payloads revert with `DeadlineExpired()`. -- **Chain + deployment binding.** The signed digest always includes `block.chainid` and `address(this)`. A payload signed for one deployment cannot be replayed on a different chain or a different deployment of the same contract. +1. User approves AllowanceHolder (not OpenRouter). +2. User calls `AllowanceHolder.exec(operator, token, amount, target, data)` with `target = OpenRouter` and `data` encoding one of the router entrypoints. +3. AllowanceHolder forwards the call and appends the user address to calldata (ERC-2771 style). OpenRouter’s `_msgSender()` resolves to that user. +4. `_pullFromUser` calls `AllowanceHolder.transferFrom` and reverts with `CallerNotSignedUser()` unless `_msgSender() == input.user`. -The signature itself is a plain personal_sign (`\x19Ethereum Signed Message:\n32` prefix, 65-byte `r,s,v`) over `keccak256(abi.encode(chainid, address(this), executionPayload))`. This matches the scheme used in the marketplace `Solver` and `StakedRouterReceiver` contracts. +Native token input skips AllowanceHolder pull: the caller must forward sufficient `msg.value` on the outer transaction. -```solidity -// src/common/OpenRouterAuthBase.sol — `_verifyAndConsume` -if (AuthenticationLib.authenticate(digest, signature) != OPEN_ROUTER_SIGNER) { - assembly { - mstore(0x00, 0x815e1d64) // InvalidSigner() - revert(0x1c, 0x04) - } -} +`AllowanceHolderContext` also implements a harmless `balanceOf` so AllowanceHolder’s confused-deputy probe succeeds (same pattern as 0x Settler + AH). -assembly { - mstore(0, nonce) - mstore(0x20, nonceUsed.slot) - let dataSlot := keccak256(0, 0x40) - if and(sload(dataSlot), 0xff) { - mstore(0x00, 0x756688fe) // InvalidNonce() - revert(0x1c, 0x04) - } - sstore(dataSlot, 0x01) -} -``` +--- -The contract has no reentrancy guard, matching `Solver` and `StakedRouterReceiver`. The combination of a fresh nonce per call and a signature that covers the entire payload is the security boundary. +## External entrypoints ---- +| Function | Purpose | +|----------|---------| +| `swap` | Same-chain: pull → optional pre/post fee → swap → deliver output to `receiver` | +| `swapAndBridge` | Cross-chain: pull → optional pre/post swap fee → swap (output stays on router) → bridge | +| `bridge` | Direct bridge: pull → optional pre-bridge fee → bridge (amount baked into calldata) | +| `performActions` | Generic action loop with optional returndata splices | +| `rescueFunds` | Owner `RESCUE_ROLE` recovery of stuck tokens (operational, not a security boundary) | -## v1 — OpenRouter (monolithic) +Each structured entrypoint emits `RequestExecuted(bytes32 quoteId)` for off-chain correlation. `quoteId` is caller-defined; the contract does not validate it. -**File:** [`src/monolithic/OpenRouter.sol`](src/monolithic/OpenRouter.sol). AllowanceHolder variant: [`src/monolithic/OpenRouterAH.sol`](src/monolithic/OpenRouterAH.sol). +--- -This version encodes the full execution pipeline directly in the contract. The steps are explicit, ordered, and named. The signed payload is a single `Execution` struct: +## Structured routes — structs ```solidity -struct Execution { +struct InputData { address user; address inputToken; uint256 inputAmount; +} - address preFeeReceiver; // address(0) to skip - uint256 preFeeAmount; // taken in inputToken, before swap - - address swapTarget; // address(0) to skip swap entirely - address swapApprovalSpender; - address swapOutputToken; - uint256 swapValue; - uint256 swapMinOutput; - bytes swapData; - - address postFeeReceiver; // address(0) to skip - uint256 postFeeAmount; // taken in finalToken, after swap +struct FeeData { + address receiver; + uint256 amount; // 0 skips fee collection +} - address bridgeTarget; - address bridgeApprovalSpender; - uint256 bridgeValue; - bytes bridgeData; - uint256[] bridgeAmountPositions; // byte offsets where finalAmount is written +struct SwapData { + address target; + address approvalSpender; + address outputToken; + uint256 value; + uint256 minOutput; + uint256 returnDataWordOffset; // word index when using returndata output mode +} - uint256 nonce; - uint256 deadline; +struct BridgeData { + address target; + address approvalSpender; + uint256 value; // static msg.value addend (see BRIDGE_VALUE flag) } ``` -The contract `performExecution` function walks through this struct in a fixed order: +### `swap` -1. Pull `inputAmount` of `inputToken` from `user` (ERC20 `transferFrom` into the contract). -2. If `preFeeAmount > 0`, send that amount to `preFeeReceiver` immediately. -3. If `swapTarget != address(0)`, take a pre-swap balance snapshot of `swapOutputToken`, call the swap target, measure the balance delta, enforce `delta >= swapMinOutput`. The delta becomes `finalAmount` and `swapOutputToken` becomes `finalToken`. If there is no swap, `finalToken = inputToken` and `finalAmount = inputAmount - preFeeAmount`. -4. If `postFeeAmount > 0`, send that amount from `finalToken` to `postFeeReceiver`. -5. Write `finalAmount` into `bridgeData` at every byte offset in `bridgeAmountPositions` using an in-place `mstore`. This is the same pattern as `GenericStakedRoute.executeData`: +1. Pull `inputAmount` of `inputToken` from `user`. +2. If `fee.amount > 0` and **pre-fee** (`flags & 0x01 == 0`): transfer fee in input token, swap the remainder. +3. Approve `swapData.approvalSpender` when needed (max allowance, only if current allowance is insufficient). +4. Execute swap via `_execSwap` (see flags below). +5. Enforce `finalAmount >= swapData.minOutput` on **gross** swap output. +6. If **post-fee** (`flags & 0x01 != 0`): swap output lands on the router; fee is taken from output token; net is sent to `receiver`. +7. If **pre-fee / no fee**: swap calldata must send tokens **directly to `receiver`**; the router never holds swap output. -```solidity -// src/common/lib/BytesSpliceLib.sol — `spliceWord`, called for each position -assembly ("memory-safe") { - mstore(add(add(data, 0x20), position), word) -} -``` +### `swapAndBridge` -6. If `bridgeApprovalSpender != address(0)`, approve it for `finalAmount`. -7. Call `bridgeTarget` with the patched `bridgeData`, forwarding `bridgeValue` ETH. Any revert bubbles up with its original error data. +Same pull / pre-fee / swap / post-fee logic as above, but swap output **always** remains on `address(this)` for bridging. Then `_doBridge` splices the post-fee amount into bridge calldata (when flagged), approves the bridge spender, and calls the bridge target. -**When to use this.** Routes where the shape of the flow is always the same: pull → optional pre-fee → optional swap → optional post-fee → bridge. The contract knows the meaning of every field and enforces sensible preconditions (e.g. `finalAmount` cannot underflow below a fee). Adding a step that does not fit this shape — like a second bridge call, a pre-swap approval to a different address, or an intermediate hop — is not possible without deploying a new version of the contract. +### `bridge` -**AllowanceHolder variant (`OpenRouterAH`).** Instead of pulling with ERC20 `transferFrom` from the user to the router, the pull step calls 0x `AllowanceHolder.transferFrom` so funds move under that contract’s transient allowance (user approves AllowanceHolder, user calls `AllowanceHolder.exec` with `target = this router` and calldata invoking `performExecution`). The AH entry decodes `_msgSender()` as the original user appended by AllowanceHolder; `_pullFromUser` requires `_msgSender() == user`, so only the signer-named user matches the ephemeral allowance binding. Like Settler + AH patterns, `AllowanceHolderContext` exposes a harmless `balanceOf` on the router so AllowanceHolder’s confused-deputy probe succeeds; the rest of the pipeline is unchanged. +No swap. Pull → optional pre-bridge fee in input token → approve bridge spender → call bridge with `bridgeCallData` **unchanged**. ---- +Because `finalAmount = inputAmount - fee` is known up front, the caller must **bake the bridge amount into `bridgeCallData`** before submission. There is no runtime calldata splice on this path. -## v2 — OpenRouterModular (generic actions + returndata splicing) +--- -**File:** [`src/modular/OpenRouterModular.sol`](src/modular/OpenRouterModular.sol). AllowanceHolder variant: [`src/modular/OpenRouterModularAH.sol`](src/modular/OpenRouterModularAH.sol). +## Packed `flags` (structured routes) -This version removes all domain-specific knowledge from the contract. The only signed payload is a list of `Action`s: +One `uint256` packs switches for `swap` and `swapAndBridge` (not used by `bridge` or `performActions`): -```solidity -struct Action { - CallType callType; // CALL, DELEGATECALL, or STATICCALL - address target; - uint256 value; // ETH forwarded; must be zero for non-CALL - bytes data; // base calldata, may be partially overwritten by splices - Splice[] splices; // applied to data before this action runs -} +| Bits | Mask | Meaning | +|------|------|---------| +| 0 | `0x01` | Post-swap fee: fee taken from output token after swap. Clear = pre-swap fee from input. | +| 1 | `0x02` | Swap output via `balanceOf` delta on `outputToken`. Clear = decode return word at `swapData.returnDataWordOffset`. | +| 2 | `0x04` | Bridge `msg.value = finalAmount + bridgeData.value` (e.g. LayerZero `nativeFee` addend in `bridgeData.value`). Clear = `bridgeData.value` only. | +| 3 | `0x08` | Splice `finalAmount` into bridge calldata at byte offset `(flags >> 16) & 0xffff`. | +| 16–31 | — | Byte offset for bridge amount splice when bit 3 is set. | -struct Splice { - uint256 srcOffset; // byte offset within the *previous* action's returndata - uint256 dstOffset; // byte offset within this action's data - uint256 length; // how many bytes to copy -} -``` +Common combinations: -The loop is: +| `flags` | Fee | Swap output | Bridge `msg.value` | +|---------|-----|-------------|-------------------| +| `0x00` | pre | returndata | `bridgeData.value` | +| `0x01` | post | returndata | `bridgeData.value` | +| `0x02` | pre | balance delta | `bridgeData.value` | +| `0x03` | post | balance delta | `bridgeData.value` | +| `0x04` | pre | returndata | `finalAmount + bridgeData.value` | -``` -prevReturn = empty bytes -for each action: - apply all splices (copy ranges from prevReturn into action.data) - dispatch the call - prevReturn = returndata from this call -``` +Add `0x08` and set bits 16–31 when bridge calldata needs the live swap output at a fixed offset (same idea as `GenericStakedRoute` / `BytesSpliceLib.spliceWord`). -**How splicing works.** The problem it solves: after a swap, the exact output amount is not known until runtime. The signed `data` for the subsequent bridge call contains a placeholder value at some byte offset. A splice says "before you make this call, copy bytes `[srcOffset, srcOffset+length)` from what the previous call returned into `data[dstOffset, dstOffset+length)`". After the copy, the call is made with the updated data. +--- -A concrete example: suppose action 0 is a STATICCALL to `balanceOf(address(this))` on the output token. Its returndata is 32 bytes encoding the current balance. Action 1 is the bridge call. Its `splices` list contains one entry: `{ srcOffset: 0, dstOffset: 68, length: 32 }`, which says "take the 32-byte balance from action 0's returndata and write it at byte 68 of the bridge calldata". When action 1 runs, its calldata already has the live balance written in. +## Generic routes — `performActions` -Under the hood, the copy uses `mcopy` (Cancun, EIP-5656): +For flows that need extra hops, manipulator contracts, or multiple splices into one calldata blob, use the modular path: ```solidity -// BytesSpliceLib.spliceBytes -assembly ("memory-safe") { - mcopy( - add(add(dst, 0x20), dstOffset), - add(add(src, 0x20), srcOffset), - length - ) +struct Action { + uint256 actionInfo; // packed call metadata + bytes data; + uint256[] splices; // packed splice descriptors } + +enum CallType { CALL, STATICCALL, CALL_WITH_NATIVE } ``` -Both source and destination offsets are bounds-checked before the copy; zero-length splices are rejected. +### `actionInfo` layout -**Security note on splices.** The base `data` for every action is part of the signed payload. A splice can only overwrite bytes within that signed data — it cannot change the call target, add extra function arguments, or replace the entire calldata. An adversarial return value can only influence the specific byte ranges the signer chose to splice. The signer controls which offsets are writable by choosing which splices to include. +```text +bits 0–7 : CallType (CALL = 0, STATICCALL = 1, CALL_WITH_NATIVE = 2) +bit 8 : store returndata for later splices +bits 16+ : target address (uint160, shifted left 16) +``` -**DELEGATECALL support.** When `callType == DELEGATECALL`, the call runs with this contract's storage and `address(this)`. This is how you plug in a separate implementation contract (analogous to how `BungeeGateway` delegates to its impl) without giving it the whitelist status required by the gateway. Caution applies: a delegatecall target can modify the contract's storage, so only trusted, audited implementation contracts should be used in this slot. +### `splices[]` entry layout -**When to use this.** Any route where the exact amount flowing between steps is not known until runtime and must be piped into the next step's calldata. The canonical motivating case is an integration like Across, where two separate fields in the bridge calldata both need to reflect the swap output amount. With `GenericStakedRoute` you can only patch one offset; with this contract you declare as many splices as needed, each targeting a different offset. +Each `splices[j]` is one `uint256`: -**AllowanceHolder variant (`OpenRouterModularAH`).** The action loop is identical after verification: no built-in pull. You choose how to compose an AllowanceHolder `transferFrom` (or delegatecall shim) as one or more ordinary `CALL` actions signed with everything else; `performExecutionAH` wraps that by binding the signature to `(chainId, this, signedUser, exec)` instead of omitting `signedUser`. It asserts `_msgSender() == signedUser` so nobody can burn another user’s nonce by submitting their payload inside a stranger’s `AH.exec`; real fund safety still comes from AllowanceHolder’s operator/owner/token scoping; `AllowanceHolderContext` only supplies the dummy `balanceOf` for AH’s probing. +```text +sourceActionIndex | (srcOffset << 64) | (dstOffset << 128) | (length << 192) +``` ---- +Before action `i` runs, each splice copies `length` bytes from `results[sourceActionIndex]` at `srcOffset` into this action’s `data` at `dstOffset` (via `mcopy`). `sourceActionIndex` must be **strictly less than** `i` or the call reverts with `FutureSplice`. -## v3 — OpenRouterMinimal (generic actions, no splicing) +`CALL_WITH_NATIVE`: first 32 bytes of `data` are `msg.value`; remaining bytes are calldata. -**File:** [`src/minimal/OpenRouterMinimal.sol`](src/minimal/OpenRouterMinimal.sol). AllowanceHolder variant: [`src/minimal/OpenRouterMinimalAH.sol`](src/minimal/OpenRouterMinimalAH.sol). +There is **no built-in pull** in `performActions`. Compose AllowanceHolder `transferFrom` (or other setup) as ordinary actions in the signed/off-chain-built sequence. -This version is the stripped-down sibling of v2. The `Action` struct has no `splices` field: +--- -```solidity -struct Action { - CallType callType; - address target; - uint256 value; - bytes data; // used exactly as signed; never mutated -} -``` +## Internal helpers (shared behavior) -The loop dispatches each action with its signed data verbatim and discards the return value. There is no mechanism to move output from one call into the input of the next. +- **`_pullFromUser`** — AllowanceHolder ERC-20 pull or native `msg.value` check. +- **`_execSwap`** — balance-delta or returndata word decode; enforces `minOutput` at the entrypoint. +- **`_doBridge`** — optional `BytesSpliceLib.spliceWord` on bridge calldata, approval, then `_doCall`. +- **`_performActions`** — splice loop + low-level `call` / `staticcall` with bubbled revert data. -``` -for each action: - dispatch the call (no splice step) - discard returndata -``` +Approvals use Solady `safeApproveWithRetry` to `type(uint256).max` only when current allowance is below the needed amount. -**How steps communicate without splicing.** They don't — at least not through the router. Instead, the called contracts are responsible for reading whatever state they need at runtime. The most common pattern is pre/post balance accounting: the bridge target (e.g. a `GenericStakedRoute`-style contract or `BungeeApproveAndBridge`) calls `balanceOf(address(this))` itself to discover how much of the token it holds after the previous step deposited it, rather than receiving the amount as an argument. +--- -This is exactly how `BaseRouterSingleOutput` works: it measures the swap output by comparing balances before and after the swap call, then passes the delta to `_execute`. With v3, that accounting logic lives inside the called contracts, not in the router. +## Choosing structured vs generic -**When to use this.** Routes where every action is self-contained — the called contracts know what token to look at, query their own balance, and use that as their amount. This covers most `GenericStakedRoute` flows today, since those contracts already contain the offset-patching and balance-reading logic. v3 is the right choice when you do not need cross-action data passing at the router layer, and you want the smallest possible trusted surface in the router contract itself. +| Use | When | +|-----|------| +| `swap` | Same-chain DEX with optional fee; output to a known `receiver`. | +| `swapAndBridge` | Swap then bridge; runtime bridge amount and/or native bridge value from swap output. | +| `bridge` | No swap; amount and calldata fixed before the tx. | +| `performActions` | Multi-step or integration-specific flows (e.g. swap → manipulator → splice into `SpokePool.deposit`). | -**AllowanceHolder variant (`OpenRouterMinimalAH`).** Same idea as the modular AH: use `performExecutionAH` plus `AllowanceHolderContext`’s `balanceOf`; sign over `signedUser` and require `_msgSender() == signedUser` for nonce-binding; compose the AH pull as ordinary actions in `exec.actions`. +Structured entrypoints keep audit surface small: linear control flow and explicit preconditions. `performActions` is the escape hatch when the pipeline is not pull → fee → swap → bridge. --- -## Choosing between them +## Security model (summary) -The three versions exist on a spectrum from "the contract knows everything" to "the contract knows nothing except who signed". +| Enforced on-chain | Not enforced | +|-------------------|--------------| +| `_msgSender() == user` on ERC-20 pull | Backend signature / nonce / deadline | +| `minOutput` after swap | That calldata matches user intent | +| Splice bounds and `FutureSplice` | That `performActions` targets are benign | +| AllowanceHolder scoping for pulls | Router must not accumulate balances or receive direct user approvals | -**v1** is the right choice when you want the router to be the authoritative record of what the flow does — you can read one struct and understand the entire execution. The cost is that every variant of the flow (different fee timing, multi-hop bridge, etc.) needs a new contract or a new version. It is also the easiest to audit because the control flow is linear and every named step has an explicit precondition check. - -**v2** is the right choice when you need to pipe outputs between steps in ways the called contracts cannot handle themselves. The key example is when a bridge call has two separate amount fields that both need to reflect the swap output — one splice entry per field, both handled in one atomic execution. The contract becomes a thin orchestrator and the "business logic" of each step lives in the action targets. - -**v3** is the right choice when the called contracts already handle their own amount discovery (balance-check style) and you just need a trusted sequencer that ensures the actions run in the signed order. It is the most gas-efficient version at the router layer because there is no splice computation overhead, and it is the easiest to build new action targets for because those targets do not need to conform to any returndata shape. +`performActions` is **public**. Any caller can execute arbitrary action lists. Operational safety depends on users only approving AllowanceHolder, never OpenRouter directly, and on backend/frontend validating routes before `AllowanceHolder.exec`. See [`OPENROUTER_ASSUMPTIONS.md`](OPENROUTER_ASSUMPTIONS.md) for the full assumption set. --- -## Shared libraries - -All live under `src/common/`. +## Shared libraries (`src/common/`) -**`OpenRouterAuthBase.sol`** — abstract base all three inherit. Owns the signer address, the nonce mapping, and `_verifyAndConsume`. +| Module | Role | +|--------|------| +| `CurrencyLib` | Native sentinel + transfers / `balanceOf` | +| `BytesSpliceLib` | `spliceWord` for bridge calldata; `mcopy`-based `spliceBytes` in modular path | +| `RescueFundsLib` | `rescueFunds` implementation | +| `AllowanceHolderContext` | `_msgSender()` / dummy `balanceOf` for AH | -**`lib/AuthenticationLib.sol`** — personal_sign recovery (`\x19Ethereum Signed Message:\n32` + ecrecover). Matches `marketplace/src/lib/AuthenticationLib.sol` exactly. +`OpenRouterAuthBase` and signed-router variants are **not** used by this contract. -**`lib/CurrencyLib.sol`** — wraps Solady `SafeTransferLib` with a native token shortcut (address `0xEee...EEe`), identical in spirit to the marketplace `CurrencyLib`. - -**`lib/BytesSpliceLib.sol`** — used by v1 (writing `finalAmount` to multiple positions in bridge calldata) and v2 (the per-splice `mcopy`). Exposes `spliceWord` (32-byte in-place overwrite, same assembly as `GenericStakedRoute`), `spliceWords` (repeat for multiple positions), and `spliceBytes` (arbitrary-length copy via `mcopy`, bounds-checked). - -**`allowance/AllowanceHolderContext.sol`**, **`interfaces/IAllowanceHolder.sol`** — imported only by the `*AH` contracts in each variant folder. +--- +## Backend and tests +ABI encoders (update if the Solidity ABI changes): +- `bungee-backend/src/modules/dex/utils.ts` — `swap`, AllowanceHolder `exec` +- `bungee-backend/src/modules/router/utils/directQuotesOpenRouter.ts` — `bridge`, `swapAndBridge` -0. AllowanceHolder -1. OpenRouter -> Fee Transfer -> -2. OpenRouter (modify input) -> Swap execution -> OpenRouter (modify input) -> -3. AcrossManipulator -> OpenRouter (modify input) -> -4. SpokePool +Tests: -0. AllowanceHolder -1. OpenRouter -> Fee Transfer -> -2. OpenRouter (modify input) -> Swap execution -> OpenRouter (modify input) -> -3. AcrossRouter(amount, AcrossBridgeData) -> (modify SpokePool input with output ) -> SpokePool +- `test/combined/OpenRouterV2Unchecked*.t.sol` — unit tests against `src/OpenRouter.sol` +- `test/poc/*OpenRouterPoC.t.sol` — fork PoCs using `performActions` + manipulators -0. AllowanceHolder -1. AcrossRouter - should have all the fee, swap, bridge code in this \ No newline at end of file +Deploy: `scripts/deploy/deployOpenRouter.ts` (`constructor(address _owner)` grants `RESCUE_ROLE`). From 7ba76fe69603dfdaa8cb33ddeca4347ad17e6a99 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Wed, 20 May 2026 13:51:06 +0530 Subject: [PATCH 37/42] feat: create3 --- scripts/deploy/create3.ts | 39 +++++++++++++++++++++++ scripts/deploy/deployOpenRouter.ts | 51 ++++++++++++++++++++++++------ 2 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 scripts/deploy/create3.ts diff --git a/scripts/deploy/create3.ts b/scripts/deploy/create3.ts new file mode 100644 index 0000000..4009a7d --- /dev/null +++ b/scripts/deploy/create3.ts @@ -0,0 +1,39 @@ +import { Log, TransactionReceipt, keccak256, toUtf8Bytes } from 'ethers'; + +// CreateX factory — https://createx.rocks/ +export const CREATE_X_FACTORY = '0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed'; + +export const Create3ABI = [ + 'function computeCreate2Address(bytes32,bytes32,address) view returns (address)', + 'function deployCreate2(bytes32,bytes) payable returns (address)', + 'function computeCreate3Address(bytes32,address) view returns (address)', + 'function deployCreate3(bytes32,bytes) payable returns (address)', +]; + +const Create3ContractCreationEvent = 'ContractCreation(address)'; +const Create3ContractCreationEventTopicHash = keccak256( + toUtf8Bytes(Create3ContractCreationEvent), +); + +/** + * Reads the deployed contract address from a CreateX CREATE3 deployment receipt. + */ +export function decodeCreate3DeploymentFromTxReceipt(params: { + receipt: TransactionReceipt; +}): string | null { + const { receipt } = params; + const filteredLogs: Log[] = receipt.logs.filter((log: Log) => + log.topics.includes(Create3ContractCreationEventTopicHash), + ); + + if (filteredLogs.length === 0) { + return null; + } + + const eventData = filteredLogs[0].topics[1]; + if (!eventData) { + return null; + } + + return '0x' + eventData.slice(26); +} diff --git a/scripts/deploy/deployOpenRouter.ts b/scripts/deploy/deployOpenRouter.ts index 6afcb60..624ba30 100644 --- a/scripts/deploy/deployOpenRouter.ts +++ b/scripts/deploy/deployOpenRouter.ts @@ -1,21 +1,25 @@ /** - * Deployment script for OpenRouter. + * Deployment script for OpenRouter via CreateX CREATE3. * * Usage: * npx hardhat run scripts/deploy/deployOpenRouter.ts --network * * Required env vars: * DEPLOYER_PRIVATE_KEY — deployer wallet private key - */ import hre from 'hardhat'; import { ethers } from 'hardhat'; +import { keccak256, toUtf8Bytes } from 'ethers'; +import { + CREATE_X_FACTORY, + Create3ABI, + decodeCreate3DeploymentFromTxReceipt, +} from './create3'; async function main() { const [deployer] = await ethers.getSigners(); const networkName = hre.network.name; - const owner = deployer.address; console.log('Deployer: ', deployer.address); @@ -23,11 +27,42 @@ async function main() { console.log('Network: ', networkName); console.log(''); - console.log('Deploying OpenRouter...'); + const constructorArgs = { _owner: owner }; + console.log('constructorArgs', constructorArgs); + + const create3Factory = new ethers.Contract( + CREATE_X_FACTORY, + Create3ABI, + deployer, + ); + const factory = await ethers.getContractFactory('OpenRouter'); - const router = await factory.deploy(owner); - await router.waitForDeployment(); - const routerAddress = await router.getAddress(); + const deployTransaction = await factory.getDeployTransaction(owner); + + const saltText = 'OpenRouter' + 1; + const salt = keccak256(toUtf8Bytes(saltText)); + + const deployAddress = await create3Factory.deployCreate3.staticCall( + salt, + deployTransaction.data, + ); + console.log('Contract address will be:', deployAddress); + + console.log('Deploying OpenRouter via CREATE3...'); + const create3Deployment = await create3Factory.deployCreate3( + salt, + deployTransaction.data, + ); + console.log('CREATE3 deployment tx:', create3Deployment.hash); + + const receipt = await create3Deployment.wait(); + const routerAddress = decodeCreate3DeploymentFromTxReceipt({ receipt }); + if (!routerAddress) { + throw new Error( + 'OpenRouter address not found in CREATE3 deployment receipt', + ); + } + console.log('OpenRouter deployed to:', routerAddress); console.log('\n=== Deployment Summary ==='); @@ -35,10 +70,8 @@ async function main() { const chainId = (await ethers.provider.getNetwork()).chainId; if (chainId !== 31337n) { - // sleep for 5secs before verification attempt await new Promise((resolve) => setTimeout(resolve, 5000)); - // run verification await hre.run('verify:verify', { address: routerAddress, constructorArguments: [owner], From 4a7b7325d9d1b1a063656c914a53bc2ccb777855 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Wed, 20 May 2026 21:07:32 +0530 Subject: [PATCH 38/42] chore: comments --- OPENROUTER.md | 65 ++++++++++++++++++++---- src/OpenRouter.sol | 124 +++++++++++++++++++++++++++++++++------------ 2 files changed, 148 insertions(+), 41 deletions(-) diff --git a/OPENROUTER.md b/OPENROUTER.md index 17b94a4..ca6020c 100644 --- a/OPENROUTER.md +++ b/OPENROUTER.md @@ -141,9 +141,9 @@ For flows that need extra hops, manipulator contracts, or multiple splices into ```solidity struct Action { - uint256 actionInfo; // packed call metadata - bytes data; - uint256[] splices; // packed splice descriptors + uint256 actionInfo; // packed call metadata (see below) + bytes data; // calldata; patched by splices before the call + uint256[] splices; // packed splice descriptors (see below) } enum CallType { CALL, STATICCALL, CALL_WITH_NATIVE } @@ -151,23 +151,70 @@ enum CallType { CALL, STATICCALL, CALL_WITH_NATIVE } ### `actionInfo` layout +One `uint256` per action. All fields are uint64-safe except `target` (uint160). + +| Bits | Field | Type | Meaning | +|------|-------|------|---------| +| 0–2 | `callType` | `uint8` | `CallType`: `CALL` (0), `STATICCALL` (1), `CALL_WITH_NATIVE` (2) | +| 3–7 | — | — | reserved (0) | +| 8 | `storeResult` | `bool` | When set, returndata is saved to `results[i]` even on success so later actions can splice from it | +| 9–15 | — | — | reserved (0) | +| 16–175 | `target` | `address` | Callee address (`uint160`, shifted left 16) | +| 176–255 | — | — | reserved (0) | + +Packing (matches `packActionInfo` in [`scripts/e2e/utils/modularActionsBuilder/index.js`](scripts/e2e/utils/modularActionsBuilder/index.js)): + ```text -bits 0–7 : CallType (CALL = 0, STATICCALL = 1, CALL_WITH_NATIVE = 2) -bit 8 : store returndata for later splices -bits 16+ : target address (uint160, shifted left 16) +callType | (storeResult ? 1 << 8 : 0) | (uint160(target) << 16) ``` +`CALL_WITH_NATIVE`: first 32 bytes of `data` are `msg.value`; remaining bytes are calldata. + ### `splices[]` entry layout -Each `splices[j]` is one `uint256`: +Each `splices[j]` is one `uint256` describing a byte-range copy from a prior action’s returndata into this action’s `data`. Offsets are into the **payload** bytes (the bytes-array contents), not including Solidity’s 32-byte length prefix. + +| Bits | Field | Type | Meaning | +|------|-------|------|---------| +| 0–63 | `sourceActionIndex` | `uint64` | Index of the prior action whose returndata is the copy source | +| 64–127 | `srcOffset` | `uint64` | Byte offset into `results[sourceActionIndex]` payload | +| 128–191 | `dstOffset` | `uint64` | Byte offset into this action’s `data` payload | +| 192–255 | `length` | `uint64` | Number of bytes to copy (must be > 0) | + +Packing (matches `packSpliceInfo` in the modular actions builder): ```text sourceActionIndex | (srcOffset << 64) | (dstOffset << 128) | (length << 192) ``` -Before action `i` runs, each splice copies `length` bytes from `results[sourceActionIndex]` at `srcOffset` into this action’s `data` at `dstOffset` (via `mcopy`). `sourceActionIndex` must be **strictly less than** `i` or the call reverts with `FutureSplice`. +Before action `i` runs, each splice copies `length` bytes from `results[sourceActionIndex]` at `srcOffset` into this action’s `data` at `dstOffset` (via `mcopy`). Constraints enforced on-chain: -`CALL_WITH_NATIVE`: first 32 bytes of `data` are `msg.value`; remaining bytes are calldata. +- `sourceActionIndex < i` — otherwise `FutureSplice` +- `srcOffset + length <= source.length` and `dstOffset + length <= data.length` — otherwise `SpliceOutOfBounds` +- The source action must have `storeResult` set (bit 8 of its `actionInfo`); the JS builder sets this automatically when a splice references that action + +**Destination offset conventions** (builder helpers in `modularActionsBuilder/index.js`): + +| Helper | `dstOffset` for… | +|--------|------------------| +| `spliceArg(n, source)` | ABI arg `n` in a normal call: `4 + n * 32` (past the 4-byte selector) | +| `valueFrom(source)` / `spliceNativeValue` | Leading value word of `CALL_WITH_NATIVE`: `0` | +| `splicePayloadWord(off, source)` | Payload of `CALL_WITH_NATIVE`: `32 + off` | +| `patchWord(off, source)` | Absolute payload offset `off` | + +Example: splice the first 32 bytes of action 0’s returndata into byte offset 132 of action 2’s calldata: + +```js +const { packSpliceInfo } = require("./scripts/e2e/utils/modularActionsBuilder/index"); + +packSpliceInfo({ + sourceActionIndex: 0, + srcOffset: 0, + dstOffset: 132, + length: 32, +}); +// => 0n | (0n << 64n) | (132n << 128n) | (32n << 192n) +``` There is **no built-in pull** in `performActions`. Compose AllowanceHolder `transferFrom` (or other setup) as ordinary actions in the signed/off-chain-built sequence. diff --git a/src/OpenRouter.sol b/src/OpenRouter.sol index 858c00d..32abfea 100644 --- a/src/OpenRouter.sol +++ b/src/OpenRouter.sol @@ -57,8 +57,41 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { } struct Action { + /// @dev Packed call metadata. Decode with masks/shifts below; encode with + /// `callType | (storeResult ? 1 << 8 : 0) | (uint160(target) << 16)`. + /// + /// Bit layout (least significant bits first): + /// bits 255..160 : reserved (0) + /// bits 159..16 : target address (uint160, left-aligned in this field) + /// bit 8 : storeResult — when set, returndata is saved to `results[i]` + /// even on success so later actions can splice from it + /// bits 7..3 : reserved (0) + /// bits 2..0 : CallType — CALL (0), STATICCALL (1), CALL_WITH_NATIVE (2) + /// + /// CALL_WITH_NATIVE: first 32 bytes of `data` are forwarded as `msg.value`; + /// the remaining bytes are the call payload. uint256 actionInfo; + /// @dev Calldata passed to the target. Splices from `splices[]` overwrite byte + /// ranges in a mutable memory copy before the external call runs. bytes data; + /// @dev Packed splice descriptors applied to `data` before the call. + /// Each entry is one `uint256` with four uint64 fields (see layout below). + /// Encode with `packSpliceInfo` in `scripts/e2e/utils/modularActionsBuilder/index.js`. + /// + /// Per-entry bit layout (least significant bits first): + /// bits 255..192 : length — number of bytes to copy (must be > 0) + /// bits 191..128 : dstOffset — byte offset into this action's `data` payload + /// (skips the bytes-array length word; for CALL_WITH_NATIVE, + /// offset 0 is the value word, offset 32 is payload start) + /// bits 127..64 : srcOffset — byte offset into `results[sourceActionIndex]` + /// payload (same length-prefix convention) + /// bits 63..0 : sourceActionIndex — index of a prior action (< current index) + /// + /// Packing formula: + /// sourceActionIndex | (srcOffset << 64) | (dstOffset << 128) | (length << 192) + /// + /// The source action must have bit 8 set in `actionInfo` (storeResult); the JS + /// builder sets this automatically when a splice references that action. uint256[] splices; } @@ -467,11 +500,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { /** * @dev Executes `actions` in order, applying returndata splices before each call. - * @dev actionInfo layout: - * - bits 0–7: call type (`CallType`) - * - bit 8: store returndata - * - bits 16+: target address - * splices[j` packs source index, src/dst byte offsets, and length. + * @dev See `Action` for `actionInfo` and `splices[]` bit layouts. * @param actions Ordered list of actions to run. */ function _performActions(Action[] calldata actions) internal { @@ -482,21 +511,25 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { Action calldata action = actions[i]; bytes memory callData = action.data; + // Patch callData with slices of prior action returndata. uint256 splicesLength = action.splices.length; for (uint256 j; j < splicesLength;) { uint256 spliceInfo = action.splices[j]; - uint256 sourceActionIndex = uint64(spliceInfo); + uint256 sourceActionIndex = uint64(spliceInfo); // first 64 bits: index of the prior action to read returndata from. if (sourceActionIndex >= i) revert FutureSplice(i, sourceActionIndex); - uint256 srcOffset = uint64(spliceInfo >> 64); - uint256 dstOffset = uint64(spliceInfo >> 128); - uint256 length = spliceInfo >> 192; + uint256 srcOffset = uint64(spliceInfo >> 64); // Next 64 bits: byte offset into source returndata + uint256 dstOffset = uint64(spliceInfo >> 128); // Next 64 bits: byte offset into next action's data + uint256 length = spliceInfo >> 192; // Top 64 bits: number of bytes to copy + + // Fetch source action returndata bytes memory source = results[sourceActionIndex]; if (srcOffset + length > source.length || dstOffset + length > callData.length) { revert SpliceOutOfBounds(i, j); } assembly ("memory-safe") { + // copy `length` bytes from `source returndata starting from `srcOffset` to `callData` starting from `dstOffset` mcopy(add(add(callData, 0x20), dstOffset), add(add(source, 0x20), srcOffset), length) } @@ -505,14 +538,16 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { } } + // Parse actionInfo bool success; uint256 actionInfo = action.actionInfo; - bool storeResult = (actionInfo & 0xff00) != 0; - uint256 callType = actionInfo & 0xff; - address target = address(uint160(actionInfo >> 16)); + bool storeResult = (actionInfo & 0xff00) != 0; // Bit 8: persist returndata if set + uint256 callType = actionInfo & 0xff; // Bits 0–7: specify CallType + address target = address(uint160(actionInfo >> 16)); // Bits 16+: target address if (callType == uint256(CallType.STATICCALL)) { assembly ("memory-safe") { + // staticcall without copying return data by default success := staticcall(gas(), target, add(callData, 0x20), mload(callData), 0, 0) } } else if (callType == uint256(CallType.CALL_WITH_NATIVE)) { @@ -520,25 +555,32 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { uint256 callValue; uint256 payloadLength = callData.length - 32; assembly ("memory-safe") { - callValue := mload(add(callData, 0x20)) - success := call(gas(), target, callValue, add(callData, 0x40), payloadLength, 0, 0) + // regular call with value forwarded without copying return data by default + callValue := mload(add(callData, 0x20)) // CALL_WITH_NATIVE prepends a 32-byte wei amount before the actual calldata payload. + success := call(gas(), target, callValue, add(callData, 0x40), payloadLength, 0, 0) // skips first two bytes to reach actuall calldata } } else { assembly ("memory-safe") { + // regular call with zero value forwarded without copying return data by default success := call(gas(), target, 0, add(callData, 0x20), mload(callData), 0, 0) } } + // Capture returndata on failure (for revert reason) or when explicitly requested. if (!success || storeResult) { bytes memory ret; assembly ("memory-safe") { + // prep return / revert data let returnDataSize := returndatasize() ret := mload(0x40) - mstore(ret, returnDataSize) - returndatacopy(add(ret, 0x20), 0, returnDataSize) - mstore(0x40, and(add(add(add(ret, 0x20), returnDataSize), 0x1f), not(0x1f))) + mstore(ret, returnDataSize) // write length prefix on free-mem pointer + returndatacopy(add(ret, 0x20), 0, returnDataSize) // copy returndata after length + mstore(0x40, and(add(add(add(ret, 0x20), returnDataSize), 0x1f), not(0x1f))) // Advance free pointer to next 32-byte boundary: (ret + 0x20 + size + 31) and clear last 5 bits with not(0x1f) } + // if any call was failed, revert with the returndata if (!success) revert CallFailed(i, ret); + + // else, save returndata to results array results[i] = ret; } unchecked { @@ -575,17 +617,32 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { // Call AllowanceHolder.transferFrom() address allowanceHolder = address(ALLOWANCE_HOLDER); assembly ("memory-safe") { + // Manually ABI-encode AllowanceHolder.transferFrom(address token, address owner, address recipient, uint256 amount) + // selector 0x15dacbea. Calldata is 0x84 (132) bytes and starts at ptr+0x1c (see last mstore below). + // + // The `shl(0x60, addr)` trick left-aligns a 20-byte address in a 32-byte word: the high 20 bytes + // hold the address and the trailing 12 bytes are zero, which simultaneously encodes the address AND + // provides the ABI zero-padding for the *next* field — so each shifted mstore clears the following + // field's padding without a separate write. + // + // Calldata layout relative to ptr+0x1c: + // [0..3] selector (0x15dacbea) + // [4..35] token (12-byte pad + 20-byte address) + // [36..67] owner/user (12-byte pad + 20-byte address) + // [68..99] recipient (12-byte pad + 20-byte address = address(this)) + // [100..131] amount (uint256) let ptr := mload(0x40) - mstore(add(0x80, ptr), amount) - mstore(add(0x60, ptr), address()) - mstore(add(0x4c, ptr), shl(0x60, user)) // clears `recipient`'s padding + mstore(add(0x80, ptr), amount) // calldata[100..131]: amount (uint256, right-aligned) + mstore(add(0x60, ptr), address()) // calldata[68..99]: recipient = this contract (right-aligned, high 12 bytes are zero padding) + mstore(add(0x4c, ptr), shl(0x60, user)) // calldata[48..67]: user address; trailing 12 zero bytes fill calldata[68..79] (recipient padding) // `shl(0x60)` (96-bit), NOT `shl(0xa0)` (160-bit): 0xa0 here is literal 160, which // shifts the 20-byte address out of place and corrupts the calldata token. Same as // 0x-settler `Permit2Payment._allowanceHolderTransferFrom`. - mstore(add(0x2c, ptr), shl(0x60, token)) // clears `owner`'s padding (settler wording) - mstore(add(0x0c, ptr), 0x15dacbea000000000000000000000000) // selector + token padding + mstore(add(0x2c, ptr), shl(0x60, token)) // calldata[16..35]: token address; trailing 12 zero bytes fill calldata[36..47] (user padding) + mstore(add(0x0c, ptr), 0x15dacbea000000000000000000000000) // selector at calldata[0..3]; 12 zero bytes fill calldata[4..15] (token padding); calldata begins at ptr+0x1c if iszero(call(gas(), allowanceHolder, 0x00, add(0x1c, ptr), 0x84, 0x00, 0x00)) { + // if call did not succeed, revert with the revert returndata let p := mload(0x40) returndatacopy(p, 0x00, returndatasize()) revert(p, returndatasize()) @@ -638,12 +695,13 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { if (!success) { bytes memory ret; assembly ("memory-safe") { + // prep and return revert data let returnDataSize := returndatasize() ret := mload(0x40) - mstore(ret, returnDataSize) - returndatacopy(add(ret, 0x20), 0, returnDataSize) - mstore(0x40, and(add(add(add(ret, 0x20), returnDataSize), 0x1f), not(0x1f))) - revert(add(ret, 0x20), mload(ret)) + mstore(ret, returnDataSize) // write length prefix on free-mem pointer + returndatacopy(add(ret, 0x20), 0, returnDataSize) // copy returndata after length + mstore(0x40, and(add(add(add(ret, 0x20), returnDataSize), 0x1f), not(0x1f))) // bump free pointer + revert(add(ret, 0x20), mload(ret)) // bubbles up the original revert payload } } } @@ -664,22 +722,23 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { bool success; assembly ("memory-safe") { let ptr := mload(0x40) - calldatacopy(ptr, data.offset, data.length) - mstore(0x40, and(add(add(ptr, data.length), 0x1f), not(0x1f))) + calldatacopy(ptr, data.offset, data.length) // copy calldata slice to fresh memory (avoids redundant memory alloc) + mstore(0x40, and(add(add(ptr, data.length), 0x1f), not(0x1f))) // advance free pointer to next 32-byte boundary success := call(gas(), target, value, ptr, data.length, 0, 0) } if (!success || storeResult) { assembly ("memory-safe") { + // prep and return revert data let returnDataSize := returndatasize() ret := mload(0x40) - mstore(ret, returnDataSize) - returndatacopy(add(ret, 0x20), 0, returnDataSize) - mstore(0x40, and(add(add(add(ret, 0x20), returnDataSize), 0x1f), not(0x1f))) + mstore(ret, returnDataSize) // write length prefix on free-mem pointer + returndatacopy(add(ret, 0x20), 0, returnDataSize) // copy returndata after length + mstore(0x40, and(add(add(add(ret, 0x20), returnDataSize), 0x1f), not(0x1f))) // bump free pointer } if (!success) { assembly ("memory-safe") { - revert(add(ret, 0x20), mload(ret)) + revert(add(ret, 0x20), mload(ret)) // bubble up the raw revert payload } } } @@ -696,6 +755,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { if (offset + 32 > ret.length) revert ReturnDataOutOfBounds(); assembly ("memory-safe") { + // read the word at the offset from return data word := mload(add(add(ret, 0x20), offset)) } } From e2a09a5d532f9a8b000419a2a36010ed8ecca415 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Wed, 20 May 2026 21:36:51 +0530 Subject: [PATCH 39/42] test: allowance holder pull fork gas test --- test/poc/OpenRouterAllowanceHolderFork.t.sol | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 test/poc/OpenRouterAllowanceHolderFork.t.sol diff --git a/test/poc/OpenRouterAllowanceHolderFork.t.sol b/test/poc/OpenRouterAllowanceHolderFork.t.sol new file mode 100644 index 0000000..abde3fe --- /dev/null +++ b/test/poc/OpenRouterAllowanceHolderFork.t.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "solady/src/tokens/ERC20.sol"; + +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; +import {ALLOWANCE_HOLDER, IAllowanceHolder} from "../../src/common/interfaces/IAllowanceHolder.sol"; + +/// @dev No-op bridge target so `router.bridge` can complete after the pull. +contract NoopBridgeTarget { + function ping() external {} +} + +/// @notice Polygon fork: user funds + AH approval, entry via AllowanceHolder.exec, OpenRouter pulls via `_pullFromUser`. +contract OpenRouterAllowanceHolderForkTest is Test { + address internal constant POLYGON_USDC = 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359; + uint256 internal constant POLYGON_FORK_BLOCK = 86_816_149; + uint256 internal constant INPUT_AMOUNT = 100e6; + + address internal user; + + function setUp() public { + user = makeAddr("ahForkUser"); + } + + function test_fork_openRouter_bridge_pullsFromUserViaAllowanceHolder() public { + string memory rpcUrl = vm.envOr("POLYGON_RPC", string("")); + if (bytes(rpcUrl).length == 0) { + emit log("Set POLYGON_RPC to run this fork test."); + return; + } + + uint256 forkBlock = vm.envOr("POLYGON_FORK_BLOCK", POLYGON_FORK_BLOCK); + vm.createSelectFork(rpcUrl, forkBlock); + + Router router = new Router(address(this)); + NoopBridgeTarget noopBridge = new NoopBridgeTarget(); + + deal(POLYGON_USDC, user, INPUT_AMOUNT); + assertEq(ERC20(POLYGON_USDC).balanceOf(address(router)), 0, "router must not be pre-funded"); + + vm.prank(user); + ERC20(POLYGON_USDC).approve(address(ALLOWANCE_HOLDER), INPUT_AMOUNT); + + bytes memory routerCalldata = abi.encodeCall( + Router.bridge, + ( + keccak256("open-router-ah-fork"), + Router.InputData({user: user, inputToken: POLYGON_USDC, inputAmount: INPUT_AMOUNT}), + Router.FeeData({receiver: address(0), amount: 0}), + Router.BridgeData({target: address(noopBridge), approvalSpender: address(0), value: 0}), + abi.encodeCall(NoopBridgeTarget.ping, ()) + ) + ); + + // Runtime-only gas (excludes `new OpenRouter` / `new NoopBridgeTarget` above). + // Forge's per-test `gas:` figure still includes deployment; use this log for comparisons. + uint256 gasBeforeExec = gasleft(); + vm.prank(user); + IAllowanceHolder(address(ALLOWANCE_HOLDER)).exec( + address(router), POLYGON_USDC, INPUT_AMOUNT, payable(address(router)), routerCalldata + ); + uint256 runtimeGas = gasBeforeExec - gasleft(); + emit log_named_uint("runtime gas AH.exec -> router.bridge", runtimeGas); + + assertEq(ERC20(POLYGON_USDC).balanceOf(user), 0, "user balance"); + assertEq(ERC20(POLYGON_USDC).balanceOf(address(router)), INPUT_AMOUNT, "router pulled via AH"); + } +} From bce867e1f2c922c391eb48db1c0805180847ae7c Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 25 May 2026 18:58:12 +0530 Subject: [PATCH 40/42] chore: comments, function renames --- src/OpenRouter.sol | 21 ++++++++++----------- src/common/lib/BytesSpliceLib.sol | 4 +--- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/OpenRouter.sol b/src/OpenRouter.sol index 32abfea..d9aa58d 100644 --- a/src/OpenRouter.sol +++ b/src/OpenRouter.sol @@ -315,7 +315,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { uint256 finalAmount = _swapBeforeBridge(flags, input, fee, swapData, swapCallData); // Execute bridge - _doBridge(swapData.outputToken, finalAmount, flags, bridgeData, bridgeCallData); + _execBridge(swapData.outputToken, finalAmount, flags, bridgeData, bridgeCallData); emit RequestExecuted(quoteId); } @@ -368,7 +368,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { } // Execute bridge - _doCallCalldata(bridgeData.target, bridgeData.value, bridgeCallData, false); + _execCallCalldata(bridgeData.target, bridgeData.value, bridgeCallData, false); emit RequestExecuted(quoteId); } @@ -399,7 +399,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { * @param fee Fee receiver and amount; `amount == 0` skips fee collection. * @param swapData Swap target, spender, output token, value, `minOutput`, and returndata offset. * @param swapCallData Calldata forwarded to `swapData.target`. - * @return finalAmount Swap output net of any post-swap fee, ready for `_doBridge`. + * @return finalAmount Swap output net of any post-swap fee, ready for `_execBridge`. */ function _swapBeforeBridge( uint256 flags, @@ -461,7 +461,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { * @param bridgeData Bridge target, approval spender, and static `msg.value` addend. * @param bridgeCallData Base bridge calldata; copied to memory when splicing is required. */ - function _doBridge( + function _execBridge( address token, uint256 amount, uint256 flags, @@ -491,7 +491,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { uint256 bridgeValue = ((flags & BRIDGE_VALUE_FLAG_BIT_MASK) != 0) ? amount + bridgeData.value : bridgeData.value; // Execute bridge call - _doCall(bridgeData.target, bridgeValue, _bridgeCallData); + _execCall(bridgeData.target, bridgeValue, _bridgeCallData); } // -------------------------------------- @@ -636,8 +636,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { mstore(add(0x60, ptr), address()) // calldata[68..99]: recipient = this contract (right-aligned, high 12 bytes are zero padding) mstore(add(0x4c, ptr), shl(0x60, user)) // calldata[48..67]: user address; trailing 12 zero bytes fill calldata[68..79] (recipient padding) // `shl(0x60)` (96-bit), NOT `shl(0xa0)` (160-bit): 0xa0 here is literal 160, which - // shifts the 20-byte address out of place and corrupts the calldata token. Same as - // 0x-settler `Permit2Payment._allowanceHolderTransferFrom`. + // shifts the 20-byte address out of place and corrupts the calldata token. Same as 0x-settler `Permit2Payment._allowanceHolderTransferFrom`. mstore(add(0x2c, ptr), shl(0x60, token)) // calldata[16..35]: token address; trailing 12 zero bytes fill calldata[36..47] (user padding) mstore(add(0x0c, ptr), 0x15dacbea000000000000000000000000) // selector at calldata[0..3]; 12 zero bytes fill calldata[4..15] (token padding); calldata begins at ptr+0x1c @@ -671,11 +670,11 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { if (useBalanceOf) { // Measure output as (balance after − balance before) at `outputReceiver` uint256 before = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver); - _doCallCalldata(swapData.target, swapData.value, swapCallData, false); + _execCallCalldata(swapData.target, swapData.value, swapCallData, false); finalAmount = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver) - before; } else { // Decode output from returndata - bytes memory ret = _doCallCalldata(swapData.target, swapData.value, swapCallData, true); + bytes memory ret = _execCallCalldata(swapData.target, swapData.value, swapCallData, true); finalAmount = _decodeReturnWord(ret, swapData.returnDataWordOffset); } } @@ -686,7 +685,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { * @param value Wei forwarded with the call. * @param data ABI-encoded calldata in memory. */ - function _doCall(address target, uint256 value, bytes memory data) internal { + function _execCall(address target, uint256 value, bytes memory data) internal { bool success; assembly ("memory-safe") { success := call(gas(), target, value, add(data, 0x20), mload(data), 0, 0) @@ -715,7 +714,7 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { * @param storeResult When true, copy returndata into memory even on success. * @return ret Returndata when `storeResult` is true or the call reverts (revert bubbles). */ - function _doCallCalldata(address target, uint256 value, bytes calldata data, bool storeResult) + function _execCallCalldata(address target, uint256 value, bytes calldata data, bool storeResult) internal returns (bytes memory ret) { diff --git a/src/common/lib/BytesSpliceLib.sol b/src/common/lib/BytesSpliceLib.sol index fc6a890..e094de6 100644 --- a/src/common/lib/BytesSpliceLib.sol +++ b/src/common/lib/BytesSpliceLib.sol @@ -2,8 +2,7 @@ pragma solidity 0.8.34; /// @title BytesSpliceLib -/// @notice Generalisation of the in-place calldata patching used in -/// GenericStakedRoute and BungeeApproveAndBridge. Supports patching +/// @notice Generalisation of the in-place calldata patching. Supports patching /// either a single 32-byte word (for `uint256` amount fields) or an /// arbitrary length copy from one bytes blob to another. library BytesSpliceLib { @@ -13,7 +12,6 @@ library BytesSpliceLib { error SplicePositionOutOfBounds(); /// @notice Overwrites a 32-byte word at `position` in `data` with `word`. - /// @dev Mirrors the GenericStakedRoute amount patching pattern. function spliceWord(bytes memory data, uint256 position, uint256 word) internal pure { // Bounds check: position + 32 must fit in data if (position + 32 > data.length) { From c1c4b4adbe0d4fa1168bacb3d7af992329ed05fe Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 25 May 2026 19:38:03 +0530 Subject: [PATCH 41/42] chore: prev. audit comments --- src/common/lib/CurrencyLib.sol | 1 + src/common/lib/RescueFundsLib.sol | 1 + src/common/utils/AccessControl.sol | 1 + src/common/utils/Ownable.sol | 1 + 4 files changed, 4 insertions(+) diff --git a/src/common/lib/CurrencyLib.sol b/src/common/lib/CurrencyLib.sol index 7208f66..6c7f208 100644 --- a/src/common/lib/CurrencyLib.sol +++ b/src/common/lib/CurrencyLib.sol @@ -5,6 +5,7 @@ import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; error TransferFailed(); +// @audit Audited before by Hexens: // @audit Audited before by Zellic: https://github.com/SocketDotTech/audits/blob/main/Socket-DL/07-2023%20-%20Data%20Layer%20-%20Zellic.pdf /// @title CurrencyLib /// @notice Token transfer + balance helpers that treat the canonical native /// pseudo-token (`0xEee...EEe`) the same way as the marketplace's diff --git a/src/common/lib/RescueFundsLib.sol b/src/common/lib/RescueFundsLib.sol index 221c215..22c4423 100644 --- a/src/common/lib/RescueFundsLib.sol +++ b/src/common/lib/RescueFundsLib.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.34; +// @audit Audited before by Zellic: https://github.com/SocketDotTech/audits/blob/main/Socket-DL/07-2023%20-%20Data%20Layer%20-%20Zellic.pdf import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; error ZeroAddress(); diff --git a/src/common/utils/AccessControl.sol b/src/common/utils/AccessControl.sol index 3faca7d..51e3f72 100644 --- a/src/common/utils/AccessControl.sol +++ b/src/common/utils/AccessControl.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.34; import {Ownable} from "./Ownable.sol"; +// @audit Audited before by Zellic: https://github.com/SocketDotTech/audits/blob/main/Socket-DL/07-2023%20-%20Data%20Layer%20-%20Zellic.pdf abstract contract AccessControl is Ownable { mapping(bytes32 => mapping(address => bool)) private _permits; diff --git a/src/common/utils/Ownable.sol b/src/common/utils/Ownable.sol index dd83c0b..a7c7f17 100644 --- a/src/common/utils/Ownable.sol +++ b/src/common/utils/Ownable.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.34; +// @audit Audited before by Zellic: https://github.com/SocketDotTech/audits/blob/main/Socket-DL/07-2023%20-%20Data%20Layer%20-%20Zellic.pdf /// @title Ownable /// @notice Two-step ownership transfer, ported from /// marketplace/src/utils/Ownable.sol. Simpler than OpenZeppelin's From 220c7a7ee049f4a2ef6fe7913d4ee6b0ea7557c3 Mon Sep 17 00:00:00 2001 From: Sebastian T F Date: Mon, 25 May 2026 19:41:31 +0530 Subject: [PATCH 42/42] fix: prev. audit comment --- src/common/lib/CurrencyLib.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/lib/CurrencyLib.sol b/src/common/lib/CurrencyLib.sol index 6c7f208..56ca7e0 100644 --- a/src/common/lib/CurrencyLib.sol +++ b/src/common/lib/CurrencyLib.sol @@ -5,7 +5,7 @@ import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; error TransferFailed(); -// @audit Audited before by Hexens: // @audit Audited before by Zellic: https://github.com/SocketDotTech/audits/blob/main/Socket-DL/07-2023%20-%20Data%20Layer%20-%20Zellic.pdf +// @audit Audited before by Hexens: https://github.com/SocketDotTech/audits/blob/main/Bungee/12-2024%20-%20Bungee%20Protocol%20-%20Hexens.pdf /// @title CurrencyLib /// @notice Token transfer + balance helpers that treat the canonical native /// pseudo-token (`0xEee...EEe`) the same way as the marketplace's