Documentation

Writing an adapter

ITradeAdapter as a build target, the custody contract, the two shipped reference implementations, and a skeleton to copy.

On this page

An adapter is one venue behind one contract. It is the extension point: adding a venue to the aggregator is one adapter deploy plus one multisig addAdapter transaction. No new manager, no user re-approvals, no change to Route.

The interface

ITradeAdapter.sol
interface ITradeAdapter {
    function buy(
        address token,
        Route calldata route,
        uint256 minAmountOut,
        uint256 deadline,
        address recipient
    ) external payable returns (uint256 tokenOut);
 
    function sell(
        address token,
        uint256 amountIn,
        Route calldata route,
        uint256 deadline
    ) external returns (uint256 ethOut);
}

Two methods. That is the whole build target.

buy: when it is called and what it must do

The manager calls buy after it has taken the platform fee off msg.value, so the msg.value your adapter sees is the net amount to route.

ParameterMeaning
tokenthe token being bought, and the route's far leg. Validate that the route resolves to it
routethe caller's tuple. Interpret route.dex to pick your venue's shape if you serve several dexIds
minAmountOuta venue-level floor. Pass it through. The manager re-checks the return value anyway
deadlineforward to the router
recipientthe trader, which is the manager's msg.sender

Requirements:

  • Deliver tokens directly to recipient. Never stage them in the adapter and never route them through the manager.
  • Return tokenOut, the tokens delivered. When your router does not return an amount, measure it as a balance delta on recipient. That is also the only way to price a fee-on-transfer token correctly.
  • Send every wei of APE you did not consume back to the manager before returning. The manager treats its own balance delta as the buyer's refund.
  • Revert if the output is zero.

sell: when it is called and what it must do

The manager transfers the tokens to your adapter before calling sell. There is no transferFrom for you to do and no allowance for you to hold.

ParameterMeaning
tokenthe token being sold
amountInwhat the manager transferred, pre-tax, for reference only
routethe caller's tuple
deadlineforward to the router

Requirements:

  • Trade your entire current balance of token, not amountIn. With a fee-on-transfer token the amount that survives the manager-to-adapter hop is less than amountIn, and the balance is authoritative.
  • Use a venue-level floor of zero. The user-facing slippage floor is enforced by the manager on the post-fee APE. A non-zero floor here would double-count.
  • Send the APE back to the manager before returning. ethOut is informational, because the manager measures its own balance delta.
  • Revert if there is nothing to sell, or if the swap produced nothing.

Validate the sell path as token → WAPE

Your buy branch validates (path, WAPE, token). Your sell branch validates (path, token, WAPE). The arguments are not the same and they are not interchangeable, and getting them backwards is what makes an integration accept a buy path on a sell and revert deep inside a router instead of at the boundary. Both shipped adapters pin the direction on every venue kind. See Path encoding.

The custody contract

This is what makes the manager's approvals permanent, so it is not optional:

  • Adapters never see user allowances. Users approve the manager. Your adapter is funded by the manager, per trade.
  • Adapters hold no funds between transactions. Everything in, everything out, same transaction.
  • Approvals are exact-amount, zero-first, and cleared afterwards. TradeAdapterBase provides _approveExactZeroFirst and _clearApproval for this. No venue is ever approved for more than the current trade, and no allowance outlives the transaction.
  • All addresses are immutable. An adapter has no owner and no admin surface, so that "a dexId's behavior can never change" is true all the way down. Changing venue plumbing means a new adapter under a new dexId.
  • onlyManager on both entry points. Direct calls revert with OnlyManager().
  • A restrictive receive(). Accept APE only from the addresses that can legitimately send it: your WAPE contract when unwrapping, and any router that pays out natively. Everything else reverts with UnexpectedEth().

Reentrancy and gas

  • ArmoryTradeManager.buy and .sell are nonReentrant. Your adapter executes inside that guard, so an adapter that calls back into buy or sell gets ReentrancyGuardReentrantCall. Do not try.
  • The manager's receive() is deliberately state-free. The reentrancy risk it would otherwise create is closed by the guard on the trading functions.
  • Send APE to the manager with a plain call{value: …}("") and forward all gas. Do not use transfer or send. The manager's receive() does an SLOAD on isAdapter and would not fit a 2300-gas stipend.
  • The manager's receive() allowlist is keyed on msg.sender. If your adapter routes APE back through a helper contract, the transfer reverts with UnexpectedEth(). The registered adapter address must be the sender.
  • Both trading paths do several balanceOf reads for their deltas. Budget for them. The aggregator hop is not free relative to calling a router directly.

Registration

function addAdapter(uint8 dexId, address adapter) external onlyOwner;

Multisig-only. It sets adapters[dexId] = adapter, sets isAdapter[adapter] = true (which is what lets the adapter send APE back), and emits AdapterAdded(dexId, adapter).

It is append-only. A bound dexId reverts with AdapterExists(dexId). There is no unbind and no re-point. The same adapter contract may be bound to several dexIds, which is how both shipped adapters serve two venues each.

Choosing your dexId is therefore a one-way decision. Note also that the adapter, not the registry, decides how a dexId behaves. ArmoryAdapter treats dexId 0 as V2 and everything else as V3. CamelotAdapter treats dexId 2 as Camelot V2 and everything else as Algebra. If you serve multiple dexIds, be explicit about which branch an unrecognised id falls into.

Reference implementation: ArmoryAdapter (dexIds 0 and 1)

The constructor takes (manager, wape, v2Router, v3SwapRouter), rejects any zero, and grants standing max WAPE approvals to both routers:

IERC20(wape_).forceApprove(v2Router_, type(uint256).max);
IERC20(wape_).forceApprove(v3SwapRouter_, type(uint256).max);

That is safe because the routers only ever pull WAPE the adapter wrapped inside the same transaction. It is also the reason for the WAPE guard on sells, below.

receive() accepts APE only from the WAPE contract, which is the sole source, since APE only arrives here from unwrapping.

buy. Wrap first, then branch:

ArmoryAdapter.buy
wape.deposit{value: msg.value}();
if (route.dex == DEX_V2) {
    SwapPaths.validateV2Path(route.v2Path, address(wape), token);
    uint256[] memory amounts =
        v2Router.swapExactTokensForTokens(msg.value, minAmountOut, route.v2Path, recipient, deadline);
    tokenOut = amounts[amounts.length - 1];
} else {
    SwapPaths.validateV3Path(route.v3Path, address(wape), token);
    tokenOut = v3SwapRouter.exactInput(IArmoryV3SwapRouter.ExactInputParams({
        path: route.v3Path,
        recipient: recipient,
        deadline: deadline,
        amountIn: msg.value,
        amountOutMinimum: minAmountOut
    }));
}

Note the validation arguments: (path, WAPE, token), so WAPE is first and the traded token is last. Both routers return an amount and deliver straight to recipient, so no balance delta is needed here. There is no APE hand-back on a buy because everything sent was wrapped and spent.

Armory V2 buys use the non-fee-on-transfer router entrypoint

swapExactTokensForTokens is not the SupportingFeeOnTransfer variant. Buying a taxed token on dexId 0 will revert inside the router. Route taxed tokens through a venue whose adapter uses a supporting entrypoint.

sell. Guard, then measure, then branch:

ArmoryAdapter.sell
if (token == address(wape)) revert SwapPaths.TokenMismatch();
uint256 amountIn = IERC20(token).balanceOf(address(this));
if (amountIn == 0) revert NoSwapOutput();

The WAPE guard is the interesting line. A circular WAPE → … → WAPE sell would run _approveExactZeroFirst on WAPE against a router, clobbering the constructor's standing max approval, after which every buy on that router bricks permanently. Any adapter that grants a standing approval needs the equivalent guard.

Then, per branch: validate (path, token, WAPE) with token first, approve exactly, swap to address(this) with a zero floor, clear the approval, unwrap, and hand the APE to the manager.

wape.withdraw(wapeOut);
ethOut = wapeOut;
if (ethOut == 0) revert NoSwapOutput();
_sendEthToManager(ethOut);

Deltas in CamelotAdapter (dexIds 2 and 3)

Same base, same custody contract, four differences that matter if you are writing against a similar venue.

1. The V2 router is not a stock Uniswap V2 router. Only SupportingFeeOnTransfer variants exist, every one of them takes a referrer parameter, and none of them return anything.

function swapExactETHForTokensSupportingFeeOnTransferTokens(
    uint256 amountOutMin, address[] calldata path, address to, address referrer, uint256 deadline
) external payable;
 
function swapExactTokensForETHSupportingFeeOnTransferTokens(
    uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to,
    address referrer, uint256 deadline
) external;

2. Camelot V2 uses native-APE entrypoints, so there is no wrap. Buys send msg.value straight to the router, and sells receive native APE directly. Consequently receive() allowlists two senders, not one:

receive() external payable {
    if (msg.sender != address(wape) && msg.sender != address(camelotV2Router)) revert UnexpectedEth();
}

3. Output is measured by balance delta, because the router returns nothing. On a buy the delta is taken on recipient, which also prices a fee-on-transfer tax correctly:

uint256 balanceBefore = IERC20(token).balanceOf(recipient);
camelotV2Router.swapExactETHForTokensSupportingFeeOnTransferTokens{value: msg.value}(
    minAmountOut, route.v2Path, recipient, camelotReferrer, deadline
);
tokenOut = IERC20(token).balanceOf(recipient) - balanceBefore;

On a sell the delta is taken on address(this).balance around the router call.

4. The V3 branch validates an Algebra path, not a Uniswap path. This is the only structural difference between the two adapters' V3 handling:

// ArmoryAdapter, dexId 1
SwapPaths.validateV3Path(route.v3Path, address(wape), token);   // 20 + 23N bytes
 
// CamelotAdapter, dexId 3
SwapPaths.validateAlgebraPath(route.v3Path, address(wape), token); // 20N bytes, no fee bytes

The ExactInputParams struct is otherwise field-identical to Uniswap's, and the wrap and unwrap flow around it is the same. Only one standing max approval is granted in the constructor, to the Algebra router. The Camelot V2 router is approved per-trade, exact-amount, because it pulls the token side.

camelotReferrer is Camelot's own order-flow referral hook. It is fixed at deploy and immutable, and address(0) opts out. It is passed on every Camelot V2 swap and is unrelated to the aggregator's own ref parameter.

Skeleton

MyVenueAdapter.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
 
import {IERC20} from "@openzeppelin/contracts-v5/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts-v5/token/ERC20/utils/SafeERC20.sol";
import {ITradeAdapter, Route} from "../interfaces/ITradeAdapter.sol";
import {TradeAdapterBase} from "./TradeAdapterBase.sol";
import {SwapPaths} from "../libraries/SwapPaths.sol";
 
interface IWAPE {
    function deposit() external payable;
    function withdraw(uint256) external;
}
 
interface IMyRouter {
    function swapExactIn(bytes calldata path, address to, uint256 amountIn, uint256 minOut, uint256 deadline)
        external
        returns (uint256 amountOut);
}
 
contract MyVenueAdapter is TradeAdapterBase {
    using SafeERC20 for IERC20;
 
    IWAPE public immutable wape;
    IMyRouter public immutable router;
 
    constructor(address manager_, address wape_, address router_) TradeAdapterBase(manager_) {
        if (wape_ == address(0) || router_ == address(0)) revert ZeroAddress();
        wape = IWAPE(wape_);
        router = IMyRouter(router_);
        // Only if your router pulls WAPE you wrapped in the same tx.
        IERC20(wape_).forceApprove(router_, type(uint256).max);
    }
 
    /// Allowlist exactly the addresses that can legitimately send APE here.
    receive() external payable {
        if (msg.sender != address(wape)) revert UnexpectedEth();
    }
 
    function buy(address token, Route calldata route, uint256 minAmountOut, uint256 deadline, address recipient)
        external
        payable
        onlyManager
        returns (uint256 tokenOut)
    {
        // WAPE is `first`, the traded token is `last`. Pick the validator that
        // matches your venue's path shape.
        SwapPaths.validateV3Path(route.v3Path, address(wape), token);
 
        wape.deposit{value: msg.value}();
 
        // If your router does not return an amount, delta on `recipient` instead.
        tokenOut = router.swapExactIn(route.v3Path, recipient, msg.value, minAmountOut, deadline);
        if (tokenOut == 0) revert NoSwapOutput();
 
        // Hand back anything the venue did not consume. The manager forwards it
        // to the buyer as EthRefunded.
        uint256 leftover = address(this).balance;
        if (leftover != 0) _sendEthToManager(leftover);
    }
 
    function sell(address token, uint256, Route calldata route, uint256 deadline)
        external
        onlyManager
        returns (uint256 ethOut)
    {
        // Required whenever the constructor granted a standing WAPE approval.
        if (token == address(wape)) revert SwapPaths.TokenMismatch();
 
        // The BALANCE is authoritative, not the manager's `amountIn`.
        uint256 amountIn = IERC20(token).balanceOf(address(this));
        if (amountIn == 0) revert NoSwapOutput();
 
        // Token is `first`, WAPE is `last`. This is the sell direction rule.
        SwapPaths.validateV3Path(route.v3Path, token, address(wape));
 
        _approveExactZeroFirst(IERC20(token), address(router), amountIn);
        // Venue floor is 0. The manager enforces the user's floor post-fee.
        uint256 wapeOut = router.swapExactIn(route.v3Path, address(this), amountIn, 0, deadline);
        _clearApproval(IERC20(token), address(router));
 
        wape.withdraw(wapeOut);
        ethOut = wapeOut;
        if (ethOut == 0) revert NoSwapOutput();
        _sendEthToManager(ethOut);
    }
}

TradeAdapterBase gives you manager (immutable), the onlyManager modifier, _sendEthToManager, _approveExactZeroFirst, _clearApproval, and the errors OnlyManager, ZeroAddress, NoSwapOutput, EthTransferFailed and UnexpectedEth.

An adapter sweeps its whole token balance on a sell

balanceOf(address(this)) is deliberate, because it is how fee-on-transfer is handled correctly, but it means any token dust stranded on the adapter is swept into the next sale of that token. Adapters are designed to hold nothing between transactions. Never send tokens to an adapter directly.

Deployed adapters

ContractAddressApeScan
ArmoryTradeManager0x2cea…Dd9F
ArmoryAdapter0xfF7D…c06d
CamelotAdapter0xe3C1…2497