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
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.
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 onrecipient. 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.
Requirements:
- Trade your entire current balance of
token, notamountIn. With a fee-on-transfer token the amount that survives the manager-to-adapter hop is less thanamountIn, 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.
ethOutis 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.
TradeAdapterBaseprovides_approveExactZeroFirstand_clearApprovalfor 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.
onlyManageron both entry points. Direct calls revert withOnlyManager().- 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 withUnexpectedEth().
Reentrancy and gas
ArmoryTradeManager.buyand.sellarenonReentrant. Your adapter executes inside that guard, so an adapter that calls back intobuyorsellgetsReentrancyGuardReentrantCall. 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 usetransferorsend. The manager'sreceive()does anSLOADonisAdapterand would not fit a 2300-gas stipend. - The manager's
receive()allowlist is keyed onmsg.sender. If your adapter routes APE back through a helper contract, the transfer reverts withUnexpectedEth(). The registered adapter address must be the sender. - Both trading paths do several
balanceOfreads 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:
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:
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 bytesThe 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
// 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.