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/.github/workflows/test.yml b/.github/workflows/test.yml index b79c8d4..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: @@ -28,9 +30,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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7e55c30 --- /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/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..ca6020c 100644 --- a/OPENROUTER.md +++ b/OPENROUTER.md @@ -1,272 +1,285 @@ -# BungeeOpenRouter — 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/ - BungeeOpenRouter.sol - BungeeOpenRouterAH.sol - modular/ - BungeeOpenRouterModular.sol - BungeeOpenRouterModularAH.sol - minimal/ - BungeeOpenRouterMinimal.sol - BungeeOpenRouterMinimalAH.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 — BungeeOpenRouter (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/BungeeOpenRouter.sol`](src/monolithic/BungeeOpenRouter.sol). AllowanceHolder variant: [`src/monolithic/BungeeOpenRouterAH.sol`](src/monolithic/BungeeOpenRouterAH.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 (`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. +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 — BungeeOpenRouterModular (generic actions + returndata splicing) +--- -**File:** [`src/modular/BungeeOpenRouterModular.sol`](src/modular/BungeeOpenRouterModular.sol). AllowanceHolder variant: [`src/modular/BungeeOpenRouterModularAH.sol`](src/modular/BungeeOpenRouterModularAH.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 (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 } ``` -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. +One `uint256` per action. All fields are uint64-safe except `target` (uint160). -**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. +| 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) | -**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. +Packing (matches `packActionInfo` in [`scripts/e2e/utils/modularActionsBuilder/index.js`](scripts/e2e/utils/modularActionsBuilder/index.js)): -**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. +```text +callType | (storeResult ? 1 << 8 : 0) | (uint160(target) << 16) +``` ---- +`CALL_WITH_NATIVE`: first 32 bytes of `data` are `msg.value`; remaining bytes are calldata. -## v3 — BungeeOpenRouterMinimal (generic actions, no splicing) +### `splices[]` entry layout -**File:** [`src/minimal/BungeeOpenRouterMinimal.sol`](src/minimal/BungeeOpenRouterMinimal.sol). AllowanceHolder variant: [`src/minimal/BungeeOpenRouterMinimalAH.sol`](src/minimal/BungeeOpenRouterMinimalAH.sol). +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. -This version is the stripped-down sibling of v2. The `Action` struct has no `splices` field: +| 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) | -```solidity -struct Action { - CallType callType; - address target; - uint256 value; - bytes data; // used exactly as signed; never mutated -} +Packing (matches `packSpliceInfo` in the modular actions builder): + +```text +sourceActionIndex | (srcOffset << 64) | (dstOffset << 128) | (length << 192) ``` -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. +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: -``` -for each action: - dispatch the call (no splice step) - discard returndata -``` +- `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 -**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. +**Destination offset conventions** (builder helpers in `modularActionsBuilder/index.js`): -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. +| 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` | -**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. +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) +``` -**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`. +There is **no built-in pull** in `performActions`. Compose AllowanceHolder `transferFrom` (or other setup) as ordinary actions in the signed/off-chain-built sequence. --- -## Choosing between them +## Internal helpers (shared behavior) -The three versions exist on a spectrum from "the contract knows everything" to "the contract knows nothing except who signed". +- **`_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. -**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. +Approvals use Solady `safeApproveWithRetry` to `type(uint256).max` only when current allowance is below the needed amount. -**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. +--- + +## Choosing structured vs generic + +| 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`). | -**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. +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. --- -## Shared libraries +## Security model (summary) -All live under `src/common/`. +| 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 | -**`OpenRouterAuthBase.sol`** — abstract base all three inherit. Owns the signer address, the nonce mapping, and `_verifyAndConsume`. +`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. -**`lib/AuthenticationLib.sol`** — personal_sign recovery (`\x19Ethereum Signed Message:\n32` + ecrecover). Matches `marketplace/src/lib/AuthenticationLib.sol` exactly. +--- + +## Shared libraries (`src/common/`) -**`lib/CurrencyLib.sol`** — wraps Solady `SafeTransferLib` with a native token shortcut (address `0xEee...EEe`), identical in spirit to the marketplace `CurrencyLib`. +| 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/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). +`OpenRouterAuthBase` and signed-router variants are **not** used by this contract. -**`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`). diff --git a/OPENROUTER_ASSUMPTIONS.md b/OPENROUTER_ASSUMPTIONS.md new file mode 100644 index 0000000..98bff2a --- /dev/null +++ b/OPENROUTER_ASSUMPTIONS.md @@ -0,0 +1,248 @@ +# OpenRouter Assumptions + +Last reviewed: 2026-05-19. + +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 + +`OpenRouterV2Unchecked` intentionally removes backend signature verification, nonces, and deadlines. Public entrypoints can be called by anyone. + +Current checked-in public surface: + +- `swap(...)` +- `swapAndBridge(...)` +- `bridge(...)` +- `performActions()(...)` +- `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: `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: + +- 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 `performActions()` 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 + +`performActions()` 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 `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 new file mode 100644 index 0000000..2736eb4 --- /dev/null +++ b/OPENROUTER_CONTEXT.md @@ -0,0 +1,108 @@ +# OpenRouter Contract Context + +Last researched: 2026-05-18. + +Main ship target: + +- `src/combined/OpenRouterV2Unchecked.sol` + +Use `src/combined/OpenRouterV2.sol` as the signed sibling/reference, but the backend branch researched here targets the unchecked ABI. + +## V2Unchecked Surface + +`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: + +- `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`. +- `performActions()(bytes32 requestHash, Action[] actions)` + - Generic action loop with packed action metadata and packed splices. + +## Flags + +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`. +- `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 `OpenRouterV2Unchecked` needs an operational access-control decision; the contract itself has no signature or nonce checks. diff --git a/foundry.toml b/foundry.toml index 34b3732..94adb66 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,15 +2,20 @@ 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 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/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 feeaa09..b1a1749 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,10 @@ "private": true, "scripts": { "compile": "hardhat compile", - "deploy:v2": "hardhat run scripts/deploy/deployBungeeOpenRouterV2.ts --network", - "typechain": "hardhat typechain" + "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" }, "devDependencies": { "@arbitrum/sdk": "^4.0.5", 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/deployBungeeOpenRouterV2.ts b/scripts/deploy/deployBungeeOpenRouterV2.ts deleted file mode 100644 index 5d51a2e..0000000 --- a/scripts/deploy/deployBungeeOpenRouterV2.ts +++ /dev/null @@ -1,81 +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 { ethers } from 'hardhat'; - -async function main() { - const [deployer] = await ethers.getSigners(); - - 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: ', (await ethers.provider.getNetwork()).name); - 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 ${v2Address} "${owner}" "${openRouterSigner}"` - // ); - console.log( - ` npx hardhat verify --network ${v2uAddress} "${owner}"`, - ); - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/deploy/deployOpenRouter.ts b/scripts/deploy/deployOpenRouter.ts new file mode 100644 index 0000000..624ba30 --- /dev/null +++ b/scripts/deploy/deployOpenRouter.ts @@ -0,0 +1,85 @@ +/** + * 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); + console.log('Owner: ', owner); + console.log('Network: ', networkName); + console.log(''); + + 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 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 ==='); + console.log(`OpenRouter: ${routerAddress}`); + + const chainId = (await ethers.provider.getNetwork()).chainId; + if (chainId !== 31337n) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + + await hre.run('verify:verify', { + address: routerAddress, + constructorArguments: [owner], + }); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); 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 /' diff --git a/scripts/e2e/approveViaModular.ts b/scripts/e2e/approveViaModular.ts new file mode 100644 index 0000000..891a7e2 --- /dev/null +++ b/scripts/e2e/approveViaModular.ts @@ -0,0 +1,145 @@ +/** + * Script — Call ERC-20 approve(spender, amount) through the router using + * `performActions(Action[])`. + * + * 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. + * + * 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'; +import { ZERO_BYTES32 } from './utils/contractTypes'; + +// ─── 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 { + 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'); + + 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('performActions', [ + ZERO_BYTES32, + 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 performActions → 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/arbitrum/performExecution.postFee.ts b/scripts/e2e/arbitrum/performExecution.postFee.ts new file mode 100644 index 0000000..3e97d1e --- /dev/null +++ b/scripts/e2e/arbitrum/performExecution.postFee.ts @@ -0,0 +1,153 @@ +/** + * Route: Ethereum AAVE → ETH (OpenOcean) → Arbitrum ETH (inbox depositEth) + * Function: swapAndBridge + * Fee: postFee — FEE_BPS of estimatedOut ETH deducted after swap + * + * 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 + */ +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 { + BRIDGE_VALUE_FLAG, + POST_FEE_FLAG, + ZERO_ADDRESS, + ZERO_BYTES32, + swapAndBridgeArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +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); + +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 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)}`); + + await ensureRouterErc20Balance(signer, TOKENS.AAVE_ETH, ROUTER_ETH); + await ensureRouterNativeBalance(signer, ROUTER_ETH); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_ETH, + TOKENS.AAVE_ETH, + ooRouter, + inputAmount, + ); + + const callData = routerIface.encodeFunctionData( + 'swapAndBridge', + swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { user: signerAddress, inputToken: TOKENS.AAVE_ETH, inputAmount }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + 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) — 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); +}); diff --git a/scripts/e2e/arbitrum/performExecution.preFee.ts b/scripts/e2e/arbitrum/performExecution.preFee.ts new file mode 100644 index 0000000..bbcd01a --- /dev/null +++ b/scripts/e2e/arbitrum/performExecution.preFee.ts @@ -0,0 +1,118 @@ +/** + * Route: Ethereum ETH → Arbitrum ETH (Arbitrum inbox depositEth, no swap) + * Function: bridge + * Fee: preFee — FEE_BPS of inputAmount ETH deducted before bridge + * + * Input is native ETH — call router.bridge directly (msg.value = inputAmount). + * + * 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 { 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); + +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 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( + 'bridge', + bridgeArgs(ZERO_BYTES32, input, fee, bridgeData, buildDepositEthCalldata()), + ); + + console.log('Sending direct router tx → router.bridge...'); + const receipt = await execDirect(signer, ROUTER_ETH, callData, inputAmount); + + 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.'); + + void arbFee; +} + +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..069e29d --- /dev/null +++ b/scripts/e2e/arbitrum/performModularExecution.postFee.ts @@ -0,0 +1,165 @@ +/** + * Route: Ethereum AAVE → ETH (OpenOcean) → Arbitrum ETH (inbox depositEth) + * Function: performActions (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/performActions.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 { 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 } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; + +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); + + 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])); + 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); + + 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) — performActions 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/arbitrum/performModularExecution.preFee.ts b/scripts/e2e/arbitrum/performModularExecution.preFee.ts new file mode 100644 index 0000000..ab9c1d9 --- /dev/null +++ b/scripts/e2e/arbitrum/performModularExecution.preFee.ts @@ -0,0 +1,118 @@ +/** + * Route: Ethereum ETH → Arbitrum ETH (Arbitrum inbox depositEth, no swap) + * Function: performActions (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 — + * performActions has no _pullFromUser; ETH arrives via msg.value). + * + * Usage: + * PRIVATE_KEY=0x... ts-node scripts/e2e/arbitrum/performActions.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('performActions', [ZERO_BYTES32, exec.toActions()]); + + // Native ETH input — send directly to the router; no AllowanceHolder needed. + console.log('Sending direct router tx → router.performActions...'); + const receipt = await execDirect(signer, ROUTER_ETH, callData, inputAmount); + + 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.'); + + void arbFee; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/bridgeViaRelay.ts b/scripts/e2e/bridgeViaRelay.ts deleted file mode 100644 index 1eef43e..0000000 --- a/scripts/e2e/bridgeViaRelay.ts +++ /dev/null @@ -1,480 +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 { MonolithicExecution, NO_FEE, NO_SWAP } from './utils/contractTypes'; -import { fetchRelayQuoteV2, parseRelayQuote } from './utils/relayLinkQuote'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; - -/** 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, -): MonolithicExecution { - 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, - }, - }; -} - -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}`); - - let execCalldata: string; - if (useModular) { - const actions = buildModularActions( - signerAddress, - ROUTER_POLYGON, - inputAmount, - feeAmount, - bridgeAmount, - relaySpender, - depositTarget, - depositData, - ); - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - actions, - ]); - } else { - const execPayload = buildMonolithicExecution( - signerAddress, - inputAmount, - feeAmount, - relaySpender, - depositTarget, - depositData, - ); - execCalldata = routerIface.encodeFunctionData('performExecution', [ - 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, -): MonolithicExecution { - 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, - }, - }; -} - -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}`); - - let execCalldata: string; - if (useModular) { - const actions = buildModularActionsUsdcPolygonToBase( - signerAddress, - ROUTER_POLYGON, - inputAmount, - feeAmount, - bridgeAmount, - relaySpender, - depositTarget, - depositData, - ); - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - actions, - ]); - } else { - const execPayload = buildMonolithicExecutionUsdcPolygonToBase( - signerAddress, - inputAmount, - feeAmount, - relaySpender, - depositTarget, - depositData, - ); - execCalldata = routerIface.encodeFunctionData('performExecution', [ - 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 / 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 / 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/cctp/bridge.preFee.ts b/scripts/e2e/cctp/bridge.preFee.ts new file mode 100644 index 0000000..bbac9a1 --- /dev/null +++ b/scripts/e2e/cctp/bridge.preFee.ts @@ -0,0 +1,126 @@ +/** + * 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 / performActions. + * + * 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 } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +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 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: 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 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/cctp/performExecution.postFee.ts b/scripts/e2e/cctp/performExecution.postFee.ts new file mode 100644 index 0000000..36d0a50 --- /dev/null +++ b/scripts/e2e/cctp/performExecution.postFee.ts @@ -0,0 +1,173 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) + * Function: swapAndBridge + * 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 { + POST_FEE_FLAG, + ZERO_BYTES32, + bridgeAmountPositionFlag, + swapAndBridgeArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +const FLAGS = POST_FEE_FLAG | 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(`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); + + 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', + swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { user: signerAddress, inputToken: TOKENS.AAVE_POLYGON, inputAmount }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { target: polyCctp.tokenMessenger, approvalSpender: bridgeApprovalSpender, 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', 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..cc53631 --- /dev/null +++ b/scripts/e2e/cctp/performExecution.preFee.ts @@ -0,0 +1,122 @@ +/** + * Route: Polygon USDC → Base USDC (CCTP depositForBurn, no swap) + * Function: bridge + * 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 { + ZERO_BYTES32, + bridgeArgs, + type BridgeData, + type FeeData, + type InputData, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +import { ensureRouterErc20Balance } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +const ROUTER_POLYGON = routerAddressForChain(CHAIN_IDS.POLYGON); + +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', [ + amount, + 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); + 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(bridgeAmount, 6)}`); + + const polyCctp = CCTP_CONFIG[CHAIN_IDS.POLYGON]; + const baseCctp = CCTP_CONFIG[CHAIN_IDS.BASE]; + const depositForBurnData = buildDepositForBurnCalldata( + signerAddress, + polyCctp.usdcAddress, + baseCctp.cctpDomain, + bridgeAmount, + ); + + 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: bridgeApprovalSpender, + value: 0n, + }; + + await ensureRouterErc20Balance(signer, TOKENS.USDC_POLYGON_CIRCLE, ROUTER_POLYGON); + + 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) — 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/cctp/performModularExecution.postFee.ts b/scripts/e2e/cctp/performModularExecution.postFee.ts new file mode 100644 index 0000000..c040a37 --- /dev/null +++ b/scripts/e2e/cctp/performModularExecution.postFee.ts @@ -0,0 +1,165 @@ +/** + * Route: Polygon AAVE → USDC (OpenOcean) → Base USDC (CCTP depositForBurn) + * Function: performActions (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/performActions.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 { 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 } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; + +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); + + 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])); + 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)); + 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()); + + 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) — performActions 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..163391f --- /dev/null +++ b/scripts/e2e/cctp/performModularExecution.preFee.ts @@ -0,0 +1,123 @@ +/** + * Route: Polygon USDC → Base USDC (CCTP depositForBurn, no swap) + * Function: performActions (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/performActions.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 { 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 } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; + +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); + 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)}`); + + 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); + + 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)); + 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()); + + 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) — performActions 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..243e0a7 --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.postFee.balanceOf.ts @@ -0,0 +1,222 @@ +/** + * 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, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} 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); +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 + ); + + 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", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, + 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.kyberswap.ts b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts new file mode 100644 index 0000000..0d9c5b0 --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.kyberswap.ts @@ -0,0 +1,276 @@ +/** + * 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, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} 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); +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 + ); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter, + inputAmount, + ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger, + estimatedOut - feeAmount, + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, + 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/cctp/swapAndBridge.postFee.returndata.ts b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts new file mode 100644 index 0000000..415e714 --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.postFee.returndata.ts @@ -0,0 +1,222 @@ +/** + * 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, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} 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); +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 + ); + + 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", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, + 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..1e2449a --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.preFee.balanceOf.ts @@ -0,0 +1,224 @@ +/** + * 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, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} 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); +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 + ); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + swapInputAmount, + ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger, + minAmountOut, + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, + 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..72050de --- /dev/null +++ b/scripts/e2e/cctp/swapAndBridge.preFee.returndata.ts @@ -0,0 +1,224 @@ +/** + * 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, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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 + ); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + swapInputAmount, + ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDC_POLYGON_CIRCLE, + polyCctp.tokenMessenger, + minAmountOut, + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: polyCctp.tokenMessenger, + approvalSpender: bridgeApprovalSpender, + 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 0c39f70..5aca8d4 100644 --- a/scripts/e2e/config.ts +++ b/scripts/e2e/config.ts @@ -29,14 +29,14 @@ 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. */ export const ROUTER_BY_CHAIN_ID: Record = { - [CHAIN_IDS.POLYGON]: '0x23D5aFEF7cE44257366D9ef6de80Ea334FAa9d25', + [CHAIN_IDS.POLYGON]: '0x33654252CEA9c95220Aa1d434a3631d5c0843AA4', [CHAIN_IDS.ARBITRUM]: '0xe1788A0374EF5D4C35e62478FdB35F37CeE5B951', - [CHAIN_IDS.BASE]: '0x96E8c261fCCDFca2CCffe8b4A33dC8a65f153785', + [CHAIN_IDS.BASE]: '0x91b536E79cd3607b593f3011937862609D608253', [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; } @@ -225,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/misc/routerUsdc.withdraw.modular.ts b/scripts/e2e/misc/routerUsdc.withdraw.modular.ts new file mode 100644 index 0000000..6ea546f --- /dev/null +++ b/scripts/e2e/misc/routerUsdc.withdraw.modular.ts @@ -0,0 +1,103 @@ +/** + * Polygon: sweep USDC from `OpenRouter` to the tx sender using + * `performActions` 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/misc/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('performActions', [ + ZERO_BYTES32, + exec.toActions(), + ]); + + console.log( + 'Sending performActions (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 performActions', + 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/oft/bridge.preFee.ts b/scripts/e2e/oft/bridge.preFee.ts new file mode 100644 index 0000000..756ed5f --- /dev/null +++ b/scripts/e2e/oft/bridge.preFee.ts @@ -0,0 +1,168 @@ +/** + * 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 / performActions. + * + * 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 } 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(); + +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 bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, + ); + + const bridgeData: BridgeData = { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, + 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 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/oft/performExecution.postFee.ts b/scripts/e2e/oft/performExecution.postFee.ts new file mode 100644 index 0000000..b8e668f --- /dev/null +++ b/scripts/e2e/oft/performExecution.postFee.ts @@ -0,0 +1,265 @@ +/** + * 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/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 { ZERO_BYTES32, bridgeAmountPositionFlag, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount, + ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, + ); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, + 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/performExecution.preFee.ts b/scripts/e2e/oft/performExecution.preFee.ts new file mode 100644 index 0000000..cc16455 --- /dev/null +++ b/scripts/e2e/oft/performExecution.preFee.ts @@ -0,0 +1,168 @@ +/** + * 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 / performActions. + * + * 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 { ZERO_BYTES32, type BridgeData, type FeeData, type InputData } from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +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(); + +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 bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, + ); + + const bridgeData: BridgeData = { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, + 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 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/oft/performModularExecution.postFee.ts b/scripts/e2e/oft/performModularExecution.postFee.ts new file mode 100644 index 0000000..be9538b --- /dev/null +++ b/scripts/e2e/oft/performModularExecution.postFee.ts @@ -0,0 +1,185 @@ +/** + * Route: Polygon AAVE → USDT0 (OpenOcean) → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2) + * Function: performActions (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/performActions.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 { 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 } 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(); +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); + + 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])); + await modularApproveIfNeeded(exec, provider, ROUTER_POLYGON, TOKENS.AAVE_POLYGON, ooRouter, inputAmount, inputAmount); + exec.call(ooRouter, swapData); + exec.call(TOKENS.USDT0_POLYGON, encodeTransfer(signerAddress, feeAmount)); + 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()); + + 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) — performActions 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..297226e --- /dev/null +++ b/scripts/e2e/oft/performModularExecution.preFee.ts @@ -0,0 +1,142 @@ +/** + * Route: Polygon USDT0 → Arbitrum USDT0 (USDT0 OFT Adapter, LayerZero v2, no swap) + * Function: performActions (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/performActions.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 { 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 } 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(); +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); + + 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)); + 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()); + + 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) — performActions 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..96f4944 --- /dev/null +++ b/scripts/e2e/oft/swapAndBridge.postFee.balanceOf.ts @@ -0,0 +1,265 @@ +/** + * 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, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} 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); +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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount, + ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, + ); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, + 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..4980ec7 --- /dev/null +++ b/scripts/e2e/oft/swapAndBridge.postFee.returndata.ts @@ -0,0 +1,265 @@ +/** + * 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, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount, + ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON, + bridgeAmount, + ); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, + 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..8415223 --- /dev/null +++ b/scripts/e2e/oft/swapAndBridge.preFee.balanceOf.ts @@ -0,0 +1,264 @@ +/** + * 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, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} 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); +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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount - feeAmount, + ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON, + minAmountOut, + ); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, + 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..5e5dcb7 --- /dev/null +++ b/scripts/e2e/oft/swapAndBridge.preFee.returndata.ts @@ -0,0 +1,264 @@ +/** + * 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, swapAndBridgeArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount - feeAmount, + ); + const bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.USDT0_POLYGON, + USDT0_OFT_ADAPTER_POLYGON, + minAmountOut, + ); + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDT0_POLYGON, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { + target: USDT0_OFT_ADAPTER_POLYGON, + approvalSpender: bridgeApprovalSpender, + 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/relay/aave.bridge.preFee.ts b/scripts/e2e/relay/aave.bridge.preFee.ts new file mode 100644 index 0000000..73f4955 --- /dev/null +++ b/scripts/e2e/relay/aave.bridge.preFee.ts @@ -0,0 +1,105 @@ +/** + * 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 / performActions. + * + * 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 } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +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); + + 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: bridgeApprovalSpender, 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..70098a0 --- /dev/null +++ b/scripts/e2e/relay/aave.performExecution.preFee.ts @@ -0,0 +1,102 @@ +/** + * Route: Polygon AAVE → Base AAVE via Relay.link (no swap) + * Function: bridge + * Fee: preFee — FEE_BPS of inputAmount AAVE deducted before bridge + * + * 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 { 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 } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +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); + + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + 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)); + + 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.performModularExecution.preFee.ts b/scripts/e2e/relay/aave.performModularExecution.preFee.ts new file mode 100644 index 0000000..dd9207e --- /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: performActions (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.performActions.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 { 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 } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; + +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); + + 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)); + await modularApproveIfNeeded(exec, provider, ROUTER_POLYGON, inputToken, relaySpender, bridgeAmount, bridgeAmount); + exec.call(depositTarget, depositData); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + 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 — performActions 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..2fc5cbb --- /dev/null +++ b/scripts/e2e/relay/usdc.bridge.preFee.ts @@ -0,0 +1,105 @@ +/** + * 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 / performActions. + * + * 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 } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +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); + + 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: bridgeApprovalSpender, 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..e5ed43d --- /dev/null +++ b/scripts/e2e/relay/usdc.performExecution.preFee.ts @@ -0,0 +1,102 @@ +/** + * Route: Polygon USDC → Base USDC via Relay.link (no swap) + * Function: bridge + * Fee: preFee — FEE_BPS of inputAmount USDC deducted before bridge + * + * 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 { 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 } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +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); + + const input: InputData = { user: signerAddress, inputToken, inputAmount }; + const fee: FeeData = { receiver: signerAddress, amount: feeAmount }; + 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)); + + 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.performModularExecution.preFee.ts b/scripts/e2e/relay/usdc.performModularExecution.preFee.ts new file mode 100644 index 0000000..9dc70e5 --- /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: performActions (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.performActions.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 { 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 } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; + +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); + + 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)); + await modularApproveIfNeeded(exec, provider, ROUTER_POLYGON, inputToken, relaySpender, bridgeAmount, bridgeAmount); + exec.call(depositTarget, depositData); + + const routerIface = new ethers.Interface(ROUTER_ABI); + const execCalldata = routerIface.encodeFunctionData('performActions', [ZERO_BYTES32, exec.toActions()]); + + await ensureAllowanceForAllowanceHolder(signer, inputToken, inputAmount); + + 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 — performActions preFee', CHAIN_IDS.POLYGON, receipt); +} + +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..81e4f8e --- /dev/null +++ b/scripts/e2e/stargate/arbUsdcBaseEth.performExecution.postFee.ts @@ -0,0 +1,196 @@ +/** + * Route: Arbitrum USDC → ETH (OpenOcean) → Base ETH (Stargate Native ETH Pool) + * 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. + * 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 + */ +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 { + BRIDGE_VALUE_FLAG, + POST_FEE_FLAG, + ZERO_ADDRESS, + ZERO_BYTES32, + bridgeAmountPositionFlag, + swapAndBridgeArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +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); + +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, 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(`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`); + + // 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_ARB, + TOKENS.USDC_ARB, + ooRouter, + inputAmount, + ); + + const stargateData = buildStargateCalldata(nativeFeeWithBuffer, signerAddress, amountLD); + + const callData = routerIface.encodeFunctionData( + 'swapAndBridge', + swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { user: signerAddress, inputToken: TOKENS.USDC_ARB, inputAmount }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: NATIVE_TOKEN_ADDRESS, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + 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); + + 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.'); +} + +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..aa0b93c --- /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: performActions (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.performActions.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 { 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 } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; + +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); + + 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])); + 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); + + 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) — performActions 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..05d0c3f --- /dev/null +++ b/scripts/e2e/stargate/baseUsdcArbEth.performExecution.postFee.ts @@ -0,0 +1,270 @@ +/** + * 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 + * + * 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"; +dotenv.config(); + +import { + CHAIN_IDS, + routerAddressForChain, + TOKENS, + NATIVE_TOKEN_ADDRESS, + FEE_BPS, + bpsOf, + RPC, + OPEN_OCEAN_API_KEY, + OO_SLIPPAGE_PERCENT, + 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 { + ZERO_BYTES32, + BRIDGE_VALUE_FLAG, + ZERO_ADDRESS, + bridgeAmountPositionFlag, + swapAndBridgeArgs, +} from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, +} 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); +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<{ 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), + ]); + 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: 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( + `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 (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`); + + // 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + inputAmount, + ); + + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.USDC_BASE, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + 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 + ); + + logTxnSummary( + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge postFee/returndata`, + 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..7521bcb --- /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: performActions (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.performActions.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 { 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 } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; + +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); + + 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])); + 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); + + 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) — performActions 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..87ca5eb --- /dev/null +++ b/scripts/e2e/stargate/polygonPolUsdt0Arb.performExecution.postFee.ts @@ -0,0 +1,236 @@ +/** + * Route: Polygon POL (native) → USDT0 (OpenOcean) → Arbitrum USDT0 (LZ OFT Adapter) + * Function: swapAndBridge + * 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 { + POST_FEE_FLAG, + ZERO_ADDRESS, + ZERO_BYTES32, + bridgeAmountPositionFlag, + swapAndBridgeArgs, +} from '../utils/contractTypes'; +import { logTxnSummary } from '../utils/txnLogSummary'; +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); +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); + + 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; + + const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); + + 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, + value: polOrEthToOo, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + { target: USDT0_OFT_ADAPTER_POLYGON, approvalSpender: bridgeApprovalSpender, 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); + + 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..d46436b --- /dev/null +++ b/scripts/e2e/stargate/polygonPolUsdt0Arb.performModularExecution.postFee.ts @@ -0,0 +1,218 @@ +/** + * Route: Polygon POL (native) → USDT0 (OpenOcean) → Arbitrum USDT0 (LZ OFT Adapter) + * Function: performActions (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.performActions.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 { 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 } 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(); +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); + + 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)); + 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()); + + const txValue = inputAmountWei + nativeFeeWithBuffer; + 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) — performActions 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.bridge.preFee.ts b/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts new file mode 100644 index 0000000..c2f6c2b --- /dev/null +++ b/scripts/e2e/stargate/polygonUsdcBase.bridge.preFee.ts @@ -0,0 +1,166 @@ +/** + * 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 / performActions. + * + * 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 } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +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 bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + STARGATE_USDC_POLYGON, + bridgeAmount, + ); + + const bridgeData: BridgeData = { + target: STARGATE_USDC_POLYGON, + approvalSpender: bridgeApprovalSpender, + 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 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); +}); diff --git a/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts new file mode 100644 index 0000000..0009435 --- /dev/null +++ b/scripts/e2e/stargate/polygonUsdcBase.performExecution.postFee.ts @@ -0,0 +1,166 @@ +/** + * 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 / performActions. + * + * 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, +} 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 } from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +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 bridgeApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + inputToken, + STARGATE_USDC_POLYGON, + bridgeAmount, + ); + + const bridgeData: BridgeData = { + target: STARGATE_USDC_POLYGON, + approvalSpender: bridgeApprovalSpender, + 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 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); +}); diff --git a/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts new file mode 100644 index 0000000..ebb355a --- /dev/null +++ b/scripts/e2e/stargate/polygonUsdcBase.performModularExecution.postFee.ts @@ -0,0 +1,141 @@ +/** + * Route: Polygon USDC → Base USDC (Stargate USDC Pool, no swap) + * 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). + * + * 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.performActions.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 { 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 } from '../utils/reproducibility'; +import { modularApproveIfNeeded } from '../utils/routerAllowance'; + +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); + + 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)); + 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()); + + 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) — performActions 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..8be7ccb --- /dev/null +++ b/scripts/e2e/stargate/swapAndBridge.postFee.balanceOf.ts @@ -0,0 +1,269 @@ +/** + * 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 + * + * 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 + 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 + */ +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_BASE, + ARBITRUM_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, + bridgeAmountPositionFlag, + swapAndBridgeArgs, +} from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, +} 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); +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<{ 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), + ]); + 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: 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( + `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 (Base native pool → Arbitrum)..."); + 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`); + + // 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + inputAmount, + ); + + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.USDC_BASE, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + 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 + ); + + logTxnSummary( + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge postFee/balanceOf`, + 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/swapAndBridge.postFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts new file mode 100644 index 0000000..ba1c483 --- /dev/null +++ b/scripts/e2e/stargate/swapAndBridge.postFee.returndata.ts @@ -0,0 +1,270 @@ +/** + * 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 + * + * 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/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_BASE, + ARBITRUM_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, + bridgeAmountPositionFlag, + swapAndBridgeArgs, +} from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, +} 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); +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<{ 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), + ]); + 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: 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( + `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 (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`); + + // 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + inputAmount, + ); + + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.USDC_BASE, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + 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 + ); + + logTxnSummary( + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge postFee/returndata`, + 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/swapAndBridge.preFee.balanceOf.ts b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts new file mode 100644 index 0000000..462ed18 --- /dev/null +++ b/scripts/e2e/stargate/swapAndBridge.preFee.balanceOf.ts @@ -0,0 +1,269 @@ +/** + * 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 + * + * 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 + 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 + */ +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_BASE, + ARBITRUM_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, + bridgeAmountPositionFlag, + swapAndBridgeArgs, +} from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, +} 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); +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<{ 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), + ]); + 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: 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( + `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 (Base native pool → Arbitrum)..."); + 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`); + + // estimatedOut is a placeholder for amountLD; router splices the actual finalETH at runtime + const amountLD = estimatedOut; + + await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); + await ensureRouterNativeBalance(signer, ROUTER_BASE); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + swapInput, + ); + + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.USDC_BASE, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + 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 + ); + + logTxnSummary( + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge preFee/balanceOf`, + 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/swapAndBridge.preFee.returndata.ts b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts new file mode 100644 index 0000000..971437f --- /dev/null +++ b/scripts/e2e/stargate/swapAndBridge.preFee.returndata.ts @@ -0,0 +1,269 @@ +/** + * 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 + * + * 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 + 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 + */ +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_BASE, + ARBITRUM_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, + bridgeAmountPositionFlag, + swapAndBridgeArgs, +} from "../utils/contractTypes"; +import { STARGATE_AMOUNT_LD_OFFSET } from "../config"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, + ensureRouterNativeBalance, +} 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); +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<{ 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), + ]); + 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: 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( + `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 (Base native pool → Arbitrum)..."); + 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`); + + // estimatedOut is a placeholder for amountLD; router splices the actual finalETH at runtime + const amountLD = estimatedOut; + + await ensureRouterErc20Balance(signer, TOKENS.USDC_BASE, ROUTER_BASE); + await ensureRouterNativeBalance(signer, ROUTER_BASE); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_BASE, + TOKENS.USDC_BASE, + ooRouter, + swapInput, + ); + + const stargateData = buildStargateCalldata( + nativeFeeWithBuffer, + signerAddress, + amountLD + ); + + const callData = routerIface.encodeFunctionData("swapAndBridge", swapAndBridgeArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.USDC_BASE, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + 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 + ); + + logTxnSummary( + `Base USDC → Arbitrum ETH (Stargate) — swapAndBridge preFee/returndata`, + 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/swap/kyberswap.postFee.balanceOf.ts b/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts new file mode 100644 index 0000000..e1a0380 --- /dev/null +++ b/scripts/e2e/swap/kyberswap.postFee.balanceOf.ts @@ -0,0 +1,234 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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, + ); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter, + inputAmount, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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..5deafac --- /dev/null +++ b/scripts/e2e/swap/kyberswap.postFee.returndata.ts @@ -0,0 +1,229 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter, + inputAmount, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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..fbd0787 --- /dev/null +++ b/scripts/e2e/swap/kyberswap.preFee.balanceOf.ts @@ -0,0 +1,240 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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, + ); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter, + swapInput, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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..c9b84e4 --- /dev/null +++ b/scripts/e2e/swap/kyberswap.preFee.returndata.ts @@ -0,0 +1,236 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ksRouter, + swapInput, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ksRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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 new file mode 100644 index 0000000..70a71af --- /dev/null +++ b/scripts/e2e/swap/swap.postFee.balanceOf.ts @@ -0,0 +1,177 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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 + ); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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..7fde5db --- /dev/null +++ b/scripts/e2e/swap/swap.postFee.returndata.ts @@ -0,0 +1,172 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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..d557b50 --- /dev/null +++ b/scripts/e2e/swap/swap.preFee.balanceOf.ts @@ -0,0 +1,177 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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 + ); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount - feeAmount, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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..094a548 --- /dev/null +++ b/scripts/e2e/swap/swap.preFee.returndata.ts @@ -0,0 +1,172 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + ooRouter, + inputAmount - feeAmount, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: ooRouter, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value: 0n, + minOutput: minAmountOut, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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/swap/zerox.postFee.balanceOf.ts b/scripts/e2e/swap/zerox.postFee.balanceOf.ts new file mode 100644 index 0000000..7137d81 --- /dev/null +++ b/scripts/e2e/swap/zerox.postFee.balanceOf.ts @@ -0,0 +1,230 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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, + ); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + approvalSpender, + inputAmount, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: swapTarget, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minBuyAmount, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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..8ec6e73 --- /dev/null +++ b/scripts/e2e/swap/zerox.postFee.returndata.ts @@ -0,0 +1,219 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + approvalSpender, + inputAmount, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: swapTarget, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minBuyAmount, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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..d3ec6f8 --- /dev/null +++ b/scripts/e2e/swap/zerox.preFee.balanceOf.ts @@ -0,0 +1,236 @@ +/** + * 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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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, + ); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + approvalSpender, + swapInput, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: swapTarget, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minBuyAmount, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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..b955005 --- /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, swapArgs } from "../utils/contractTypes"; +import { logTxnSummary } from "../utils/txnLogSummary"; +import { + ensureRouterErc20Balance, +} from '../utils/reproducibility'; +import { resolveApprovalSpender } from '../utils/routerAllowance'; + +// 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); + + const swapApprovalSpender = await resolveApprovalSpender( + provider, + ROUTER_POLYGON, + TOKENS.AAVE_POLYGON, + approvalSpender, + swapInput, + ); + + const callData = routerIface.encodeFunctionData("swap", swapArgs( + ZERO_BYTES32, + FLAGS, + { + user: signerAddress, + inputToken: TOKENS.AAVE_POLYGON, + inputAmount: inputAmount, + }, + { receiver: signerAddress, amount: feeAmount }, + { + target: swapTarget, + approvalSpender: swapApprovalSpender, + outputToken: TOKENS.USDC_POLYGON_CIRCLE, + value, + minOutput: minBuyAmount, + returnDataWordOffset: 0n, + }, + swapData, + signerAddress, + )); + + 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/swapBridgeViaArbitrumNative.ts b/scripts/e2e/swapBridgeViaArbitrumNative.ts deleted file mode 100644 index 767d0c0..0000000 --- a/scripts/e2e/swapBridgeViaArbitrumNative.ts +++ /dev/null @@ -1,418 +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. - * - useFinalAmountAsValue=true: 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, - 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 { MonolithicExecution, NO_FEE, ZERO_ADDRESS } from './utils/contractTypes'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; - -// ─── 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, - slippageBps: number = 100, -): 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: (slippageBps / 100).toString(), - 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, useFinalAmountAsValue=true 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, -): MonolithicExecution { - 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, - }, - }; -} - -// ─── 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`); - - 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', [actions]); - } else { - const mono = buildMonolithic(signerAddress, inputAmount, feeAmount, minAmountOut, ooRouter, swapData); - execCalldata = routerIface.encodeFunctionData('performExecution', [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 / 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 3ff723e..0000000 --- a/scripts/e2e/swapBridgeViaCctp.ts +++ /dev/null @@ -1,536 +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, - 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 { - MonolithicExecution, - NO_FEE, - NO_SWAP, -} from './utils/contractTypes'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; - -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, - slippageBps: number = 100, -): 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: (slippageBps / 100).toString(), - 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, -): MonolithicExecution { - 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, - }, - }; -} - -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, -): MonolithicExecution { - 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, - }, - }; -} - -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, - ); - - let execCalldata: string; - if (useModular) { - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - buildModularActionsUsdcPolygonToBaseCctp( - signerAddress, - ROUTER_POLYGON, - inputAmount, - feeAmount, - depositForBurnData, - polyCctp.tokenMessenger, - ), - ]); - } else { - execCalldata = routerIface.encodeFunctionData('performExecution', [ - 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, - ); - - let execCalldata: string; - if (useModular) { - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - buildModularActions( - signerAddress, - ROUTER_POLYGON, - inputAmount, - feeAmount, - ooRouterAddress, - swapData, - depositForBurnData, - polyCctp.tokenMessenger, - ), - ]); - } else { - execCalldata = routerIface.encodeFunctionData('performExecution', [ - 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 / 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 / 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/swapBridgeViaOft.ts b/scripts/e2e/swapBridgeViaOft.ts deleted file mode 100644 index f9fe823..0000000 --- a/scripts/e2e/swapBridgeViaOft.ts +++ /dev/null @@ -1,785 +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, - 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 { MonolithicExecution, NO_FEE, NO_SWAP } from './utils/contractTypes'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; - -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, - slippageBps: number = 100, -): 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: (slippageBps / 100).toString(), - 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) - * - useFinalAmountAsValue=false; amountPositions=[196n] splices actual balance into amountLD - * - 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, -): MonolithicExecution { - 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, - }, - }; -} - -/** - * 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`, - ); - - const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - - let execCalldata: string; - if (useModular) { - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - buildCase1Modular( - signerAddress, - inputAmount, - feeAmount, - ooRouter, - swapData, - oftSendData, - nativeFeeWithBuffer, - ), - ]); - } else { - execCalldata = routerIface.encodeFunctionData('performExecution', [ - 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) - * - useFinalAmountAsValue=false; amountPositions=[196n] splices actual balance - * - bridge.value = nativeFeeWithBuffer (forwarded as LZ msg.value) - */ -function buildCase2Monolithic( - signer: string, - inputAmount: bigint, - feeAmount: bigint, - oftSendData: string, - nativeFeeWithBuffer: bigint, -): MonolithicExecution { - 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, - }, - }; -} - -/** - * 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`, - ); - - const oftSendData = buildOftSendCalldata(nativeFeeWithBuffer, signerAddress); - - let execCalldata: string; - if (useModular) { - execCalldata = routerIface.encodeFunctionData('performModularExecution', [ - buildCase2Modular( - signerAddress, - inputAmount, - feeAmount, - oftSendData, - nativeFeeWithBuffer, - ), - ]); - } else { - execCalldata = routerIface.encodeFunctionData('performExecution', [ - 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 / 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 / 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 ad9cc13..0000000 --- a/scripts/e2e/swapBridgeViaStargateNative.ts +++ /dev/null @@ -1,1060 +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: useFinalAmountAsValue=true (router forwards actualFinalAmount as msg.value). - * amountLD = minAmountOut - fee - nativeFeeWithBuffer; positions=[]. - * 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. - * 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, - 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 { MonolithicExecution, NO_FEE, NO_SWAP, ZERO_ADDRESS } from './utils/contractTypes'; -import { sleep } from './utils/sleep'; -import { logTxnSummary } from './utils/txnLogSummary'; - -/** - * 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, - slippageBps: number = 100, -): 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: (slippageBps / 100).toString(), - 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 - * - useFinalAmountAsValue=true: router forwards actualFinalETH as msg.value to Stargate - * - amountLD = minAmountOut - fee - nativeFeeWithBuffer; pre-encoded; no splice needed (positions=[]) - * - StargatePoolNative checks msg.value >= amountLD + nativeFee; satisfied since actual >= min - */ -function buildNativePoolMonolithic( - signer: string, - cfg: CaseConfig, - inputAmount: bigint, - feeAmount: bigint, - minAmountOut: bigint, - ooRouter: string, - swapData: string, - stargateData: string, -): MonolithicExecution { - 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 - }, - }; -} - -/** - * 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 - * - 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, -): MonolithicExecution { - 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, - }, - }; -} - -// ─── 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, -): MonolithicExecution { - 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, - }, - }; -} - -/** - * 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; - } - - 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; - } - - 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', [actions]); - } else { - let mono: MonolithicExecution; - if (cfg.isNativePool) { - mono = buildNativePoolMonolithic( - signerAddress, cfg, inputAmountWei, feeAmount, minAmountOut, - ooRouter, swapData, stargateData, - ); - } 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', [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; - 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 = walletBalance / 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 aeaf23e..9bf07c2 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 OpenRouter Solidity structs. + * Field names and order must match the compiler ABI encoding. */ -// ─── Monolithic execution types ─────────────────────────────────────────────── - export interface InputData { user: string; inputToken: string; @@ -23,7 +20,6 @@ export interface SwapData { outputToken: string; value: bigint; minOutput: bigint; - data: string; returnDataWordOffset: bigint; } @@ -31,33 +27,79 @@ export interface BridgeData { target: string; approvalSpender: string; value: bigint; - data: string; - amountPositions: bigint[]; - useFinalAmountAsValue: boolean; } -export interface MonolithicExecution { - input: InputData; - preFee: FeeData; - swap: SwapData; - postFee: FeeData; - bridge: BridgeData; -} +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; -// ─── Sentinel / zero helpers ────────────────────────────────────────────────── +/** 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 }; -/** Convenience: empty swap (skip swap step) */ -export const NO_SWAP: SwapData = { - target: ZERO_ADDRESS, - approvalSpender: ZERO_ADDRESS, - outputToken: ZERO_ADDRESS, - value: 0n, - minOutput: 0n, - data: '0x', - returnDataWordOffset: 0n, -}; +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 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; +} + +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; +} + +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; +} + +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/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/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 new file mode 100644 index 0000000..62e49da --- /dev/null +++ b/scripts/e2e/utils/reproducibility.ts @@ -0,0 +1,66 @@ +/** + * 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. + * + * 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 + * consistent gas readings across repeated runs. + */ +import { ethers } from 'ethers'; +import { getErc20Contract } from './erc20'; + +const SEED_WEI = 20n; + +/** + * 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(); +} diff --git a/scripts/e2e/utils/routerAbi.ts b/scripts/e2e/utils/routerAbi.ts index a64499f..7e2e702 100644 --- a/scripts/e2e/utils/routerAbi.ts +++ b/scripts/e2e/utils/routerAbi.ts @@ -1,21 +1,39 @@ /** - * 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 OpenRouter entrypoints used by e2e scripts. + * Struct field order must match the Solidity definitions. */ export const ROUTER_ABI = [ - // Monolithic path - `function performExecution( - ( - (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 receiver, uint256 amount) postFee, - (address target, address approvalSpender, uint256 value, bytes data, uint256[] amountPositions, bool useFinalAmountAsValue) bridge - ) exec + `function performActions( + bytes32 quoteId, + (uint256 actionInfo, bytes data, uint256[] splices)[] actions ) external payable`, - // Modular path - `function performModularExecution( - (uint256 actionInfo, bytes data, uint256[] splices)[] actions + `function swap( + 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, + address receiver + ) external payable returns (uint256)`, + + `function swapAndBridge( + 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, + (address target, address approvalSpender, uint256 value) bridgeData, + bytes bridgeCallData + ) external payable`, + + `function bridge( + bytes32 quoteId, + (address user, address inputToken, uint256 inputAmount) input, + (address receiver, uint256 amount) fee, + (address target, address approvalSpender, uint256 value) bridgeData, + bytes bridgeCallData ) external payable`, ] as const; diff --git a/scripts/e2e/utils/routerAllowance.ts b/scripts/e2e/utils/routerAllowance.ts new file mode 100644 index 0000000..15c382b --- /dev/null +++ b/scripts/e2e/utils/routerAllowance.ts @@ -0,0 +1,110 @@ +/** + * Router ERC-20 allowance helpers for e2e scripts. + * + * `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. + */ +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; +} diff --git a/src/OpenRouter.sol b/src/OpenRouter.sol new file mode 100644 index 0000000..d9aa58d --- /dev/null +++ b/src/OpenRouter.sol @@ -0,0 +1,771 @@ +// SPDX-License-Identifier: GPL-3.0-only +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"; +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 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 OpenRouter is AccessControl, AllowanceHolderContext { + using SafeTransferLib for address; + + // ========================================================================= + // Structs + // ========================================================================= + + struct InputData { + address user; + address inputToken; + uint256 inputAmount; + } + + struct FeeData { + address receiver; + uint256 amount; + } + + struct SwapData { + address target; + address approvalSpender; + address outputToken; + uint256 value; + uint256 minOutput; + uint256 returnDataWordOffset; + } + + struct BridgeData { + address target; + address approvalSpender; + uint256 value; + } + + enum CallType { + CALL, + STATICCALL, + CALL_WITH_NATIVE + } + + 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; + } + + // ========================================================================= + // Flags (swap / swapAndBridge) + // ========================================================================= + // + // 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 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: bridge.value alone vs finalAmount + bridge.value + // 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 flags: + // + // 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 + // + // 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 + // 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 + // 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. + // 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. + 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; + + /// @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. + 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 + // ========================================================================= + + error SwapOutputInsufficient(); + 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(); + + // ========================================================================= + // Events + // ========================================================================= + + event RequestExecuted(bytes32 indexed quoteId); + + // ========================================================================= + // Constructor + // ========================================================================= + + /** + * @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 functions + // ========================================================================= + + /** + * @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. + * @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`. + * @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, + uint256 flags, + InputData calldata input, + FeeData calldata fee, + SwapData calldata swapData, + bytes calldata swapCallData, + address receiver + ) external payable returns (uint256 finalAmount) { + if ( + input.user == address(0) || input.inputToken == address(0) || swapData.target == address(0) + || receiver == address(0) + ) { + revert InvalidExecution(); + } + + // 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); + + // Collect pre-swap fee + uint256 swapInput = input.inputAmount; + if (fee.amount != 0 && !postFee) { + uint256 feeAmount = fee.amount; + CurrencyLib.transfer(input.inputToken, fee.receiver, feeAmount); + unchecked { + swapInput -= feeAmount; + } + } + + // 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); + } + } + + /// @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; + + // 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 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 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. + * @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 quoteId, + uint256 flags, + InputData calldata input, + FeeData calldata fee, + SwapData calldata swapData, + 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) + ) { + revert InvalidExecution(); + } + + // Execute swap before bridge + uint256 finalAmount = _swapBeforeBridge(flags, input, fee, swapData, swapCallData); + + // Execute bridge + _execBridge(swapData.outputToken, finalAmount, flags, bridgeData, bridgeCallData); + + emit RequestExecuted(quoteId); + } + + /** + * @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 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`. + */ + function bridge( + bytes32 quoteId, + 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(); + } + + // 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); + } + + uint256 netAmount; + unchecked { + netAmount = input.inputAmount - feeAmount; + } + + // Approve bridge spender + 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 + _execCallCalldata(bridgeData.target, bridgeData.value, bridgeCallData, false); + + emit RequestExecuted(quoteId); + } + + /** + * @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. + */ + function performActions(bytes32 quoteId, Action[] calldata actions) external payable { + _performActions(actions); + + emit RequestExecuted(quoteId); + } + + // ========================================================================= + // Internal functions + // ========================================================================= + + // ------------------------------------- + // swapAndBridge internal functions + // ------------------------------------- + + /** + * @dev Pull, optional pre/post swap fee, and swap for `swapAndBridge`. Swap output always remains at `address(this)` for bridging. + * @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`. + * @return finalAmount Swap output net of any post-swap fee, ready for `_execBridge`. + */ + function _swapBeforeBridge( + uint256 flags, + InputData calldata input, + FeeData calldata fee, + 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; + { + // Collect pre-swap fee + 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; + } + } + + // Approve swap 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); + } + } + + // 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); + unchecked { + finalAmount -= feeAmount; + } + } + } + + /** + * @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. + */ + function _execBridge( + address token, + uint256 amount, + uint256 flags, + BridgeData calldata bridgeData, + 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 ( + // 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 + uint256 bridgeValue = ((flags & BRIDGE_VALUE_FLAG_BIT_MASK) != 0) ? amount + bridgeData.value : bridgeData.value; + + // Execute bridge call + _execCall(bridgeData.target, bridgeValue, _bridgeCallData); + } + + // -------------------------------------- + // performActions internal functions + // -------------------------------------- + + /** + * @dev Executes `actions` in order, applying returndata splices before each call. + * @dev See `Action` for `actionInfo` and `splices[]` bit layouts. + * @param actions Ordered list of actions to run. + */ + function _performActions(Action[] calldata actions) internal { + uint256 actionsLength = actions.length; + bytes[] memory results = new bytes[](actionsLength); + + for (uint256 i; i < actionsLength;) { + 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); // first 64 bits: index of the prior action to read returndata from. + if (sourceActionIndex >= i) revert FutureSplice(i, sourceActionIndex); + + 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) + } + + unchecked { + ++j; + } + } + + // Parse actionInfo + bool success; + uint256 actionInfo = action.actionInfo; + 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)) { + if (callData.length < 32) revert MissingNativeValue(i); + uint256 callValue; + uint256 payloadLength = callData.length - 32; + assembly ("memory-safe") { + // 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) // 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 { + ++i; + } + } + } + + // ------------------------------- + // 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. + * @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(); + } + return; + } + + // Check caller is user + if (_msgSender() != user) revert CallerNotSignedUser(); + + // 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) // 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)) // 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()) + } + } + } + + /** + * @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) { + // Measure output as (balance after − balance before) at `outputReceiver` + uint256 before = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver); + _execCallCalldata(swapData.target, swapData.value, swapCallData, false); + finalAmount = CurrencyLib.balanceOf(swapData.outputToken, outputReceiver) - before; + } else { + // Decode output from returndata + bytes memory ret = _execCallCalldata(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 _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) + } + + if (!success) { + bytes memory ret; + assembly ("memory-safe") { + // prep and return revert data + let returnDataSize := returndatasize() + ret := mload(0x40) + 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 + } + } + } + + /** + * @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`. + * @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 _execCallCalldata(address target, uint256 value, bytes calldata data, bool storeResult) + internal + returns (bytes memory ret) + { + bool success; + assembly ("memory-safe") { + let ptr := mload(0x40) + 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) // 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)) // bubble up the raw revert payload + } + } + } + } + + /** + * @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(); + + assembly ("memory-safe") { + // read the word at the offset from return data + 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 onlyRole(RESCUE_ROLE) { + RescueFundsLib.rescueFunds(token, rescueTo, amount); + } +} diff --git a/src/combined/BungeeOpenRouterV2.sol b/src/combined/BungeeOpenRouterV2.sol deleted file mode 100644 index 67fe166..0000000 --- a/src/combined/BungeeOpenRouterV2.sol +++ /dev/null @@ -1,420 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; - -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/combined/BungeeOpenRouterV2Unchecked.sol b/src/combined/BungeeOpenRouterV2Unchecked.sol deleted file mode 100644 index 33674ea..0000000 --- a/src/combined/BungeeOpenRouterV2Unchecked.sol +++ /dev/null @@ -1,388 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; - -import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; - -import {Ownable} from "../common/utils/Ownable.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 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 { - using SafeTransferLib for address; - - // ========================================================================= - // Monolithic execution types - // ========================================================================= - - struct InputData { - address user; - address inputToken; - uint256 inputAmount; - } - - struct FeeData { - address receiver; - uint256 amount; - } - - struct SwapData { - address target; - address approvalSpender; - address outputToken; - uint256 value; - uint256 minOutput; - bytes data; - uint256 returnDataWordOffset; - } - - struct BridgeData { - 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; - } - - struct MonolithicExecution { - InputData input; - FeeData preFee; - SwapData swap; - FeeData postFee; - BridgeData bridge; - } - - // ========================================================================= - // Modular execution types - // ========================================================================= - - enum CallType { - CALL, - STATICCALL, - CALL_WITH_NATIVE - } - - struct Action { - uint256 actionInfo; - bytes data; - uint256[] splices; - } - - // ========================================================================= - // 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) Ownable(_owner) {} - - 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 multi-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. - */ - function performExecution(MonolithicExecution calldata exec) external payable { - _runMonolithic(exec); - } - - // ========================================================================= - // External: modular path - // ========================================================================= - - /** - * @notice Runs a sequence of generic actions with optional returndata - * splicing between steps. No signature verification. - */ - function performModularExecution(Action[] calldata actions) external payable returns (bytes[] memory results) { - results = _performActions(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/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/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); +} 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..e094de6 100644 --- a/src/common/lib/BytesSpliceLib.sol +++ b/src/common/lib/BytesSpliceLib.sol @@ -1,9 +1,8 @@ // 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 -/// 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) { diff --git a/src/common/lib/CurrencyLib.sol b/src/common/lib/CurrencyLib.sol index d6df584..56ca7e0 100644 --- a/src/common/lib/CurrencyLib.sol +++ b/src/common/lib/CurrencyLib.sol @@ -1,10 +1,11 @@ // 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"; error TransferFailed(); +// @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 diff --git a/src/common/lib/RescueFundsLib.sol b/src/common/lib/RescueFundsLib.sol new file mode 100644 index 0000000..22c4423 --- /dev/null +++ b/src/common/lib/RescueFundsLib.sol @@ -0,0 +1,33 @@ +// 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(); + +/// @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_); + } + } +} diff --git a/src/common/utils/AccessControl.sol b/src/common/utils/AccessControl.sol new file mode 100644 index 0000000..51e3f72 --- /dev/null +++ b/src/common/utils/AccessControl.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-only +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; + + 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..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.25; +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 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 deleted file mode 100644 index 86c1bc5..0000000 --- a/src/minimal/BungeeOpenRouterMinimal.sol +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; - -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 fbd0401..0000000 --- a/src/minimal/BungeeOpenRouterMinimalAH.sol +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; - -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 14983bc..0000000 --- a/src/modular/BungeeOpenRouterModular.sol +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; - -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 e0f37cb..0000000 --- a/src/modular/BungeeOpenRouterModularAH.sol +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; - -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 df0ac22..0000000 --- a/src/monolithic/BungeeOpenRouter.sol +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; - -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 2f0f560..0000000 --- a/src/monolithic/BungeeOpenRouterAH.sol +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity =0.8.25; - -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()) - } - } - } -} diff --git a/test/combined/OpenRouterV2UncheckedBridge.t.sol b/test/combined/OpenRouterV2UncheckedBridge.t.sol new file mode 100644 index 0000000..e6c5f81 --- /dev/null +++ b/test/combined/OpenRouterV2UncheckedBridge.t.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; +import {OpenRouterV2UncheckedTestBase} from "./OpenRouterV2UncheckedTestBase.sol"; + +contract OpenRouterV2UncheckedBridgeTest is OpenRouterV2UncheckedTestBase { + 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/OpenRouterV2UncheckedSwap.t.sol b/test/combined/OpenRouterV2UncheckedSwap.t.sol new file mode 100644 index 0000000..b36aca1 --- /dev/null +++ b/test/combined/OpenRouterV2UncheckedSwap.t.sol @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; +import {OpenRouterV2UncheckedTestBase} from "./OpenRouterV2UncheckedTestBase.sol"; + +contract OpenRouterV2UncheckedSwapTest is OpenRouterV2UncheckedTestBase { + 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/OpenRouterV2UncheckedSwapAndBridge.t.sol b/test/combined/OpenRouterV2UncheckedSwapAndBridge.t.sol new file mode 100644 index 0000000..3eddd22 --- /dev/null +++ b/test/combined/OpenRouterV2UncheckedSwapAndBridge.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {OpenRouter as Router} from "../../src/OpenRouter.sol"; +import {OpenRouterV2UncheckedTestBase} from "./OpenRouterV2UncheckedTestBase.sol"; + +contract OpenRouterV2UncheckedSwapAndBridgeTest is OpenRouterV2UncheckedTestBase { + enum FeeMode { + None, + Pre, + Post + } + + struct Scenario { + 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, false); + } + + function test_swapAndBridge_noFee_nativeToErc20() public { + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.None, false); + } + + function test_swapAndBridge_noFee_erc20ToErc20() public { + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.None, false); + } + + function test_swapAndBridge_prefee_erc20ToNative() public { + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Pre, false); + } + + function test_swapAndBridge_prefee_nativeToErc20() public { + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Pre, false); + } + + function test_swapAndBridge_prefee_erc20ToErc20() public { + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Pre, false); + } + + function test_swapAndBridge_postfee_erc20ToNative() public { + _runSwapAndBridge(address(inputToken), NATIVE_TOKEN, FeeMode.Post, false); + } + + function test_swapAndBridge_postfee_nativeToErc20() public { + _runSwapAndBridge(NATIVE_TOKEN, address(outputToken), FeeMode.Post, false); + } + + function test_swapAndBridge_postfee_erc20ToErc20() public { + _runSwapAndBridge(address(inputToken), address(outputToken), FeeMode.Post, false); + } + + 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); + + _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, bool balanceDelta) + internal + pure + returns (Scenario memory scenario) + { + scenario.input = input; + scenario.output = output; + scenario.feeMode = feeMode; + scenario.balanceDelta = balanceDelta; + 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"), + _flags(scenario.output, scenario.feeMode, scenario.balanceDelta), + Router.InputData({user: USER, inputToken: scenario.input, inputAmount: INPUT_AMOUNT}), + _fee(scenario.feeMode), + _swapDataWithValue( + scenario.input, + scenario.output, + SWAP_OUTPUT_AMOUNT, + scenario.input == NATIVE_TOKEN ? scenario.swapInput : 0 + ), + _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); + } + + 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, 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); + } + + 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/OpenRouterV2UncheckedTestBase.sol b/test/combined/OpenRouterV2UncheckedTestBase.sol new file mode 100644 index 0000000..1b3fda1 --- /dev/null +++ b/test/combined/OpenRouterV2UncheckedTestBase.sol @@ -0,0 +1,376 @@ +// 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"; + +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; + 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"), + params.flags, + Router.InputData({user: USER, inputToken: params.input, inputAmount: params.inputAmount}), + params.fee, + params.swapData, + params.swapCallData, + params.receiver + ) + ) + ); + 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"), + params.flags, + Router.InputData({user: USER, inputToken: params.input, inputAmount: params.inputAmount}), + 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); + } +} diff --git a/test/poc/OneInchCctpOpenRouterPoC.t.sol b/test/poc/OneInchCctpOpenRouterPoC.t.sol index 0c24dff..d5f939e 100644 --- a/test/poc/OneInchCctpOpenRouterPoC.t.sol +++ b/test/poc/OneInchCctpOpenRouterPoC.t.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity =0.8.25; +pragma solidity 0.8.34; 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 {OpenRouter as Router} from "../../src/OpenRouter.sol"; import {ALLOWANCE_HOLDER, IAllowanceHolder} from "../../src/common/interfaces/IAllowanceHolder.sol"; interface ITokenMessengerV2 { @@ -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 = @@ -76,24 +77,22 @@ contract OneInchCctpOpenRouterPoCTest is Test { Router.Action[] memory actions = _buildActions(inputAmount, vm.parseBytes(ONEINCH_SWAP_CALLDATA)); - bytes memory ahResult; vm.prank(FIXTURE_RECIPIENT); uint256 gasBeforeExecute = gasleft(); - ahResult = IAllowanceHolder(address(ALLOWANCE_HOLDER)).exec( + IAllowanceHolder(address(ALLOWANCE_HOLDER)).exec( address(router), POLYGON_AAVE, inputAmount, payable(address(router)), - abi.encodeCall(router.performModularExecution, (actions)) + abi.encodeCall(router.performActions, (keccak256("one-inch-cctp-modular"), actions)) ); uint256 executeGasUsed = gasBeforeExecute - gasleft(); - emit log_named_uint("AllowanceHolder.exec -> router.performModularExecution gas used", executeGasUsed); + emit log_named_uint("AllowanceHolder.exec -> router.performActions gas used", executeGasUsed); - bytes[] memory results = abi.decode(ahResult, (bytes[])); - _assertPocResult(router, feeRecipientUsdcBefore, usdcSupplyBefore, results[2], results[5]); + _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); @@ -119,22 +118,17 @@ contract OneInchCctpOpenRouterPoCTest is Test { uint256 feeRecipientUsdcBefore = ERC20(POLYGON_USDC).balanceOf(FEE_RECIPIENT); uint256 usdcSupplyBefore = ERC20(POLYGON_USDC).totalSupply(); - Router.MonolithicExecution memory exec = - _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)) + 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); + _assertPocResult(router, feeRecipientUsdcBefore, usdcSupplyBefore); } function test_oneInchSwapCctpBridgeSocketGatewayReference_polygonFork() public { @@ -236,61 +230,32 @@ 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) + returns (bytes memory) { - uint256[] memory amountPositions = new uint256[](1); - amountPositions[0] = 4; - - 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"), + uint256(0x01 | 0x08 | (uint256(4) << 16)), + Router.InputData({user: FIXTURE_RECIPIENT, inputToken: POLYGON_AAVE, inputAmount: inputAmount}), + Router.FeeData({receiver: FEE_RECIPIENT, amount: ROUTE_FEE_USDC}), + Router.SwapData({ target: ONEINCH_SWAP_TARGET, approvalSpender: ONEINCH_SWAP_TARGET, 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 - }) - }); - } - - function _assertPocResult( - Router router, - uint256 feeRecipientUsdcBefore, - uint256 usdcSupplyBefore, - bytes memory oneInchResult, - bytes memory balanceResult - ) internal view { - uint256 swapOutput = abi.decode(oneInchResult, (uint256)); - uint256 bridgeAmount = abi.decode(balanceResult, (uint256)); - - assertEq(swapOutput, EXPECTED_SWAP_OUTPUT_USDC); - assertEq(bridgeAmount, EXPECTED_CCTP_BURN_AMOUNT); - assertEq(bridgeAmount, swapOutput - ROUTE_FEE_USDC); - assertEq(ERC20(POLYGON_USDC).balanceOf(FEE_RECIPIENT) - feeRecipientUsdcBefore, ROUTE_FEE_USDC); - assertEq(ERC20(POLYGON_USDC).totalSupply(), usdcSupplyBefore - bridgeAmount); - assertEq(ERC20(POLYGON_AAVE).balanceOf(address(router)), 0); - assertEq(ERC20(POLYGON_USDC).balanceOf(address(router)), 0); + swapCalldata, + Router.BridgeData({target: CCTP_TOKEN_MESSENGER_V2, approvalSpender: CCTP_TOKEN_MESSENGER_V2, value: 0}), + _emptyDepositForBurnCalldata() + ); } - function _assertMonolithicPocResult(Router router, uint256 feeRecipientUsdcBefore, uint256 usdcSupplyBefore) - internal - view - { + function _assertPocResult(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); diff --git a/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol b/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol index 537f5fa..57522fd 100644 --- a/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol +++ b/test/poc/OpenOceanAcrossOpenRouterPoC.t.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity =0.8.25; +pragma solidity 0.8.34; 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 {OpenRouter as Router} from "../../src/OpenRouter.sol"; import {AcrossERC20AmountManipulator} from "../../src/manipulators/AcrossERC20AmountManipulator.sol"; interface ISpokePool { @@ -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"; @@ -67,11 +68,11 @@ contract OpenOceanAcrossOpenRouterPoCTest is Test { uint256 spokePoolWethBefore = ERC20(ARBITRUM_WETH).balanceOf(ACROSS_ARBITRUM_SPOKE_POOL); uint256 gasBeforeExecute = gasleft(); - bytes[] memory results = router.performModularExecution(actions); + router.performActions(keccak256("open-ocean-across-modular"), actions); uint256 executeGasUsed = gasBeforeExecute - gasleft(); - emit log_named_uint("router.performModularExecution gas used", executeGasUsed); + emit log_named_uint("router.performActions gas used", executeGasUsed); - _assertPocResult(router, bridgeFee, spokePoolWethBefore, results[2]); + _assertPocResult(router, bridgeFee, spokePoolWethBefore); } function _buildActions( @@ -121,19 +122,12 @@ contract OpenOceanAcrossOpenRouterPoCTest is Test { ); } - function _assertPocResult( - Router router, - uint256 bridgeFee, - uint256 spokePoolWethBefore, - bytes memory manipulatorResult - ) internal view { + function _assertPocResult(Router router, uint256 bridgeFee, uint256 spokePoolWethBefore) internal view { uint256 actualInputAmount = ERC20(ARBITRUM_WETH).balanceOf(ACROSS_ARBITRUM_SPOKE_POOL) - spokePoolWethBefore; - uint256 actualOutputAmount = abi.decode(manipulatorResult, (uint256)); assertEq(ERC20(ARBITRUM_USDC).balanceOf(address(router)), 0); assertEq(ERC20(ARBITRUM_WETH).balanceOf(address(router)), 0); - assertGt(actualInputAmount, 0); - assertEq(actualOutputAmount, actualInputAmount - bridgeFee); + assertGt(actualInputAmount, bridgeFee); } function _routerAtFixtureAddress() internal returns (Router router) { diff --git a/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol b/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol index b653b75..f9ae56a 100644 --- a/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol +++ b/test/poc/OpenOceanStargateNativeOpenRouterPoC.t.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity =0.8.25; +pragma solidity 0.8.34; 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 {OpenRouter as Router} from "../../src/OpenRouter.sol"; import {MathManipulator} from "../../src/manipulators/MathManipulator.sol"; interface IOpenOceanExchangeV2 { @@ -101,21 +101,11 @@ contract OpenOceanStargateNativeOpenRouterPoCTest is Test { ); uint256 gasBeforeExecute = gasleft(); - bytes[] memory results = router.performModularExecution(actions); + router.performActions(keccak256("open-ocean-stargate-native-modular"), actions); uint256 executeGasUsed = gasBeforeExecute - gasleft(); - emit log_named_uint("router.performModularExecution gas used", executeGasUsed); - - _assertPocResult( - router, - nativeFee, - initialNativeBalance, - initialFeeRecipientBalance, - initialWethBalance, - results[1], - results[2], - results[4], - results[5] - ); + emit log_named_uint("router.performActions gas used", executeGasUsed); + + _assertPocResult(router, nativeFee, initialNativeBalance, initialFeeRecipientBalance, initialWethBalance); } function _openOceanSwapCalldata(uint256 inputAmount) internal pure returns (bytes memory) { @@ -268,22 +258,9 @@ contract OpenOceanStargateNativeOpenRouterPoCTest is Test { uint256 nativeFee, uint256 initialNativeBalance, uint256 initialFeeRecipientBalance, - uint256 initialWethBalance, - bytes memory openOceanResult, - bytes memory feeResult, - bytes memory postFeeResult, - bytes memory bridgeAmountResult + uint256 initialWethBalance ) internal view { - uint256 swapOutput = abi.decode(openOceanResult, (uint256)); - uint256 routeFee = abi.decode(feeResult, (uint256)); - uint256 postFeeAmount = abi.decode(postFeeResult, (uint256)); - uint256 bridgeAmount = abi.decode(bridgeAmountResult, (uint256)); - - assertGt(swapOutput, 0); - assertEq(routeFee, swapOutput * ROUTE_FEE_BPS / 10_000); - assertEq(FEE_RECIPIENT.balance - initialFeeRecipientBalance, routeFee); - assertEq(postFeeAmount + routeFee, swapOutput); - assertEq(bridgeAmount + nativeFee, postFeeAmount); + assertGt(FEE_RECIPIENT.balance - initialFeeRecipientBalance, 0); assertEq(ERC20(ARBITRUM_USDC).balanceOf(address(router)), 0); assertEq(ERC20(ARBITRUM_WETH).balanceOf(address(router)), initialWethBalance); assertLt(address(router).balance - initialNativeBalance, nativeFee); 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"); + } +}