Skip to main content

RelaySwaps contract

RelaySwaps is the withdraw-and-swap processor and the on-chain half of the protocol's intents integration: intent-based routing through a DEX aggregator (LiFi in the shipped integration, abstracted so another provider can be used). Like PrivacyPoolRelay it calls PoolVault.transact and receives the public output, but instead of paying a recipient it hands the withdrawn value to the aggregator with calldata the user committed in their proof. That one shape covers withdraw-and-swap to any token, reshield (swap and redeposit in the same transaction), and cross-chain delivery through a bridge. It lives in packages/swaps, has its own deploy script, and is not part of the core deployment. In production it is deployed on Ethereum mainnet and BNB Chain but not on Citrea, which is not a LiFi network.

What it does

execute pulls the withdrawal from the pool, pays the relayer's fee in the input token, then forwards the remainder to an allowlisted swap router with the exact swapCalldata bound in the proof. The aggregator (LiFi in the shipped integration) performs every post-swap step: a direct transfer to the recipient, a contractCalls deposit back into the Entrypoint, or a bridge hop. RelaySwaps never parses that calldata; it cannot, for a generic aggregator. What stands in for parsing is two guarantees enforced on-chain:

  • Allowlisted targets. Both swapTarget (the router receiving the call) and approvalAddress (the address receiving the scoped ERC-20 approval, which for LiFi v4 is a separate ERC20Proxy) must be in the owner-curated approvedRouters set, else RelaySwaps_InvalidSwapTarget / RelaySwaps_InvalidApprovalAddress.
  • Proof-bound routing. The whole SwapRouting struct, calldata included, is ABI-encoded into transactParams.data, which the pool checks against the proof's context. A relayer that alters the route, the recipient, the slippage floor, or the fee after proving cannot land the transaction.

executeUnwrapped(..., zap) is the second entry point, for spending a yield-share note. It shares the same body; the only difference is that after the pool pays the withdrawn ppUSDC, the contract redeems the shares to their underlying through an allowlisted PPYieldTokenZap before the swap runs. Passing zap == address(0) is the plain path.

Order of operations

  1. Read tokenIdOut and amountOut from the proof; reject a zero withdrawal.
  2. Decode SwapRouting from transactParams.data (already bound in the proof's context).
  3. Require non-zero swapTarget, approvalAddress, and dustRecipient, and that the first two are allowlisted.
  4. Require msg.value == nativeGas. If recipient is the zero address (reshield or cross-chain, where output is delivered elsewhere), nativeGas and minOutputAmount must both be zero. If recipient is set, minOutputAmount must be positive, because a zero floor would disable the output check and let the calldata route output anywhere.
  5. Require feeAmount <= Entrypoint.assets(token).maxRelayFee (a configured 0 means no cap) and feeAmount <= amountOut.
  6. Snapshot ETH, input-token, and output-token balances, and the recipient's output-token balance. For executeUnwrapped, require the zap is in approvedZaps and zap.VAULT() == tokenIdOut.
  7. Call PoolVault.transact; the pool pays amountOut to this contract.
  8. For executeUnwrapped, redeem the shares through the zap so the rest of the flow runs on the underlying; the fee, committed in shares, is converted alongside.
  9. Pay feeAmount to feeRecipient (RelaySwaps_ZeroFeeRecipient if unset while a fee is due).
  10. If swapValue > 0 (the B2 path for a bridge that needs native messaging value), run gasSwapCalldata against the same allowlisted router with an approval capped at gasSwapMaxInput (itself capped at amountOut - feeAmount), and require the contract's ETH rose by at least swapValue. Only valid on the ERC-20 input branch.
  11. Execute the main swap: a native-input note forwards the value with the call; an ERC-20 note approves approvalAddress for the net amount, calls, then resets the approval to zero.
  12. If a recipient was named, require its output-token balance rose by at least minOutputAmount, then forward nativeGas to it.
  13. Sweep dust to dustRecipient using balance deltas against the step-6 snapshots, so concurrent executions cannot drain each other and pre-existing balances are never touched.

SwapRouting

The struct the proof commits to. Every field is fixed at proving time.

swapTarget, approvalAddress The allowlisted aggregator entry point and the address that receives the scoped ERC-20 approval.
outputToken, recipient, fallbackAddress What the swap produces and where it lands. recipient == 0 marks a flow whose output is delivered by the aggregator (reshield deposit or cross-chain).
feeRecipient, feeAmount The relayer's fee, in the input token (in shares for a yield note, converted at unwrap).
minOutputAmount, nativeGas, dustRecipient The recipient-side output floor, the ETH gas top-up forwarded to the recipient, and where residue goes.
swapValue, gasSwapMaxInput, gasSwapCalldata The B2 gas-swap leg: mint native messaging value for a bridge in-contract instead of asking the user to front source-chain ETH. swapValue == 0 skips the leg entirely.
swapCalldata The full aggregator calldata, executed verbatim.
dustRescue A DustRescue blob (chainId, tokenId, isCanonical, spendingPubKey, viewingPubKey) that RelaySwaps never reads or validates. It rides in the struct purely to inherit its proof binding, so an off-chain dust-rescue service can deliver a cross-chain reshield's leftover to a stealth address the user committed to.

ROUTING_TYPEHASH() returns the EIP-712 type hash of this struct. It exists so the relayer and SDK can assert at boot that their encoding of SwapRouting matches the contract's layout byte for byte (RelaySwapsLayoutMismatch on the SDK side), and it is generated from the compiler's own AST rather than hand-maintained.

Owner functions

The owner is the only privileged role, and its surface is the two allowlists plus recovery.

approveRouter(address) / removeRouter(address) Maintain approvedRouters. The constructor seeds one router.
approveZap(address) / removeZap(address) Maintain approvedZaps. approveZap cross-checks the zap against its wrapper: zap.A_TOKEN() must be the wrapper's asset(), zap.UNDERLYING() must be that aToken's underlying and must be a deployed non-native token, and the share must be an enabled pool asset on the Entrypoint. approvedZapList() returns the whole set so a relayer can derive its share-to-zap map on-chain at boot.
rescue(address _token, address _to, uint256 _amount) Moves ETH or tokens stranded outside an execute window, for example an asynchronous bridge refund. execute is atomic and nonReentrant, so rescue cannot interleave with a live swap.
changeOwnership(address) Transfers ownership.
renounceOwnership() Always reverts (RelaySwaps_RenounceDisabled). Renouncing would freeze both allowlists with no way to add a router or zap again.

receive() accepts ETH from anyone: the pool on a native withdrawal, the aggregator on a refund or residue, and bridges on asynchronous refunds. Pre-existing ETH is never swept by execute (delta-only baseline); it is recovered with rescue.

Errors you'll see

RelaySwaps_InvalidSwapTarget() / RelaySwaps_InvalidApprovalAddress() The quote named a router or approval address the owner has not allowlisted. The SDK checks approvedRouters before proving, so seeing this on-chain means the allowlist changed in between.
RelaySwaps_SwapFailed() The aggregator call reverted or returned false. The whole transaction reverts and the pool's state is unchanged, so the note is not spent; re-quote and retry.
RelaySwaps_InsufficientOutput() The recipient received less than minOutputAmount. Slippage moved past the floor between quote and execution.
RelaySwaps_ZeroMinOutputWithRecipient() A recipient was named but the output floor was zero. The SDK never produces this; it guards against a hand-built routing.
RelaySwaps_NativeGasWithoutRecipient() / RelaySwaps_MinOutputWithoutRecipient() A reshield or cross-chain routing (zero recipient) carried a gas top-up or an output floor, neither of which has anywhere to apply.
RelaySwaps_FeeExceedsMax() / RelaySwaps_FeeExceedsWithdrawal() The committed fee is above the asset's maxRelayFee or above the withdrawn amount.
RelaySwaps_GasSwapNativeInput() / RelaySwaps_GasSwapMaxInputExceedsWithdrawal() / RelaySwaps_GasSwapInsufficient() / RelaySwaps_InputBalanceDeficit() B2 gas-swap guards: the leg was requested on a native-input note, its approval cap exceeded the net proceeds, it minted less ETH than swapValue, or the two swaps together consumed more input than this withdrawal supplied.
RelaySwaps_InvalidZap() / RelaySwaps_ZapShareMismatch() / RelaySwaps_UnwrapZeroOutput() executeUnwrapped guards: the zap is not allowlisted, its VAULT() is not the withdrawn share token, or the redeem produced no underlying.
RelaySwaps_ZapATokenMismatch() / RelaySwaps_ZapUnderlyingMismatch() / RelaySwaps_ZapShareNotPoolAsset() / RelaySwaps_ZapInvalidUnderlying() approveZap cross-checks failed; the owner tried to allowlist a zap that does not match its wrapper or whose share is not an enabled pool asset.

Trust model

The owner curates which aggregators may receive funds and which zaps may unwrap shares; a compromised owner could allowlist a malicious router, but could not change any routing a user has already proven, since that is fixed in the proof. Users trust the aggregator (LiFi) to execute the committed calldata honestly and the bridge, when one is involved, to deliver on the destination chain. The relayer that submits the transaction is trusted for liveness only: it cannot alter the route, redirect output, or take more than the committed fee.

Source: v2-monorepo/packages/swaps/contracts/src/RelaySwaps.sol