diff --git a/src/swap/execution/bridge.ts b/src/swap/execution/bridge.ts index 61d852a6..9dc0fb18 100644 --- a/src/swap/execution/bridge.ts +++ b/src/swap/execution/bridge.ts @@ -5,9 +5,8 @@ import { VAULT_ABI_MAYAN, } from '@avail-project/nexus-types/rff'; import Decimal from 'decimal.js'; -import { encodeFunctionData, erc20Abi, type Hex, type PublicClient, parseSignature } from 'viem'; +import { encodeFunctionData, erc20Abi, type Hex, type PublicClient } from 'viem'; import type { PrivateKeyAccount } from 'viem/accounts'; -import { ERC20PermitABI } from '../../abi/erc20'; import { EVMVaultABI } from '../../abi/vault'; import { submitRFFToMiddleware, waitForFill } from '../../bridge/executor'; import { type Chain, DEFAULT_FILL_TIMEOUT_MINUTES, getLogger } from '../../domain'; @@ -19,7 +18,6 @@ import { NexusError, UserActionError, } from '../../domain/errors'; -import { PermitVariant } from '../../domain/permits'; import { isNativeAddress } from '../../services/addresses'; import { confirmStepReceipt, switchChain } from '../../services/evm'; import { createExplorerTxURL } from '../../services/explorer'; @@ -46,6 +44,7 @@ import { createSwapBridgeIntent } from '../bridge-intent'; import { predictSafeAccountAddress } from '../safe/predict'; import type { BridgeAsset, ExecutionContext, SBCResult, SwapMetadata, SwapRoute } from '../types'; import { chainSupports7702 } from '../wallet/capabilities'; +import { buildEphemeralPermitCall } from '../wallet/ephemeral-permit'; import { resolvePreparedFundingTransferCalls } from './eoa-to-ephemeral'; import { dispatchSafeSource } from './safe-dispatch'; @@ -151,11 +150,12 @@ const submitBridgeFundingSbc = async ( throw Errors.internal(`Unreachable bridge funding SBC retry state for chain ${chainId}`); }; -// Non-7702 bridge funding/allowance, shared by the Nexus deposit batch and the Mayan approve: -// 1. transfer(ephemeral, depositValue) — Safe moves the COT to the ephemeral -// 2. permit(ephemeral → vault) — ephemeral grants the vault transferFrom via EIP-2612 +// Non-7702 bridge allowance, shared by the Nexus deposit batch and the Mayan approve: +// 1. permit(ephemeral → vault) — ephemeral grants the vault transferFrom via EIP-2612 +// COT already sits at the ephemeral: source swaps deliver there directly, and EOA-held bridge +// funding uses transferFrom(EOA, ephemeral) with the Safe as spender. // (the deposit itself is appended by the Nexus path, or sponsored by the middleware for Mayan). -const buildSafeTransferAndPermitCalls = async (input: { +const buildSafePermitCalls = async (input: { asset: BridgeAsset; depositValue: bigint; vaultAddress: Hex; @@ -165,105 +165,24 @@ const buildSafeTransferAndPermitCalls = async (input: { publicClient: PublicClient; deadline: bigint; }): Promise => { - const token = input.chainList.getTokenByAddress(input.chain.id, input.asset.contractAddress); - const permitVariant = token?.permitVariant; - if (!permitVariant || permitVariant === PermitVariant.Unsupported) { - throw Errors.tokenNotSupported( - input.asset.contractAddress, - input.chain.id, - 'permit required for non-7702 bridge deposit' - ); - } - // v1's createPermitOnlyApprovalTx only supports EIP-2612 canonical. - if (permitVariant !== PermitVariant.EIP2612Canonical) { - throw Errors.tokenNotSupported( - input.asset.contractAddress, - input.chain.id, - '(2612 details not found)' - ); - } - const permitContractVersion = token?.permitVersion ?? 1; - - const [name, nonce] = (await Promise.all([ - input.publicClient.readContract({ - address: input.asset.contractAddress, - abi: erc20Abi, - functionName: 'name', - }), - input.publicClient.readContract({ - address: input.asset.contractAddress, - abi: ERC20PermitABI, - functionName: 'nonces', - args: [input.ephemeralWallet.address], - }), - ])) as [string, bigint]; - - const sigHex = await input.ephemeralWallet.signTypedData({ - domain: { - chainId: BigInt(input.chain.id), - name, - verifyingContract: input.asset.contractAddress, - version: permitContractVersion.toString(10), - }, - types: { - Permit: [ - { name: 'owner', type: 'address' }, - { name: 'spender', type: 'address' }, - { name: 'value', type: 'uint256' }, - { name: 'nonce', type: 'uint256' }, - { name: 'deadline', type: 'uint256' }, - ], - }, - primaryType: 'Permit', - message: { - owner: input.ephemeralWallet.address, + return [ + await buildEphemeralPermitCall({ + tokenAddress: input.asset.contractAddress, + amount: input.depositValue, spender: input.vaultAddress, - value: input.depositValue, - nonce, + chain: input.chain, + chainList: input.chainList, + ephemeralWallet: input.ephemeralWallet, + publicClient: input.publicClient, deadline: input.deadline, - }, - }); - const parsedSig = parseSignature(sigHex); - const v = Number( - parsedSig.v ?? (parsedSig.yParity != null ? Number(parsedSig.yParity) + 27 : 27) - ); - - return [ - { - to: input.asset.contractAddress, - value: 0n, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [input.ephemeralWallet.address, input.depositValue], - }), - }, - { - to: input.asset.contractAddress, - value: 0n, - data: encodeFunctionData({ - abi: ERC20PermitABI, - functionName: 'permit', - args: [ - input.ephemeralWallet.address, - input.vaultAddress, - input.depositValue, - input.deadline, - v, - parsedSig.r, - parsedSig.s, - ], - }), - }, + }), ]; }; -// Safe-path bridge deposit (non-7702 source). Seam 1 bridges the actual Safe balance, so the batch -// is just transfer → permit → deposit with no trailing Sweeper (the deposit drains the Safe): +// Safe-path bridge deposit (non-7702 source). COT is already at the ephemeral, so the batch is: // -// 1. transfer(ephemeral, depositValue) — Safe moves the full COT to ephemeral -// 2. permit(ephemeral → vault) — ephemeral signs EIP-2612 granting vault transferFrom -// 3. vault.deposit(...) — vault.transferFrom(ephemeral, vault, depositValue) +// 1. permit(ephemeral → vault) — ephemeral signs EIP-2612 granting vault transferFrom +// 2. vault.deposit(...) — vault.transferFrom(ephemeral, vault, depositValue) const buildSafeBridgeDepositCalls = async (input: { asset: BridgeAsset; depositValue: bigint; @@ -280,7 +199,7 @@ const buildSafeBridgeDepositCalls = async (input: { deadline: bigint; }): Promise => { const calls: SafeCall[] = [ - ...(await buildSafeTransferAndPermitCalls({ + ...(await buildSafePermitCalls({ asset: input.asset, depositValue: input.depositValue, vaultAddress: input.vaultAddress, @@ -302,9 +221,6 @@ const buildSafeBridgeDepositCalls = async (input: { }, ]; - // No COT sweep: Seam 1 bridges the actual Safe balance, so transfer(ephemeral, depositValue) moves - // the full COT and the deposit drains it — nothing residual stays at the Safe. The surplus is - // consolidated at the destination, not returned per source chain. return calls; }; @@ -623,11 +539,9 @@ const runMayanEphemeralBridge = async ( ctx.middlewareClient ); } else { - // Non-7702: the sponsored depositMayan pulls the COT from the ephemeral. Move it - // Safe→ephemeral and grant the vault allowance via permit in one Safe batch. When a source - // swap funded the Safe the COT already sits there (eoaBalance == 0 ⇒ fundingCalls empty); on - // the fast path it's still at the EOA, so prepend the EOA→Safe funding (permit + transferFrom) - // — without it the Safe holds zero and the Safe→ephemeral transfer reverts (GS013). + // Non-7702: COT already lands at the ephemeral. The Safe submits the ephemeral→vault permit; + // on an EOA-held fast path, fundingCalls first performs transferFrom(EOA→ephemeral) with the + // Safe as spender. const publicClient = ctx.publicClientList.get(asset.chainID); const { address: safeAddress } = predictSafeAccountAddress(ctx.ephemeralWallet.address); await ensureSafeForEphemeral({ @@ -640,7 +554,7 @@ const runMayanEphemeralBridge = async ( BigInt(Math.floor(Date.now() / 1000)) + BRIDGE_VAULT_PERMIT_DEADLINE_SECONDS; const safeCalls = [ ...fundingCalls, - ...(await buildSafeTransferAndPermitCalls({ + ...(await buildSafePermitCalls({ asset, depositValue: totalBalanceRaw, vaultAddress, @@ -1131,11 +1045,9 @@ const executeEphemeralBridgePath = async ( ctx, }); } else if (chain && !chainSupports7702(chain)) { - // Non-7702 source chain → v1's Safe `safe_account` mode batch: per-asset - // transfer→permit→deposit→approve(Sweeper)→sweep. When a source swap funded the Safe its - // output already sits there (eoaBalance == 0 ⇒ fundingCalls empty); on the fast path the - // COT is still at the EOA, so prepend the EOA→Safe funding (permit + transferFrom) — without - // it the Safe holds zero and the deposit's transfer reverts (GS013). + // Non-7702 source: the Safe submits permit→deposit. Source swaps already delivered COT to + // the ephemeral; on an EOA-held fast path, fundingCalls first performs + // transferFrom(EOA→ephemeral) with the Safe as spender. const publicClient = ctx.publicClientList.get(asset.chainID); const { address: safeAddress } = predictSafeAccountAddress(ctx.ephemeralWallet.address); await ensureSafeForEphemeral({ @@ -1172,7 +1084,7 @@ const executeEphemeralBridgePath = async ( const result = await ctx.middlewareClient.createSafeExecuteTx(request); txHash = result.txHash; } else { - // 7702 chain: existing Calibur SBC path. Build approve(vault)+deposit+sweep on the + // 7702 chain: existing Calibur SBC path. Build approve(vault)+deposit on the // ephemeral smart account (msg.sender == ephemeral); funding calls come from the // pre-built EOA→ephemeral transfer authorization. const calls = [...fundingCalls]; @@ -1198,7 +1110,7 @@ const executeEphemeralBridgePath = async ( }), value: 0n, }); - // No COT sweep: Seam 1 bridges the actual wrapper balance, so approve(vault, total) + deposit + // No COT sweep: Seam 1 bridges the actual source balance, so approve(vault, total) + deposit // drain the ephemeral — nothing residual to sweep. The surplus is consolidated at the // destination (EXACT_OUT direct transfer / EXACT_IN grown swap), not returned per source chain. const sbcTx = await createSBCTxFromCalls({ diff --git a/src/swap/execution/failure-cleanup.ts b/src/swap/execution/failure-cleanup.ts index 1a1379d7..5621f4ae 100644 --- a/src/swap/execution/failure-cleanup.ts +++ b/src/swap/execution/failure-cleanup.ts @@ -1,4 +1,4 @@ -import type { Hex } from 'viem'; +import { encodeFunctionData, erc20Abi, type Hex } from 'viem'; import { getLogger } from '../../domain'; import { buildRefundSweepCall, @@ -10,9 +10,11 @@ import { type CurrencyID, resolveCOT } from '../cot'; import { predictSafeAccountAddress } from '../safe/predict'; import type { ExecutionContext, SwapRoute } from '../types'; import { chainSupports7702 } from '../wallet/capabilities'; +import { buildEphemeralPermitCall } from '../wallet/ephemeral-permit'; import { readSettlementBalanceRaw } from './settlement-balance'; const logger = getLogger(); +const CLEANUP_PERMIT_DEADLINE_SECONDS = 5n * 60n; /** * The currency the on-failure cleanup should sweep, or `null` to skip it. A Nexus same-token bridge @@ -35,20 +37,28 @@ export const resolveFailureSweepCurrencyId = ( type FailureCleanupContext = Pick< ExecutionContext, - 'cache' | 'chainList' | 'eoaAddress' | 'ephemeralWallet' | 'middlewareClient' | 'publicClientList' ->; + | 'cache' + | 'chainList' + | 'destinationDirectEoa' + | 'eoaAddress' + | 'ephemeralWallet' + | 'middlewareClient' + | 'publicClientList' +> & { destinationChainId: number }; /** * Sweep the route's COT stranded on a failed leg back to the EOA. Unlike a blind balance scan, we * know exactly what to look for: the single COT token, on the chains the failure left it (source * chains if we failed before the bridge, the destination chain if the destination swap failed), at - * the one holder that chain uses (ephemeral on 7702, predicted Safe otherwise). So we read just that - * one balance per chain (`balanceOf` / `getBalance`) and direct-transfer the exact amount — no full - * `getBalancesForSwap` over every token on every chain for two addresses. Best-effort; never rethrows. + * the holder for the failed stage. Remote source settlement is at the ephemeral on both wallet + * paths; destination-chain settlement stays at its wrapper when another swap follows. On a + * non-7702 remote source, the Safe submits an ephemeral permit + transferFrom recovery batch. + * Best-effort; never rethrows. */ export const cleanupStrandedCot = async (input: { currencyId: CurrencyID; chainIds: number[]; + scope: 'source' | 'destination'; ctx: FailureCleanupContext; }): Promise => { const { ctx } = input; @@ -58,12 +68,17 @@ export const cleanupStrandedCot = async (input: { currencyId: input.currencyId, chainIds: input.chainIds, chainCount: input.chainIds.length, + scope: input.scope, }); for (const chainId of input.chainIds) { try { - const is7702 = chainSupports7702(ctx.chainList.getChainByID(chainId)); - const holderAddress = is7702 ? ctx.ephemeralWallet.address : safeAddress; + const chain = ctx.chainList.getChainByID(chainId); + const is7702 = chainSupports7702(chain); + const sourceOnDestination = input.scope === 'source' && chainId === ctx.destinationChainId; + if (sourceOnDestination && ctx.destinationDirectEoa) continue; + const sourceAtEphemeral = input.scope === 'source' && chainId !== ctx.destinationChainId; + const holderAddress = is7702 || sourceAtEphemeral ? ctx.ephemeralWallet.address : safeAddress; const cot = resolveCOT(chainId, ctx.chainList, input.currencyId); const tokenAddress = cot.address as Hex; const balance = await readSettlementBalanceRaw({ @@ -74,6 +89,36 @@ export const cleanupStrandedCot = async (input: { }); if (balance <= 0n) continue; + if (sourceAtEphemeral && !is7702) { + const deadline = BigInt(Math.floor(Date.now() / 1000)) + CLEANUP_PERMIT_DEADLINE_SECONDS; + const permitCall = await buildEphemeralPermitCall({ + tokenAddress, + amount: balance, + spender: safeAddress, + chain, + chainList: ctx.chainList, + ephemeralWallet: ctx.ephemeralWallet, + publicClient: ctx.publicClientList.get(chainId), + deadline, + }); + groups.push({ + chainId, + holder: 'safe', + calls: [ + permitCall, + { + to: tokenAddress, + value: 0n, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transferFrom', + args: [ctx.ephemeralWallet.address, ctx.eoaAddress, balance], + }), + }, + ], + }); + continue; + } groups.push({ chainId, holder: is7702 ? 'ephemeral' : 'safe', diff --git a/src/swap/execution/orchestrator.ts b/src/swap/execution/orchestrator.ts index f99cade5..c0a2a34a 100644 --- a/src/swap/execution/orchestrator.ts +++ b/src/swap/execution/orchestrator.ts @@ -121,7 +121,12 @@ export const executeSwapRoute = async ( ? [route.destination.chainId] : metadata.src.map((entry) => entry.chid); await withTimingSpan(context.timing, 'flow.swap.execute.cleanup', async () => - cleanupStrandedCot({ currencyId: sweepCurrencyId, chainIds, ctx: context }) + cleanupStrandedCot({ + currencyId: sweepCurrencyId, + chainIds, + scope: reachedDestinationSwap ? 'destination' : 'source', + ctx: context, + }) ); } throw error; diff --git a/src/swap/execution/source-swaps.ts b/src/swap/execution/source-swaps.ts index c5ed1cf4..753129bb 100644 --- a/src/swap/execution/source-swaps.ts +++ b/src/swap/execution/source-swaps.ts @@ -153,8 +153,8 @@ const buildBridgeAsset = ( ? divDecimals(overrideBalanceRaw, decimals) : chainSwaps.reduce((sum, swap) => sum.plus(swap.quote.output.amount), new Decimal(0)); - // Swap output is always carried as the ephemeral identity for the RFF; the per-chain Safe - // → ephemeral transfer happens inside the bridge deposit batch, not in this bookkeeping. + // Remote swap output already lands at the ephemeral bridge holder on both wallet paths. The + // asset is therefore always carried as the ephemeral identity for the RFF. return { chainID: chainId, contractAddress: cot?.contractAddress ?? chainSwaps[0].quote.output.contractAddress, @@ -164,17 +164,21 @@ const buildBridgeAsset = ( }; }; -// Read the COT that actually landed at the source wrapper after the swaps confirmed (ephemeral on -// 7702 chains, the predicted Safe on non-7702). Mirrors the targeted read in failure-cleanup. -const readWrapperCotBalanceRaw = async ( +// Read the COT that actually landed after the source swaps confirmed. Remote outputs that bridge +// land directly at the ephemeral on both wallet paths. A destination-chain Safe keeps local COT for +// its later destination swap. +const readSourceCotBalanceRaw = async ( chainId: number, cotAddress: Hex, - ctx: Pick + ctx: Pick & { + destinationChainId: number; + } ): Promise => { const is7702 = chainSupports7702(ctx.chainList.getChainByID(chainId)); - const holder = is7702 - ? ctx.ephemeralWallet.address - : predictSafeAccountAddress(ctx.ephemeralWallet.address).address; + const holder = + is7702 || chainId !== ctx.destinationChainId + ? ctx.ephemeralWallet.address + : predictSafeAccountAddress(ctx.ephemeralWallet.address).address; return readSettlementBalanceRaw({ chainId, tokenAddress: cotAddress, @@ -363,11 +367,12 @@ const requoteFailedChains = async ( 'sourceExecutionPaths' | 'eoaAddress' | 'ephemeralWallet' | 'destinationDirectEoa' > & { destinationChainId: number } ) => { - // Per-chain recipient: when the chain is the dst chain AND the route has no dst swap step, - // the route quoted the source swap with recipient = EOA (direct delivery). Otherwise the - // recipient is the chain's wrapper (Safe for non-7702, ephemeral for 7702). + // Per-chain recipient: remote output always lands at the ephemeral bridge holder. On the + // destination chain, direct delivery uses the EOA and a later Safe destination swap keeps COT + // at that Safe. const recipientForChain = (chainId: number, walletPath: WalletPath): Hex => { if (chainId === ctx.destinationChainId && ctx.destinationDirectEoa) return ctx.eoaAddress; + if (chainId !== ctx.destinationChainId) return ctx.ephemeralWallet.address; return walletPath === 'safe' ? predictSafeAccountAddress(ctx.ephemeralWallet.address).address : ctx.ephemeralWallet.address; @@ -758,8 +763,8 @@ export const executeSourceSwaps = async ( }); } - // Bridge funding flows through the ephemeral identity regardless of the per-chain wrapper; the - // asset is always tagged ephemeral here. EXACT_IN reclaim reads the actual wrapper COT balance so + // Bridge funding flows through the ephemeral identity regardless of the source executor; the + // asset is always tagged ephemeral here. EXACT_IN reclaim reads the actual source COT holder so // positive source slippage bridges through instead of being swept at the source. return Promise.all( sortedEntries.map(async (entry) => { @@ -773,7 +778,7 @@ export const executeSourceSwaps = async ( ctx.timing, 'flow.swap.execute.source.read_actual_balance', async () => - readWrapperCotBalanceRaw( + readSourceCotBalanceRaw( entry.chainId, cot?.contractAddress ?? entry.chainSwaps[0].quote.output.contractAddress, ctx diff --git a/src/swap/prepare.ts b/src/swap/prepare.ts index e85fbf03..33841a23 100644 --- a/src/swap/prepare.ts +++ b/src/swap/prepare.ts @@ -42,6 +42,7 @@ type DeterministicTransferSpec = { amount: bigint; eagerPermit: boolean; targetAddress: Hex; + recipientAddress?: Hex; }; const getPublicClientMap = ( @@ -146,9 +147,9 @@ export const prepareSwapExecution = async ( ) ? input.ephemeralWallet.address : predictSafeAccountAddress(input.ephemeralWallet.address).address; - // Bridge deposit executor (= permit spender + transferFrom recipient): the predicted Safe on - // non-7702 chains (the Safe runs the deposit batch and pulls the COT from itself), the ephemeral - // on 7702 chains. Mirrors the deposit dispatch in execution/bridge.ts. + // Bridge funding spender/executor: the predicted Safe on non-7702 chains, the ephemeral on 7702. + // The recipient is always the ephemeral bridge holder; on a Safe chain the Safe calls + // transferFrom(EOA, ephemeral, amount) without taking intermediate custody. const ownerForBridgeChain = (chainId: number) => chainSupports7702(input.chainList.getChainByID(chainId)) ? input.ephemeralWallet.address @@ -204,6 +205,7 @@ export const prepareSwapExecution = async ( amount: mulDecimals(asset.eoaBalance, asset.decimals), eagerPermit: false, targetAddress: ownerForBridgeChain(asset.chainID), + recipientAddress: input.ephemeralWallet.address, }, ]; }) ?? []; diff --git a/src/swap/routing/addresses.ts b/src/swap/routing/addresses.ts index d2b1001e..e2669217 100644 --- a/src/swap/routing/addresses.ts +++ b/src/swap/routing/addresses.ts @@ -1,9 +1,9 @@ import type { Hex } from 'viem'; import type { ChainListType } from '../../domain'; +import type { RouteOptions } from '../route'; import { predictSafeAccountAddress } from '../safe/predict'; import type { WalletPath } from '../types'; import { chainSupports7702, resolveWalletPath } from '../wallet/capabilities'; -import type { RouteOptions } from '../route'; export type WalletDecision = { sourceExecutionPaths: Map; @@ -42,8 +42,9 @@ export function buildExecutorAddressByChain( ); } -// Source-swap recipient. Output stays at the per-chain wrapper unless this is the same-chain -// COT-destination case (no dst swap step) — there it can go straight to the user's EOA. +// Source-swap recipient. Remote outputs that will bridge go straight to the ephemeral bridge +// holder, even when a Safe executes the swap. Destination-chain output stays at its wrapper unless +// this is the COT-destination case (no dst swap step), where it can go straight to the user's EOA. export function buildSourceRecipientAddressByChain(input: { chainIds: Iterable; sourceExecutionPaths: Map; @@ -61,6 +62,9 @@ export function buildSourceRecipientAddressByChain(input: { if (!path) { return [chainId, input.options.ephemeralAddress]; } + if (chainId !== input.destinationChainId) { + return [chainId, input.options.ephemeralAddress]; + } return [chainId, resolveWalletAddress(path, input.options)]; }) ); diff --git a/src/swap/routing/exact-in.ts b/src/swap/routing/exact-in.ts index 6af33727..96cb2aed 100644 --- a/src/swap/routing/exact-in.ts +++ b/src/swap/routing/exact-in.ts @@ -568,7 +568,7 @@ export async function _exactInRoute(data: ExactInData, options: RouteOptions): P cotByChain: buildSourceCotByChain(sourceSwaps, chainList, currencyId), // EXACT_IN: no source buffer — a failed leg re-quotes and proceeds with no drift guard. srcBuffer: null, - // Only meaningful when a bridge runs — execution bridges the actual wrapper balance so + // Only meaningful when a bridge runs — execution bridges the actual source-holder balance so // positive source slippage reaches the destination instead of being swept at the source. reclaimFromActualBalance: bridge !== null, }, diff --git a/src/swap/swap.md b/src/swap/swap.md index fb9198d4..41149b7a 100644 --- a/src/swap/swap.md +++ b/src/swap/swap.md @@ -92,14 +92,16 @@ implemented; every execution stage reads `sourceExecutionPaths` and switches dis | Stage | `'ephemeral'` | `'safe'` | |---|---|---| | Source swap | Calibur SBC → `submitSBCs` | `dispatchSafeSource` (ensure Safe, then `createSafeExecuteTx` / EOA‑submit) | -| Bridge deposit | combined SBC `[approve, deposit]` | v1 5‑step Safe batch (`createSafeExecuteTx`) | +| Bridge deposit | combined SBC `[approve, deposit]` | Safe batch `[permit(EPH→vault), deposit]`, with an optional direct EOA→EPH funding prefix | | Destination swap | Calibur SBC | `Safe.execTransaction` | -Routing **pre‑aligns** quotes and recipients to the chosen path: on a `'safe'` chain the -source‑swap quote's `userAddress` **and** `recipientAddress` are the **predicted Safe address** -(`predictSafeAccountAddress(ephemeral).address`); on a `'ephemeral'` chain they are the ephemeral. -Flipping a chain between 7702 and non‑7702 needs no special‑casing at the call sites — the -resolver flips the path and the stages follow. +Routing **pre‑aligns** quote takers to the chosen execution path: on a `'safe'` chain the +source‑swap quote's `userAddress` is the predicted Safe address +(`predictSafeAccountAddress(ephemeral).address`); on an `'ephemeral'` chain it is the ephemeral. +The `recipientAddress` follows custody needs instead: every remote source swap delivers directly +to the ephemeral bridge holder, while a destination‑chain source swap delivers to the EOA when it +is final or to the destination wrapper when another swap follows. Flipping a chain between 7702 +and non‑7702 changes the executor without adding a Safe custody hop before bridging. --- @@ -228,8 +230,8 @@ swap(input = {mode: EXACT_OUT, data:{toChainId:Base, toTokenAddress:WETH, toAmou > **Scenario (illustrative amounts; decisions are exact).** Spend **1 WETH on Arbitrum (42161, > non‑7702 → Safe)**, receive **USDC on Base (8453, 7702)**. COT = USDC. Exercises liquidation, a > destination that **is** COT, and the **Safe path as a first‑class execution path**. Grounded in -> `route.test.ts` (`EXACT_IN liquidates …`, `… routes source-swap recipient on non-7702 chains to -> the predicted Safe address`, `EXACT_IN dst quote spends the full cotAvailable (no source buffer) …`), +> `route.test.ts` (`EXACT_IN liquidates …`, `… routes bridged source-swap output on non-7702 +> chains directly to the ephemeral`, `EXACT_IN dst quote spends the full cotAvailable (no source buffer) …`), > `execution/bridge.test.ts` (Safe deposit batch), `execution/destination-swap.test.ts` (COT > no‑op), and `safe-dispatch.test.ts`. @@ -247,7 +249,8 @@ swap(input = {mode: EXACT_IN, data:{sources:[{Arb, WETH}], toChainId:Base, toTok tryBuildSameTokenBridgeRoute → null # WETH has no currencyId (non-mesh) ⇒ COT flow (§5) provider = resolveBridgeProviderDecision(dstCOT, {Arb WETH USD}) # → 'nexus' (under threshold) (§5) # quote-address resolution — the seam, pre-execution: - quote.userAddress = quote.recipientAddress = predictedSafe(Arb) # because path=safe (else eph) ◄ seam + quote.userAddress = predictedSafe(Arb) # Safe remains the taker/executor ◄ seam + quote.recipientAddress = ephemeral # remote output lands at the bridge holder source = liquidateInputHoldings(holdings) # WETH non-COT → quote WETH→USDC @ Arb (COT skipped) (§6) # no source buffer — a failed source leg re-quotes and proceeds (no drift guard) dstQuoteInput = cotAvailable # full; Seam 2 re-sizes the dst swap to the actual delivered COT (both ways), floor 0 @@ -270,14 +273,13 @@ swap(input = {mode: EXACT_IN, data:{sources:[{Arb, WETH}], toChainId:Base, toTok native → eoaWallet.sendTransaction(execTransaction, value=nativeValue) # refuse single-call value mismatch SafeTx EIP-712-signed by the ephemeral owner - → swap-output COT lands at the Safe (the quote receiver) + → swap-output COT lands directly at the ephemeral (the quote receiver) # ── Step 7: bridge ── (§9) executeSwapBridge(route.bridge, assets): - path(Arb)='safe' → deposit via Safe (createSafeExecuteTx), 3-step batch: - 1 transfer(ephemeral, depositValue) # Safe → ephemeral - 2 permit(ephemeral → vault, depositValue) # EIP-2612 - 3 vault.deposit(…) # no sweep — Seam 1 bridges the Safe's full COT balance + path(Arb)='safe' → deposit via Safe (createSafeExecuteTx), 2-step batch: + 1 permit(ephemeral → vault, depositValue) # EIP-2612 + 2 vault.deposit(…) # no custody transfer — COT is already at ephemeral recipient = EOA # dst 7702 ∧ no dst swap (COT) ⇒ deliver to EOA RFF + waitForFill; has_xcs = true @@ -650,16 +652,16 @@ destination_swap: # only when a dst token OR gas swap exists ( ```text per transfer (reason: source | destination | bridge): - target = the executor that runs the swap: predictedSafe on non-7702, ephemeral on 7702 - (source/bridge: per sourceExecutionPaths; destination: per chainSupports7702(dstChain). - bridge target is the deposit executor; a non-7702 Safe batch then moves Safe→ephemeral - for RFF funding.) + target = the authorized spender/executor: predictedSafe on non-7702, ephemeral on 7702 + (source/bridge: per sourceExecutionPaths; destination: per chainSupports7702(dstChain)) + recipient = bridge ? ephemeral : target + # a non-7702 Safe spends the EOA authorization but sends bridge funding directly to EPH if cachedAllowance(eoa→target) ≥ amount: authorization = null # skip elif permit supported: source | bridge → LAZY {kind:'permit', call:null, signature:null} # materialized at execution destination → EAGER signPermitForAddressAndValue(spender=target) else: authorization = {kind:'approve'} # EOA approve(target), mined before the batch - transferCall = transferFrom(eoa, target, amount) # permit/approve spender == target + transferCall = transferFrom(eoa, recipient, amount) # called by target; bridge recipient may differ # bridge EOA balances converted human → raw # source, destination, and bridge transfer specs are constructed once, then reused for cache # queries and the single prepared-transfer build loop. Their order remains source → destination → bridge. @@ -738,18 +740,19 @@ executeSourceSwaps(source, ctx, meta) -> BridgeAsset[]: await all receipts # only AFTER every chain is dispatched confirmed paid EOA approval → update allowance cache # retry reuses it; never prompts/submits again user rejects permit/approval/transaction → terminal # emit failed and stop; never requote/re-prompt - on chain failure: requote that chain ONCE (EXACT_IN; taker=receiver = that chain's executor — - EOA for the direct-COT dst chain, predictedSafe on non-7702, else ephemeral) + on chain failure: requote that chain ONCE (EXACT_IN; taker remains the chain's executor; + receiver = ephemeral for a remote chain, EOA for a direct-COT dst chain, + or the destination wrapper when another dst swap follows) # EXACT_OUT: require Σ(output drop) ≤ srcBuffer, pooled across that route's source legs # (directDestination EXACT_OUT never reaches this shared retry; its dedicated executor is above) # EXACT_IN: srcBuffer = null → no guard; accept the re-quote and proceed (Seam 2 re-sizes the dst swap) still failing → rethrow # no sweep here — cleanup is the orchestrator's job (§11) - # SEAM 1 (reclaim, when bridge ≠ null): read balanceOf(COT, wrapper) per chain → bridge the ACTUAL + # SEAM 1 (reclaim, when bridge ≠ null): read balanceOf(COT, source holder) per chain → bridge the ACTUAL # landed COT, not the quote floor (captures positive source slippage; best-effort — on a read - # failure fall back to the quote output). wrapper = ephemeral (7702) / predicted-Safe (non-7702). - return assets # ACTUAL wrapper COT balances (Seam 1) + route-resolved COT metadata; meta.src = [{chid, tx_hash}] + # failure fall back to the quote output). Remote source holder = ephemeral on both wallet paths. + return assets # ACTUAL source COT balances (Seam 1) + route-resolved COT metadata; meta.src = [{chid, tx_hash}] -executeSwapBridge(bridge, executedAssets, ctx, meta): # bridges the ACTUAL wrapper COT (Seam 1), not the route estimate +executeSwapBridge(bridge, executedAssets, ctx, meta): # bridges the ACTUAL source COT (Seam 1), not the route estimate bridgedAssets = executedAssets − dstChain − zero-balance, sorted by chainId asc # empty ⇒ return → executeEphemeralBridgePath(bridge, bridgedAssets, ctx, meta): recipient = destinationDirectEoa ? EOA : (dst 7702 ? ephemeralWrapper : predictedSafe) @@ -763,7 +766,7 @@ executeSwapBridge(bridge, executedAssets, ctx, meta): # bridges the ACTUAL wra # ── Mayan (intent.provider == 'mayan') → runMayanEphemeralBridge, then return ── per chain (approve-ONLY — NO deposit, NO sweep): 7702 → SBC [funding?, approve(vault, total)] - non-7702 → Safe [funding?, transfer(eph), permit(eph → vault)] # permit IS the allowance grant + non-7702 → Safe [funding?, permit(eph → vault)] # permit IS the allowance grant submit, then WAIT for it to be MINED # mw sponsors depositMayan() async the moment the # RFF lands, and fails if the allowance isn't on-chain after ALL approves mined: @@ -777,10 +780,11 @@ executeSwapBridge(bridge, executedAssets, ctx, meta): # bridges the ACTUAL wra # 7702: bootstrap Calibur if !hasAuthCodeSet, then EOA execute; non-7702: Safe execTransaction{value} ephemeral → SBC [funding?, approve(vault, total), deposit] # no sweep — Seam 1 bridges the full balance safe → Safe batch (createSafeExecuteTx; token must be EIP-2612 permit): - [funding?] → transfer(eph) → permit(eph → vault) → vault.deposit # no sweep (Seam 1 full) - # funding? = prepared EOA→executor [permit, transferFrom], prepended when eoaBalance>0 (fast - # path / direct-COT — COT still at the EOA); empty when a source swap funded the executor. - # On non-7702 the executor is the Safe, so funds flow EOA→Safe→ephemeral (transfer(eph) above). + [funding?] → permit(eph → vault) → vault.deposit # no sweep (Seam 1 full) + # funding? = prepared [EOA authorization for executor, transferFrom(EOA→recipient)], prepended + # when eoaBalance>0 (fast path / direct-COT — COT still at the EOA); empty when a source + # swap funded the holder. The bridge recipient is EPH on both paths, so a non-7702 Safe + # calls transferFrom(EOA→EPH) without taking custody. eoaBalance>0 but no prepared bridge-transfer → throw ExecutionError{ stepType:'eoa_to_ephemeral_transfer', stepId: createEoaToEphemeralTransferStepId(chainId)} bridge funding retry boundary (Mayan approve + Nexus deposit): @@ -886,13 +890,17 @@ chainIds = reachedDestinationSwap # stage flag: COT moved ? [route.destination.chainId] # otherwise it sits on the source chains that swapped to COT : metadata.src.map(chid) -cleanupStrandedCot({currencyId, chainIds, ctx}): # ONLY on execution failure, and only when currencyId != null +scope = reachedDestinationSwap ? 'destination' : 'source' +cleanupStrandedCot({currencyId, chainIds, scope, ctx}): # ONLY on execution failure, and only when currencyId != null for chainId in chainIds: # no getBalancesForSwap — one known token, one holder, per chain - holder = chainSupports7702 ? ephemeral : predictedSafe + if scope == 'source' ∧ chainId == dstChain ∧ destinationDirectEoa: skip # already at EOA + holder = scope == 'source' ∧ chainId != dstChain ? EPH : WRAPPER(chainId) cot = resolveCOT(chainId, currencyId) bal = isNative(cot) ? getBalance(holder) : cot.balanceOf(holder) # single targeted read - if bal > 0: group{chainId, holder, [transfer(cot, bal → eoa)]} # direct transfer of the read amount - dispatchSweepGroups(groups) # shared w/ init sweep: 7702→SBC, non-7702→Safe execTransaction + if bal > 0: + source ∧ non-7702 → Safe group [permit(EPH→Safe), transferFrom(EPH→EOA)] + otherwise → holder group [transfer(cot, bal→EOA)] + dispatchSweepGroups(groups) # 7702→SBC, non-7702→Safe execTransaction # best-effort; never rethrows / masks the original error. pre-execution failures (deny, routing) never reach here. ``` @@ -1125,7 +1133,7 @@ The gaps from the original audit have been addressed: Mayan bridge **execution** is now exercised end‑to‑end by `tests/swap/characterization/swap.test.ts` (§14): the approve‑only batches (7702 `approve(VAULT)` / -non‑7702 `transfer→permit`, no deposit/sweep), the native `depositMayan{value}` + +non‑7702 `permit`, no deposit/sweep), the native `depositMayan{value}` + `reportMayanNativeTx` path, and the EPH/Safe destination receivers — all decoded from the real emitted calls. That suite mocks the middleware boundary, so a focused `tests/swap/execution/*` unit for `runMayanEphemeralBridge`'s **wait‑for‑mine‑then‑RFF ordering** and the 2‑minute @@ -1161,9 +1169,9 @@ just the token swap — §5). ```text # ── source-swap receiver (buildSourceRecipientAddressByChain) ── -if chain == dstChain ∧ ¬destination_swap: receiver = EOA # COT is the final token here → deliver direct -else: receiver = WRAPPER(chain) # output stays at the wrapper, to be bridged / dst-swapped -# cross-chain legs IGNORE destination_swap — only the same-as-dst leg can short-circuit to the EOA. +if chain != dstChain: receiver = EPH # bridge custody is path-independent +elif ¬destination_swap: receiver = EOA # COT is final → deliver direct +else: receiver = WRAPPER(chain) # keep local COT for the dst swap # Path A (directDestination): EVERY source is on dstChain with no destination_swap, so every leg takes # the receiver = EOA short-circuit — the swap delivers toToken straight to the user, taker = WRAPPER. @@ -1180,13 +1188,13 @@ else: SAFE # non-7702 dst + dst swap # pre-bridge calls below are the fast-path funding+deposit shape (COT/token still at the EOA). # ── pre-bridge calls (per source chain ≠ dstChain; §9) the vault is ALWAYS driven by EPH ── -# funding? = [permit/approve(owner=EOA, spender=WRAPPER), transferFrom(EOA→WRAPPER)] — present only on the -# fast path (COT still at the EOA); EMPTY when a source swap already funded the wrapper. +# funding? = [permit/approve(owner=EOA, spender=WRAPPER), transferFrom(EOA→EPH)] — present only on +# the fast path (COT still at the EOA); EMPTY when a remote source swap funded EPH. NEXUS 7702 → [funding?, approve(EPH→VAULT), vault.deposit] # Seam 1 bridges the full balance → no sweep -NEXUS safe → [funding?, transfer(SAFE→EPH), permit(owner=EPH, spender=VAULT), vault.deposit] # no sweep (Seam 1 full) +NEXUS safe → [funding?, permit(owner=EPH, spender=VAULT), vault.deposit] # Safe executes; EPH holds NEXUS native → EOA payable vault.deposit{value} # no approve/permit/transfer/sweep MAYAN 7702 → [funding?, approve(EPH→VAULT)] # NO deposit / NO sweep — middleware sponsors depositMayan() -MAYAN safe → [funding?, transfer(SAFE→EPH), permit(owner=EPH, spender=VAULT)] # NO deposit / NO sweep +MAYAN safe → [funding?, permit(owner=EPH, spender=VAULT)] # NO deposit / NO sweep MAYAN native → EOA payable vault.depositMayan{value} + reportMayanNativeTx # ordering: Mayan approves are submitted + MINED before the RFF; Nexus deposits run AFTER submitRFF (§9). @@ -1201,9 +1209,9 @@ MAYAN native → EOA payable vault.depositMayan{value} + reportMayanNativeTx Two invariants this view makes explicit (both asserted by the suite): -- **The bridge/vault identity is always EPH**, even on a non‑7702 source — the SAFE is a transient - holder that `transfer(SAFE→EPH)`s, then EPH signs the vault permit. Swap output is tagged - `ephemeralBalance` regardless of the wrapper that produced it (§9). +- **The bridge/vault identity and remote source holder are always EPH**, even on a non‑7702 source. + The Safe remains the swap taker and bridge-call executor, but remote swap output and fast-path + `transferFrom` funding land at EPH directly; the Safe never takes intermediate bridge custody. - **Provider choice is leg‑independent of the receiver** — Nexus vs Mayan only changes the *pre‑bridge* shape (real deposit vs. allowance‑only + sponsored `depositMayan`); the source/bridge/destination *receivers* are identical either way. **Native participates in provider diff --git a/src/swap/types.ts b/src/swap/types.ts index 59423047..44f04564 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -536,9 +536,9 @@ export type PreparedEoaToEphemeralTransfer = { chainId: number; tokenAddress: Hex; amount: bigint; // raw integer units - // The smart-account executor that receives the funds and is the approve/permit spender: - // the predicted Safe on non-7702 chains, the ephemeral on 7702 chains. The transferFrom - // recipient and the authorization spender must both be this address. + // The smart-account executor and approve/permit spender: the predicted Safe on non-7702 chains, + // the ephemeral on 7702 chains. Bridge funding may encode a different transferFrom recipient + // (the ephemeral bridge holder) while retaining this spender. targetAddress: Hex; authorization: PreparedAuthorizationCall | null; transferCall: { to: Hex; data: Hex; value: bigint }; diff --git a/src/swap/wallet/ephemeral-permit.ts b/src/swap/wallet/ephemeral-permit.ts new file mode 100644 index 00000000..88e1044b --- /dev/null +++ b/src/swap/wallet/ephemeral-permit.ts @@ -0,0 +1,93 @@ +import { encodeFunctionData, erc20Abi, type Hex, type PublicClient, parseSignature } from 'viem'; +import type { PrivateKeyAccount } from 'viem/accounts'; +import { ERC20PermitABI } from '../../abi/erc20'; +import type { Chain, ChainListType } from '../../domain'; +import { Errors } from '../../domain/errors'; +import { PermitVariant } from '../../domain/permits'; + +export const buildEphemeralPermitCall = async (input: { + tokenAddress: Hex; + amount: bigint; + spender: Hex; + chain: Chain; + chainList: ChainListType; + ephemeralWallet: PrivateKeyAccount; + publicClient: PublicClient; + deadline: bigint; +}) => { + const token = input.chainList.getTokenByAddress(input.chain.id, input.tokenAddress); + const permitVariant = token?.permitVariant; + if (!permitVariant || permitVariant === PermitVariant.Unsupported) { + throw Errors.tokenNotSupported( + input.tokenAddress, + input.chain.id, + 'permit required for non-7702 bridge custody' + ); + } + if (permitVariant !== PermitVariant.EIP2612Canonical) { + throw Errors.tokenNotSupported(input.tokenAddress, input.chain.id, '(2612 details not found)'); + } + + const [name, nonce] = (await Promise.all([ + input.publicClient.readContract({ + address: input.tokenAddress, + abi: erc20Abi, + functionName: 'name', + }), + input.publicClient.readContract({ + address: input.tokenAddress, + abi: ERC20PermitABI, + functionName: 'nonces', + args: [input.ephemeralWallet.address], + }), + ])) as [string, bigint]; + + const signature = parseSignature( + await input.ephemeralWallet.signTypedData({ + domain: { + chainId: BigInt(input.chain.id), + name, + verifyingContract: input.tokenAddress, + version: (token.permitVersion ?? 1).toString(10), + }, + types: { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + primaryType: 'Permit', + message: { + owner: input.ephemeralWallet.address, + spender: input.spender, + value: input.amount, + nonce, + deadline: input.deadline, + }, + }) + ); + const v = Number( + signature.v ?? (signature.yParity != null ? Number(signature.yParity) + 27 : 27) + ); + + return { + to: input.tokenAddress, + value: 0n, + data: encodeFunctionData({ + abi: ERC20PermitABI, + functionName: 'permit', + args: [ + input.ephemeralWallet.address, + input.spender, + input.amount, + input.deadline, + v, + signature.r, + signature.s, + ], + }), + }; +}; diff --git a/src/swap/wallet/prepared-transfer.ts b/src/swap/wallet/prepared-transfer.ts index 43352c80..9b6c5c9a 100644 --- a/src/swap/wallet/prepared-transfer.ts +++ b/src/swap/wallet/prepared-transfer.ts @@ -16,6 +16,7 @@ export const buildPreparedTransfer = async (input: { amount: bigint; eagerPermit: boolean; targetAddress: Hex; + recipientAddress?: Hex; chainList: ChainListType; eoaAddress: Hex; eoaWallet: WalletClient; @@ -39,6 +40,7 @@ export const buildPreparedTransfer = async (input: { eagerPermit: input.eagerPermit, }) : input.authorization; + const recipientAddress = input.recipientAddress ?? input.targetAddress; return { reason: input.reason, @@ -52,7 +54,7 @@ export const buildPreparedTransfer = async (input: { data: encodeFunctionData({ abi: erc20Abi, functionName: 'transferFrom', - args: [input.eoaAddress, input.targetAddress, input.amount], + args: [input.eoaAddress, recipientAddress, input.amount], }), value: 0n, }, diff --git a/tests/swap/characterization/README.md b/tests/swap/characterization/README.md index 164a5928..074598a1 100644 --- a/tests/swap/characterization/README.md +++ b/tests/swap/characterization/README.md @@ -122,8 +122,9 @@ bridge, dst swap pulls USDT, EXACT_OUT even sizes the gas swap in USDT). and over-delivers the surplus to the EOA, and with a native gas request runs a second pass over the remainder (`toNative`) so ONE batch carries two output tokens — toToken and native gas — both to the EOA (A6). -- The bridge/vault identity is always the **ephemeral**, even on a non-7702 source (Safe → ephemeral - transfer, then ephemeral signs the vault permit). +- The bridge/vault identity and remote source holder are always the **ephemeral**, even on a + non-7702 source. The Safe executes the swap and bridge calls without taking intermediate bridge + custody; remote swap output and direct bridge funding land at the ephemeral. - **Native participates in provider selection** like any token — `forceMayan` routes a native same-token bridge through Mayan (EOA-submitted `depositMayan`); non-forced native with an unverifiable Mayan source downgrades to Nexus `vault.deposit{value}`. diff --git a/tests/swap/characterization/swap.test.ts b/tests/swap/characterization/swap.test.ts index d633baaa..069a22fd 100644 --- a/tests/swap/characterization/swap.test.ts +++ b/tests/swap/characterization/swap.test.ts @@ -210,7 +210,7 @@ describe('swap execution characterization', () => { expect(a[2]).toBe(X); // inputAmount expect(a[3]).toBe(srcOut); // outputAmount eq(EPH)(a[4]); // taker - eq(EPH)(a[5]); // receiver = WRAPPER(chain) + eq(EPH)(a[5]); // receiver = remote bridge holder }, }, ], @@ -218,7 +218,7 @@ describe('swap execution characterization', () => { ); // PRE_BRIDGE_CALLS (Nexus 7702): no funding leg (swap funded EPH); approve→deposit. #86 Seam 1 - // bridges the actual wrapper COT (the full balance), so there is nothing left to sweep here. + // bridges the actual EPH COT (the full balance), so there is nothing left to sweep here. expectCallSequence( bridge, [ @@ -320,7 +320,7 @@ describe('swap execution characterization', () => { const amt = 1000n * 10n ** 6n; // Single bridge batch on ARB (no source swap): fast-path funding (permit+transferFrom EOA→EPH), - // then approve(vault)→deposit. #86 bridges the full wrapper balance → no sweep. + // then approve(vault)→deposit. #86 bridges the full EPH balance → no sweep. const arb = sbcBatchesForChain(middlewareClient, ARB_CHAIN); expect(arb.length, 'ARB SBC batch count').toBe(1); expectCallSequence( @@ -570,7 +570,7 @@ describe('swap execution characterization', () => { const opSafe = safeBatchesForChain(middlewareClient, OP_CHAIN); expect(opSafe.length, 'OP Safe batch count').toBe(2); - // SOURCE_SWAP (Safe): fund EOA→SAFE, approve router, swap → receiver=SAFE (wrapper) + // SOURCE_SWAP (Safe): fund EOA→SAFE, approve router, swap → receiver=EPH (bridge holder) expectCallSequence( opSafe[0], [ @@ -586,19 +586,17 @@ describe('swap execution characterization', () => { expect(a[2]).toBe(X); expect(a[3]).toBe(srcOut); eq(PREDICTED_SAFE)(a[4]); // taker = SAFE - eq(PREDICTED_SAFE)(a[5]); // receiver = SAFE (wrapper) + eq(EPH)(a[5]); // receiver = EPH (bridge holder) }, }, ], 'OP source (safe)' ); - // PRE_BRIDGE_CALLS (Nexus Safe): COT already at SAFE (swap-sourced, no funding leg); - // transfer SAFE→EPH, EPH permits the vault, deposit. #86 bridges the full balance → no sweep. + // PRE_BRIDGE_CALLS (Nexus Safe): COT is already at EPH, so only permit + deposit remain. expectCallSequence( opSafe[1], [ - { fn: 'transfer', to: USDC_OP, argsMatch: (a) => { eq(EPH)(a[0]); expect(a[1]).toBe(srcOut); } }, { fn: 'permit', to: USDC_OP, argsMatch: (a) => { eq(EPH)(a[0]); eq(VAULT_BY_CHAIN[OP_CHAIN])(a[1]); expect(a[2]).toBe(srcOut); } }, { fn: 'deposit', to: VAULT_BY_CHAIN[OP_CHAIN] }, ], @@ -729,7 +727,7 @@ describe('swap execution characterization', () => { eq(EPH)(source[3].args[5]); // receiver = wrapper (cross-chain leg) const usdcOut = source[3].args[3] as bigint; // swap outputAmount (USDC) - // BRIDGE deposit: approve(vault) + deposit + sweep; approve amount == produced COT. + // BRIDGE deposit: approve(vault) + deposit; approve amount == produced COT. expect(bridge.map((c) => c.fn)).toEqual(['approve', 'deposit']); eq(VAULT_BY_CHAIN[ARB_CHAIN])(bridge[0].args[0]); expect(bridge[0].args[1]).toBe(usdcOut); // vault approve == swap output (full produced COT) @@ -1068,9 +1066,9 @@ describe('swap execution characterization', () => { expect(rffRecipient(middlewareClient)).toBe(bytes32Address(EOA)); }); - it('EXACT_IN · Mayan · Safe source swap → COT dst (transfer+permit, no deposit/sweep)', async () => { + it('EXACT_IN · Mayan · Safe source swap → COT dst (permit only, no deposit/sweep)', async () => { // Non-7702 Mayan: the Safe source swap dispatches via execTransaction, and the Mayan deposit - // batch stops at SAFE→EPH transfer + EPH→vault permit (middleware sponsors depositMayan). + // batch is only EPH→vault permit because the swap output already landed at EPH. const balances: FlatBalance[] = [ { amount: '1000', chainID: OP_CHAIN, decimals: 18, symbol: 'DAI', tokenAddress: SOURCE_DAI, value: 1000, name: 'DAI', logo: '' }, ]; @@ -1100,14 +1098,14 @@ describe('swap execution characterization', () => { const op = safeBatchesForChain(middlewareClient, OP_CHAIN); expect(op.length, 'OP Safe batch count').toBe(2); - // [0] source swap (DAI→USDC, receiver=SAFE) + // [0] source swap (DAI→USDC, taker=SAFE, receiver=EPH) expect(op[0].map((c) => c.fn)).toEqual(['permit', 'transferFrom', 'approve', 'swap']); - eq(PREDICTED_SAFE)(op[0][3].args[5]); - // [1] Mayan deposit = transfer SAFE→EPH + EPH→vault permit. NO deposit, NO sweep. - expect(op[1].map((c) => c.fn)).toEqual(['transfer', 'permit']); - eq(EPH)(op[1][0].args[0]); // transfer to EPH - eq(EPH)(op[1][1].args[0]); // permit owner = EPH - eq(VAULT_BY_CHAIN[OP_CHAIN])(op[1][1].args[1]); // permit spender = vault + eq(PREDICTED_SAFE)(op[0][3].args[4]); + eq(EPH)(op[0][3].args[5]); + // [1] Mayan approval = EPH→vault permit. NO transfer, deposit, or sweep. + expect(op[1].map((c) => c.fn)).toEqual(['permit']); + eq(EPH)(op[1][0].args[0]); // permit owner = EPH + eq(VAULT_BY_CHAIN[OP_CHAIN])(op[1][0].args[1]); // permit spender = vault expect(rffRecipient(middlewareClient)).toBe(bytes32Address(EOA)); }); @@ -1358,13 +1356,13 @@ describe('swap execution characterization', () => { ['approve', 'deposit'], ]); eq(EPH)(sbcBatchesForChain(middlewareClient, ARB_CHAIN)[0][3].args[5]); - // OP (non-7702) via Safe: source swap (recv=SAFE) + bridge (transfer→permit→deposit→sweep). + // OP (non-7702) via Safe: source swap (recv=EPH) + bridge (permit→deposit). const opSafe = safeBatchesForChain(middlewareClient, OP_CHAIN); expect(opSafe.map((b) => b.map((c) => c.fn))).toEqual([ ['permit', 'transferFrom', 'approve', 'swap'], - ['transfer', 'permit', 'deposit'], + ['permit', 'deposit'], ]); - eq(PREDICTED_SAFE)(opSafe[0][3].args[5]); + eq(EPH)(opSafe[0][3].args[5]); // 7702 destination swap; bridge recv = EPH. expect(rffRecipient(middlewareClient)).toBe(bytes32Address(EPH)); expect(sbcBatchesForChain(middlewareClient, BASE_CHAIN).length).toBe(1); @@ -1402,14 +1400,14 @@ describe('swap execution characterization', () => { { onIntent: (d: { allow: () => void }) => d.allow() } ); - // 7702 source → SBC swap + Mayan approve(vault). Non-7702 source → Safe swap + Mayan transfer+permit. + // 7702 source → SBC swap + Mayan approve(vault). Non-7702 source → Safe swap + EPH permit. expect(sbcBatchesForChain(middlewareClient, ARB_CHAIN).map((b) => b.map((c) => c.fn))).toEqual([ ['permit', 'transferFrom', 'approve', 'swap'], ['approve'], ]); expect(safeBatchesForChain(middlewareClient, OP_CHAIN).map((b) => b.map((c) => c.fn))).toEqual([ ['permit', 'transferFrom', 'approve', 'swap'], - ['transfer', 'permit'], + ['permit'], ]); expect(rffRecipient(middlewareClient)).toBe(bytes32Address(EOA)); }); @@ -1626,8 +1624,8 @@ describe('swap execution characterization', () => { }); it('EXACT_IN · Nexus · Safe COT-direct fast-path (no source swap) → bridge recv=EOA', async () => { - // COT at the EOA on a non-7702 chain → no source swap, Safe bridge with the fast-path funding leg: - // EOA→Safe (permit+transferFrom), then Safe→EPH transfer + EPH→vault permit + deposit (#86: no sweep). + // COT at the EOA on a non-7702 chain → no source swap. The Safe is the transferFrom spender, + // but sends EOA→EPH directly, followed by EPH→vault permit + deposit. const balances: FlatBalance[] = [ { amount: '1000', chainID: OP_CHAIN, decimals: 6, symbol: 'USDC', tokenAddress: USDC_OP, value: 1000, name: 'USD Coin', logo: '' }, ]; @@ -1658,13 +1656,12 @@ describe('swap execution characterization', () => { const op = safeBatchesForChain(middlewareClient, OP_CHAIN); expect(op.length).toBe(1); // single bridge batch (no source swap) expect(op[0].map((c) => c.fn)).toEqual([ - 'permit', 'transferFrom', 'transfer', 'permit', 'deposit', + 'permit', 'transferFrom', 'permit', 'deposit', ]); - permitOwnerSpender(EOA, PREDICTED_SAFE)(op[0][0].args); // fund EOA→Safe - eq(PREDICTED_SAFE)(op[0][1].args[1]); - eq(EPH)(op[0][2].args[0]); // Safe→EPH transfer - eq(EPH)(op[0][3].args[0]); // EPH permits vault - eq(VAULT_BY_CHAIN[OP_CHAIN])(op[0][3].args[1]); + permitOwnerSpender(EOA, PREDICTED_SAFE)(op[0][0].args); // Safe is transferFrom spender + eq(EPH)(op[0][1].args[1]); // direct EOA→EPH + eq(EPH)(op[0][2].args[0]); // EPH permits vault + eq(VAULT_BY_CHAIN[OP_CHAIN])(op[0][2].args[1]); expect(rffRecipient(middlewareClient)).toBe(bytes32Address(EOA)); }); @@ -1698,10 +1695,10 @@ describe('swap execution characterization', () => { { onIntent: (d: { allow: () => void }) => d.allow() } ); - // OP (Safe): source swap (recv=SAFE) + bridge. + // OP (Safe): source swap (recv=EPH) + bridge. expect(safeBatchesForChain(middlewareClient, OP_CHAIN).map((b) => b.map((c) => c.fn))).toEqual([ ['permit', 'transferFrom', 'approve', 'swap'], - ['transfer', 'permit', 'deposit'], + ['permit', 'deposit'], ]); // BASE (Safe): destination swap (recv=SAFE bridge fill → swap recv=EOA). expect(rffRecipient(middlewareClient)).toBe(bytes32Address(PREDICTED_SAFE)); @@ -1746,7 +1743,7 @@ describe('swap execution characterization', () => { // Safe source swap + bridge. expect(safeBatchesForChain(middlewareClient, OP_CHAIN).map((b) => b.map((c) => c.fn))).toEqual([ ['permit', 'transferFrom', 'approve', 'swap'], - ['transfer', 'permit', 'deposit'], + ['permit', 'deposit'], ]); // 7702 destination swap delivers exactly the requested WETH. expect(rffRecipient(middlewareClient)).toBe(bytes32Address(EPH)); @@ -2483,11 +2480,11 @@ describe('EXACT_OUT coverage expansion', () => { // scenarios induce the one missing thing, executed ≠ planned, and assert the WHOLE chain re-aligns. // // There are TWO independent drift levers (see ai-requote-characterization-plan.md F2): -// • balanceOf (the COT that ACTUALLY lands at the wrapper). Post-#84 the EXACT_IN reclaim bridges +// • balanceOf (the COT that ACTUALLY lands at the source holder). Post-#84 the EXACT_IN reclaim bridges // this, NOT the quote — so it is the lever for the BRIDGE amount, the Nexus destination // re-derivation, and the Mayan refresh / value-match. Realized positive slippage = balanceOf > the // quote's minReceived floor. (The global harness stubs balanceOf=0, which zeroes the reclaim'd -// bridge — F1 — so each scenario self-provides a realistic wrapper balance.) +// bridge — F1 — so each scenario self-provides a realistic source-holder balance.) // • the requote (a failed source dispatch → requoteFailedChains). The re-dispatched calldata carries // the FRESH re-quote (the echo stamps quote.output into the swap). EXACT_IN accepts it // unconditionally — the pooled srcBuffer guard is removed (EXACT_OUT keeps it). diff --git a/tests/swap/execution/bridge.test.ts b/tests/swap/execution/bridge.test.ts index 991ca45e..6ffd6441 100644 --- a/tests/swap/execution/bridge.test.ts +++ b/tests/swap/execution/bridge.test.ts @@ -1222,13 +1222,9 @@ describe('executeSwapBridge', () => { expect(intent?.recipientAddress.toLowerCase()).toBe(safeAddress.toLowerCase()); }); - it('builds the 3-step deposit batch (no Sweeper) on non-7702 source chains', async () => { - // Seam 1 bridges the actual wrapper balance, so transfer(ephemeral, depositValue) moves the - // FULL Safe COT and the deposit drains it — nothing residual stays at the Safe. The old v1 - // steps 4-5 (approve(Sweeper) + Sweeper.sweepERC20) are gone; the batch is just: - // 1. transfer(ephemeral, depositValue) — Safe sends the deposit amount to ephemeral - // 2. permit(ephemeral → vault) — ephemeral grants vault allowance via EIP-2612 - // 3. vault.deposit(...) — vault.transferFrom(ephemeral, vault, depositValue) + it('builds a 2-step permit and deposit batch on non-7702 source chains', async () => { + // The source swap now delivers COT directly to the ephemeral bridge holder. The Safe only + // submits the ephemeral's permit and the vault deposit; no Safe→ephemeral transfer or Sweeper. const baseCtx = makeCtx(); const createSafeExecuteTx = vi.fn().mockResolvedValue({ txHash: '0xsafe_deposit_tx' as Hex, @@ -1298,35 +1294,26 @@ describe('executeSwapBridge', () => { const vaultAddress = (ctx.chainList.getVaultContractAddress(ARB_CHAIN)) as Hex; const ephemeral = ctx.ephemeralWallet.address; - // Step 1: Safe → ephemeral transfer - const transferCall = decodeFunctionData({ abi: erc20Abi, data: callsArg[0].data }); - expect(transferCall.functionName).toBe('transfer'); - expect((transferCall.args?.[0] as string).toLowerCase()).toBe(ephemeral.toLowerCase()); - expect(transferCall.args?.[1]).toBe(depositValue); - expect(callsArg[0].to.toLowerCase()).toBe(USDC_ARB.toLowerCase()); - - // Step 2: permit(ephemeral, vault, depositValue, ...) - const permitCall = decodeFunctionData({ abi: ERC20PermitABI, data: callsArg[1].data }); + // Step 1: permit(ephemeral, vault, depositValue, ...) + const permitCall = decodeFunctionData({ abi: ERC20PermitABI, data: callsArg[0].data }); expect(permitCall.functionName).toBe('permit'); expect((permitCall.args?.[0] as string).toLowerCase()).toBe(ephemeral.toLowerCase()); expect((permitCall.args?.[1] as string).toLowerCase()).toBe(vaultAddress.toLowerCase()); expect(permitCall.args?.[2]).toBe(depositValue); - // Step 3: vault.deposit - const depositCall = decodeFunctionData({ abi: EVMVaultABI, data: callsArg[2].data }); + // Step 2: vault.deposit + const depositCall = decodeFunctionData({ abi: EVMVaultABI, data: callsArg[1].data }); expect(depositCall.functionName).toBe('deposit'); - // No Sweeper steps — the deposit drained the Safe, so there's nothing to sweep. - expect(callsArg).toHaveLength(3); + expect(callsArg).toHaveLength(2); for (const call of callsArg) { expect(call.to.toLowerCase()).not.toBe((SWEEPER_ADDRESS as string).toLowerCase()); } }); - it('funds the Safe (permit + transferFrom EOA->Safe) before the deposit on a non-7702 fast-path bridge', async () => { - // Fast-path bridge: no source swap funded the Safe, so the bridged COT sits at the EOA - // (eoaBalance > 0). The non-7702 deposit batch must consume the prepared EOA->Safe funding - // (permit + transferFrom) before transfer(Safe->ephemeral), else the Safe is empty -> GS013. + it('transfers EOA-held bridge funding directly to the ephemeral on a non-7702 fast path', async () => { + // The Safe remains the authorized transferFrom spender, but sends EOA-held COT directly to the + // ephemeral bridge holder before the ephemeral permit and vault deposit. const baseCtx = makeCtx(); const safeAddress = predictSafeAccountAddress(baseCtx.ephemeralWallet.address).address; const createSafeExecuteTx = vi.fn().mockResolvedValue({ txHash: '0xsafe_deposit_tx' as Hex }); @@ -1365,7 +1352,7 @@ describe('executeSwapBridge', () => { data: encodeFunctionData({ abi: erc20Abi, functionName: 'transferFrom', - args: [baseCtx.eoaAddress, safeAddress, 3000000n], + args: [baseCtx.eoaAddress, baseCtx.ephemeralWallet.address, 3000000n], }), value: 0n, }, @@ -1417,27 +1404,27 @@ describe('executeSwapBridge', () => { expect(createSafeExecuteTxFromCalls).toHaveBeenCalledTimes(1); const callsArg = vi.mocked(createSafeExecuteTxFromCalls).mock.calls[0]?.[0]?.calls ?? []; - // Funding runs first: permit(EOA -> Safe) then transferFrom(EOA -> Safe) … + // Funding runs first: permit spender = Safe, transferFrom recipient = ephemeral. const permitCall = decodeFunctionData({ abi: ERC20PermitABI, data: callsArg[0].data }); expect(permitCall.functionName).toBe('permit'); expect((permitCall.args?.[1] as string).toLowerCase()).toBe(safeAddress.toLowerCase()); const fundingTransfer = decodeFunctionData({ abi: erc20Abi, data: callsArg[1].data }); expect(fundingTransfer.functionName).toBe('transferFrom'); - expect((fundingTransfer.args?.[1] as string).toLowerCase()).toBe(safeAddress.toLowerCase()); - - // … then the deposit batch's Safe -> ephemeral transfer. - const transferCall = decodeFunctionData({ abi: erc20Abi, data: callsArg[2].data }); - expect(transferCall.functionName).toBe('transfer'); - expect((transferCall.args?.[0] as string).toLowerCase()).toBe( + expect((fundingTransfer.args?.[1] as string).toLowerCase()).toBe( baseCtx.ephemeralWallet.address.toLowerCase() ); + expect(decodeFunctionData({ abi: ERC20PermitABI, data: callsArg[2].data }).functionName).toBe( + 'permit' + ); + expect(decodeFunctionData({ abi: EVMVaultABI, data: callsArg[3].data }).functionName).toBe( + 'deposit' + ); + expect(callsArg).toHaveLength(4); }); - it('moves COT Safe→ephemeral + permits the vault for a non-7702 Mayan source (no deposit, no SBC approve)', async () => { - // On a non-7702 source chain the source-swap COT sits on the Safe, but the Mayan deposit pulls - // from the ephemeral. So the "approval" must be a Safe.execTransaction batch of - // transfer(Safe→ephemeral) + permit(ephemeral→vault) — NOT a Calibur SBC approve — and the - // depositMayan itself stays sponsored by the middleware (no deposit call in this batch). + it('permits the vault for a non-7702 Mayan source without a custody transfer', async () => { + // The source-swap COT already sits at the ephemeral. The Safe submits only the + // permit(ephemeral→vault); depositMayan remains middleware-sponsored. const baseCtx = makeCtx(); const createSafeExecuteTx = vi.fn().mockResolvedValue({ txHash: '0xsafe_mayan_approve' as Hex }); const safeReadContract = vi.fn().mockImplementation((args: { functionName: string }) => { @@ -1504,7 +1491,7 @@ describe('executeSwapBridge', () => { contractAddress: USDC_ARB, decimals: 6, eoaBalance: new Decimal(0), - ephemeralBalance: new Decimal('3'), // source-swap COT, on the Safe + ephemeralBalance: new Decimal('3'), // source-swap COT, at the ephemeral }; const metadata: SwapMetadata = { src: [], dst: null, has_xcs: false, intent_request_hash: null }; @@ -1518,18 +1505,12 @@ describe('executeSwapBridge', () => { const ephemeral = ctx.ephemeralWallet.address; const vaultAddress = ctx.chainList.getVaultContractAddress(ARB_CHAIN) as Hex; - // Step 1: Safe → ephemeral transfer of the COT. - const transferCall = decodeFunctionData({ abi: erc20Abi, data: calls[0].data }); - expect(transferCall.functionName).toBe('transfer'); - expect((transferCall.args?.[0] as string).toLowerCase()).toBe(ephemeral.toLowerCase()); - expect(transferCall.args?.[1]).toBe(depositValue); - - // Step 2: permit(ephemeral → vault) granting the deposit allowance. - const permitCall = decodeFunctionData({ abi: ERC20PermitABI, data: calls[1].data }); + const permitCall = decodeFunctionData({ abi: ERC20PermitABI, data: calls[0].data }); expect(permitCall.functionName).toBe('permit'); expect((permitCall.args?.[0] as string).toLowerCase()).toBe(ephemeral.toLowerCase()); expect((permitCall.args?.[1] as string).toLowerCase()).toBe(vaultAddress.toLowerCase()); expect(permitCall.args?.[2]).toBe(depositValue); + expect(calls).toHaveLength(1); // No vault.deposit in the approve batch — Mayan's depositMayan is sponsored separately. const hasDeposit = calls.some((c) => { @@ -1545,10 +1526,9 @@ describe('executeSwapBridge', () => { expect(waitForFill).toHaveBeenCalledTimes(1); }); - it('funds the Safe (permit + transferFrom EOA->Safe) before Safe->ephemeral on a non-7702 Mayan fast-path source', async () => { - // Mayan fast path: no source swap, so the COT is at the EOA (eoaBalance > 0), not the Safe. - // The Safe approve batch (transfer(Safe->ephemeral) + permit(ephemeral->vault)) must be preceded - // by the prepared EOA->Safe funding — the sponsored depositMayan then pulls from the ephemeral. + it('transfers EOA-held Mayan bridge funding directly to the ephemeral on non-7702', async () => { + // The Safe is the transferFrom spender, while the ephemeral receives the COT and permits the + // vault for the middleware-sponsored depositMayan. const baseCtx = makeCtx(); const safeAddress = predictSafeAccountAddress(baseCtx.ephemeralWallet.address).address; const createSafeExecuteTx = vi.fn().mockResolvedValue({ txHash: '0xsafe_mayan_approve' as Hex }); @@ -1587,7 +1567,7 @@ describe('executeSwapBridge', () => { data: encodeFunctionData({ abi: erc20Abi, functionName: 'transferFrom', - args: [baseCtx.eoaAddress, safeAddress, 3000000n], + args: [baseCtx.eoaAddress, baseCtx.ephemeralWallet.address, 3000000n], }), value: 0n, }, @@ -1647,20 +1627,19 @@ describe('executeSwapBridge', () => { expect(createSafeExecuteTxFromCalls).toHaveBeenCalledTimes(1); const calls = vi.mocked(createSafeExecuteTxFromCalls).mock.calls[0]?.[0]?.calls ?? []; - // Funding first: permit(EOA -> Safe), transferFrom(EOA -> Safe) … + // Funding first: permit spender = Safe, transferFrom recipient = ephemeral. const permitCall = decodeFunctionData({ abi: ERC20PermitABI, data: calls[0].data }); expect(permitCall.functionName).toBe('permit'); expect((permitCall.args?.[1] as string).toLowerCase()).toBe(safeAddress.toLowerCase()); const fundingTransfer = decodeFunctionData({ abi: erc20Abi, data: calls[1].data }); expect(fundingTransfer.functionName).toBe('transferFrom'); - expect((fundingTransfer.args?.[1] as string).toLowerCase()).toBe(safeAddress.toLowerCase()); - - // … then the Mayan approve batch's Safe -> ephemeral transfer. - const transferCall = decodeFunctionData({ abi: erc20Abi, data: calls[2].data }); - expect(transferCall.functionName).toBe('transfer'); - expect((transferCall.args?.[0] as string).toLowerCase()).toBe( + expect((fundingTransfer.args?.[1] as string).toLowerCase()).toBe( baseCtx.ephemeralWallet.address.toLowerCase() ); + expect(decodeFunctionData({ abi: ERC20PermitABI, data: calls[2].data }).functionName).toBe( + 'permit' + ); + expect(calls).toHaveLength(3); }); it('deposits a native source via EOA-submitted Calibur execute carrying value, skipping approve and funding (7702)', async () => { @@ -1876,9 +1855,9 @@ describe('executeSwapBridge', () => { expect(waitForFill).toHaveBeenCalledTimes(1); }); - it('deposits a native source via dispatchSafeSource carrying value, skipping the ERC-20 5-step batch (non-7702)', async () => { + it('deposits a native source via dispatchSafeSource carrying value, skipping the ERC-20 batch (non-7702)', async () => { // Phase 1b non-7702: native deposit is a single payable Safe.execTransaction{value} dispatched - // by the EOA (dispatchSafeSource), not the sponsor 5-step transfer/permit/deposit/sweep batch. + // by the EOA (dispatchSafeSource), not the sponsored ERC-20 permit/deposit batch. const NATIVE = '0x0000000000000000000000000000000000000000' as Hex; const depositValue = 1_000_000_000_000_000_000n; vi.mocked(createRequestFromIntent).mockResolvedValueOnce({ @@ -1956,7 +1935,7 @@ describe('executeSwapBridge', () => { (ctx.chainList.getVaultContractAddress(ARB_CHAIN) as string).toLowerCase() ); expect(decodeFunctionData({ abi: EVMVaultABI, data: dispatchArg!.calls[0].data }).functionName).toBe('deposit'); - // No sponsor 5-step ERC-20 batch and no relayed SBC deposit. + // No sponsored ERC-20 batch and no relayed SBC deposit. expect(createSafeExecuteTxFromCalls).not.toHaveBeenCalled(); expect(createSBCTxFromCalls).not.toHaveBeenCalled(); expect(waitForFill).toHaveBeenCalledTimes(1); diff --git a/tests/swap/execution/failure-cleanup.test.ts b/tests/swap/execution/failure-cleanup.test.ts index 82ac24b5..2291176a 100644 --- a/tests/swap/execution/failure-cleanup.test.ts +++ b/tests/swap/execution/failure-cleanup.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Hex } from 'viem'; +import { decodeFunctionData, erc20Abi, type Hex } from 'viem'; +import { ERC20PermitABI } from '../../../src/abi/erc20'; import { CurrencyID } from '../../../src/swap/cot'; +import { predictSafeAccountAddress } from '../../../src/swap/safe/predict'; vi.mock('../../../src/swap/wallet/capabilities', () => ({ chainSupports7702: (chain: { id: number }) => chain.id === 42161, @@ -18,11 +20,20 @@ import { import { dispatchSweepGroups } from '../../../src/services/init-refund-sweep'; const ARB = 42161; // 7702 → ephemeral holder +const OP = 10; // non-7702 const USDC = '0xaf88d065e77c8cc2239327c5edb3a432268e5831' as Hex; const EPH = '0xbbbb000000000000000000000000000000000002' as Hex; const EOA = '0xaaaa000000000000000000000000000000000001' as Hex; -const makeCtx = (balance: bigint) => +const makeCtx = ( + balance: bigint, + readContract = vi.fn().mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'balanceOf') return balance; + if (functionName === 'name') return 'USD Coin'; + return 0n; + }), + destination = { chainId: 8453, directEoa: false } +) => ({ cache: undefined, chainList: { @@ -32,13 +43,27 @@ const makeCtx = (balance: bigint) => decimals: 6, currencyId: CurrencyID.USDC, }), + getTokenByAddress: () => ({ + contractAddress: USDC, + decimals: 6, + currencyId: CurrencyID.USDC, + permitVariant: 1, + permitVersion: 2, + }), }, eoaAddress: EOA, - ephemeralWallet: { address: EPH }, + destinationChainId: destination.chainId, + destinationDirectEoa: destination.directEoa, + ephemeralWallet: { + address: EPH, + signTypedData: vi + .fn() + .mockResolvedValue(`0x${'11'.repeat(32)}${'22'.repeat(32)}1b` as Hex), + }, middlewareClient: {}, publicClientList: { get: () => ({ - readContract: vi.fn().mockResolvedValue(balance), + readContract, getBalance: vi.fn().mockResolvedValue(balance), }), }, @@ -51,6 +76,7 @@ describe('cleanupStrandedCot', () => { await cleanupStrandedCot({ currencyId: CurrencyID.USDC, chainIds: [ARB], + scope: 'source', ctx: makeCtx(5_000_000n), }); @@ -62,10 +88,85 @@ describe('cleanupStrandedCot', () => { expect(groups[0]!.calls[0]!.to).toBe(USDC); // ERC-20 transfer call targets the COT token }); + it('reads non-7702 source settlement at the ephemeral bridge holder', async () => { + const readContract = vi.fn().mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'balanceOf') return 5_000_000n; + if (functionName === 'name') return 'USD Coin'; + return 0n; + }); + + await cleanupStrandedCot({ + currencyId: CurrencyID.USDC, + chainIds: [OP], + scope: 'source', + ctx: makeCtx(5_000_000n, readContract), + }); + + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: 'balanceOf', args: [EPH] }) + ); + const groups = vi.mocked(dispatchSweepGroups).mock.calls[0]![0]; + expect(groups[0]!.holder).toBe('safe'); + expect(groups[0]!.calls).toHaveLength(2); + expect( + decodeFunctionData({ abi: ERC20PermitABI, data: groups[0]!.calls[0]!.data }).functionName + ).toBe('permit'); + const transferFrom = decodeFunctionData({ + abi: erc20Abi, + data: groups[0]!.calls[1]!.data, + }); + expect(transferFrom.functionName).toBe('transferFrom'); + expect((transferFrom.args?.[0] as Hex).toLowerCase()).toBe(EPH.toLowerCase()); + expect((transferFrom.args?.[1] as Hex).toLowerCase()).toBe(EOA.toLowerCase()); + expect(transferFrom.args?.[2]).toBe(5_000_000n); + }); + + it('reads destination-chain source settlement at its Safe when a destination swap follows', async () => { + const readContract = vi.fn().mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'balanceOf') return 5_000_000n; + return 0n; + }); + const ctx = makeCtx(5_000_000n, readContract, { chainId: OP, directEoa: false }); + + await cleanupStrandedCot({ + currencyId: CurrencyID.USDC, + chainIds: [OP], + scope: 'source', + ctx, + }); + + const safeAddress = predictSafeAccountAddress(EPH).address; + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: 'balanceOf', args: [safeAddress] }) + ); + const groups = vi.mocked(dispatchSweepGroups).mock.calls[0]![0]; + expect(groups[0]!.holder).toBe('safe'); + expect(groups[0]!.calls).toHaveLength(1); + expect( + decodeFunctionData({ abi: erc20Abi, data: groups[0]!.calls[0]!.data }).functionName + ).toBe('transfer'); + }); + + it('does not inspect destination-chain source output that was delivered directly to the EOA', async () => { + const readContract = vi.fn().mockResolvedValue(5_000_000n); + const ctx = makeCtx(5_000_000n, readContract, { chainId: OP, directEoa: true }); + + await cleanupStrandedCot({ + currencyId: CurrencyID.USDC, + chainIds: [OP], + scope: 'source', + ctx, + }); + + expect(readContract).not.toHaveBeenCalled(); + expect(vi.mocked(dispatchSweepGroups).mock.calls[0]![0]).toHaveLength(0); + }); + it('skips a chain whose COT balance is zero', async () => { await cleanupStrandedCot({ currencyId: CurrencyID.USDC, chainIds: [ARB], + scope: 'source', ctx: makeCtx(0n), }); diff --git a/tests/swap/execution/orchestrator.test.ts b/tests/swap/execution/orchestrator.test.ts index ab256eae..41f321a4 100644 --- a/tests/swap/execution/orchestrator.test.ts +++ b/tests/swap/execution/orchestrator.test.ts @@ -54,6 +54,7 @@ describe('executeSwapRoute destination cleanup', () => { expect(cleanupStrandedCot).toHaveBeenCalledWith({ currencyId: CurrencyID.USDC, chainIds: [8453], + scope: 'destination', ctx: context, }); }); diff --git a/tests/swap/execution/source-swaps.test.ts b/tests/swap/execution/source-swaps.test.ts index 4ca92a75..513c1d85 100644 --- a/tests/swap/execution/source-swaps.test.ts +++ b/tests/swap/execution/source-swaps.test.ts @@ -382,9 +382,9 @@ describe('executeSourceSwaps', () => { return readContract; }; - it('EXACT_IN reclaim: bridges the actual wrapper COT balance, not the quote floor', async () => { + it('EXACT_IN reclaim: bridges the actual source-holder COT balance, not the quote floor', async () => { const ctx = makeCtx('ephemeral'); - const readContract = withBalanceReadClient(ctx, 3015000000n); // 3015 USDC at wrapper (floor 3000) + const readContract = withBalanceReadClient(ctx, 3015000000n); // 3015 USDC at EPH (floor 3000) const source = { swaps: [makeQuoteResponse()], creationTime: Date.now(), @@ -417,12 +417,13 @@ describe('executeSourceSwaps', () => { expect(assets[0].ephemeralBalance).toEqual(new Decimal('3000')); // quote floor }); - it('EXACT_IN reclaim: reads the COT balance at the predicted Safe on non-7702 chains', async () => { + it('EXACT_IN reclaim: reads bridged COT at the ephemeral on non-7702 source chains', async () => { const SAFE = '0x2d7E4C3ef02B86D271624742C6e81636f4c9e663' as Hex; // predictSafe(0xbbbb...0002) vi.mocked(dispatchSafeSource).mockResolvedValue({ txHash: '0xsafe_src' as Hex, safeAddress: SAFE }); const readContract = vi.fn().mockResolvedValue(3010000000n); const ctx: SrcCtx = { ...makeCtx('ephemeral'), + destinationChainId: 8453, sourceExecutionPaths: new Map([[ARB_CHAIN, 'safe']]), chainList: { getChainByID: vi @@ -451,7 +452,11 @@ describe('executeSourceSwaps', () => { const assets = await executeSourceSwaps(source, ctx, metadata); expect(readContract).toHaveBeenCalledWith( - expect.objectContaining({ address: USDC_ARB, functionName: 'balanceOf', args: [SAFE] }) + expect.objectContaining({ + address: USDC_ARB, + functionName: 'balanceOf', + args: ['0xbbbb000000000000000000000000000000000002'], + }) ); expect(assets[0].ephemeralBalance).toEqual(new Decimal('3010')); }); diff --git a/tests/swap/prepare.test.ts b/tests/swap/prepare.test.ts index 87ca5a52..9c52f385 100644 --- a/tests/swap/prepare.test.ts +++ b/tests/swap/prepare.test.ts @@ -550,10 +550,9 @@ describe('prepareSwapExecution', () => { expect(bridgeTransfer?.amount).toBe(5000000n); }); - it('targets the predicted Safe for the bridge funding transfer on non-7702 source chains', async () => { - // Fast-path bridge (no source swap) on a non-7702 chain: the Safe deposit batch pulls the COT - // from the Safe, so the EOA's COT must move EOA->Safe (Safe = permit spender + transferFrom - // recipient). Targeting the ephemeral leaves the Safe empty and the deposit reverts (GS013). + it('authorizes the Safe but transfers bridge funding directly from the EOA to the ephemeral', async () => { + // Fast-path bridge (no source swap) on a non-7702 chain: the Safe executes transferFrom, so it + // remains the permit spender, while the COT recipient is the ephemeral bridge holder. const route = makeRoute(); route.source = { swaps: [], creationTime: Date.now(), srcBuffer: new Decimal(0) }; route.destination = { @@ -611,7 +610,7 @@ describe('prepareSwapExecution', () => { expect(bridgeTransfer!.targetAddress.toLowerCase()).toBe(expectedSafe.toLowerCase()); const transferCall = decodeFunctionData({ abi: ERC20ABI, data: bridgeTransfer!.transferCall.data }); expect(transferCall.functionName).toBe('transferFrom'); - expect((transferCall.args?.[1] as Hex).toLowerCase()).toBe(expectedSafe.toLowerCase()); + expect((transferCall.args?.[1] as Hex).toLowerCase()).toBe(EPH.toLowerCase()); }); it('does not build an eoa->ephemeral transfer for a native bridge asset (paid inline by the EOA)', async () => { diff --git a/tests/swap/route.test.ts b/tests/swap/route.test.ts index ce1bec39..642a55c4 100644 --- a/tests/swap/route.test.ts +++ b/tests/swap/route.test.ts @@ -4219,10 +4219,10 @@ describe('determineSwapRoute', () => { expect(holding.amountRaw).toBe(999999999999999999999n); }); - it('routes source-swap recipient on non-7702 chains to the predicted Safe address', async () => { + it('routes bridged source-swap output on non-7702 chains directly to the ephemeral', async () => { // EXACT_IN cross-chain: WETH on ARB (treated non-7702 via 'safe' hint) → USDC on BASE. - // Source-swap recipient on ARB must equal the predicted Safe address (matches v1's - // "taker == receiver = Safe" on non-Pectra source chains). + // The Safe remains the aggregator taker/executor, but the COT output goes straight to the + // ephemeral bridge holder so the deposit path does not need a Safe→ephemeral transfer. const input: SwapData = { mode: SwapMode.EXACT_IN, data: { @@ -4257,7 +4257,7 @@ describe('determineSwapRoute', () => { const safeAddress = predictSafeAccountAddress(EPHEMERAL_EXECUTOR).address; expect(liquidateInputHoldings).toHaveBeenCalledOnce(); const callArgs = vi.mocked(liquidateInputHoldings).mock.calls[0][0]; - expect(callArgs.recipientAddressByChain.get(ARB_CHAIN)).toBe(safeAddress); + expect(callArgs.recipientAddressByChain.get(ARB_CHAIN)).toBe(EPHEMERAL_EXECUTOR); expect(callArgs.userAddressByChain.get(ARB_CHAIN)).toBe(safeAddress); });