Documentation

Integration recipes

viem and Solidity patterns for quoting, buying with a native APE leg, selling with the correct path direction, multi-hop, and routing your users' trades.

On this page

Everything here uses the real function and parameter names. Assume tradeManager, WAPE and the ABIs come from your application's address and ABI modules.

ContractAddressApeScan
ArmoryTradeManager0x2cea…Dd9F
ArmoryAdapter0xfF7D…c06d
CamelotAdapter0xe3C1…2497
ArmoryTradeCashBackReceiver0x9B10…4a03

Preflight: is this venue routable, and what does it cost?

Do this in one multicall. feeBpsFor is read live, always.

venueState.ts
import { createPublicClient, type Address } from "viem";
 
import { ZERO_ADDRESS } from "./addresses";
import { TRADE_MANAGER_ABI } from "./abis/tradeManager";
 
export async function venueState(
  client: ReturnType<typeof createPublicClient>,
  opts: { tradeManager: Address; dexId: number },
) {
  const base = { address: opts.tradeManager, abi: TRADE_MANAGER_ABI } as const;
  const [adapter, paused, feeBps, maxBps] = await client.multicall({
    allowFailure: false,
    contracts: [
      { ...base, functionName: "adapters", args: [opts.dexId] },
      { ...base, functionName: "dexPaused", args: [opts.dexId] },
      { ...base, functionName: "feeBpsFor", args: [opts.dexId] },
      { ...base, functionName: "MAX_BPS" },
    ],
  });
 
  return {
    routable: adapter !== ZERO_ADDRESS && !paused,
    adapter,
    paused,
    feeBps: BigInt(feeBps),
    maxBps: BigInt(maxBps),
  };
}

Read feeBpsFor live before you quote

feeBpsFor(dexId) is the only authority on what a trade costs. It is multisig-settable per venue and can change between one block and the next. Read it in the same batch you quote with. Never hardcode a rate and never copy one out of a local fallback table.

Then quote by simulating the real trade with a zero floor:

quoteBuy.ts
import { ZERO_ADDRESS } from "./addresses";
 
const deadlineIn = (seconds: number) => BigInt(Math.floor(Date.now() / 1000) + seconds);
 
export async function quoteBuy(client: any, opts: {
  tradeManager: Address;
  token: Address;
  route: Route;
  sizeWei: bigint; // the exact msg.value you will send
  account: Address;
}) {
  const { result } = await client.simulateContract({
    address: opts.tradeManager,
    abi: TRADE_MANAGER_ABI,
    functionName: "buy",
    args: [opts.token, opts.route, 0n, deadlineIn(600), ZERO_ADDRESS, "0x"],
    value: opts.sizeWei,
    account: opts.account,
  });
  return result as bigint; // tokenOut, net of the platform fee
}

Slippage is applied to the simulated output, not to a mark, because the simulation already contains the fee and the price impact:

const minOut = (simulatedOut * (maxBps - BigInt(slippageBps))) / maxBps;

Fee ordering, if you compute a quote yourself instead of simulating

A buy is quoted on size - fee, because the fee comes off msg.value before the venue sees it. A sell is quoted as grossOut - fee, because the whole token amount is routed and the fee comes off the APE that came back. The two orderings are not interchangeable.

Buy with a native APE leg

buy.ts
import { encodePacked, type Address, type Hex } from "viem";
 
import { WAPE, ZERO_ADDRESS } from "./addresses";
 
// Armory V3 (dexId 1), one hop, WAPE -> token. `poolFeeTier` is the POOL's
// uint24 tier, from FEE_TIERS. It is not the platform fee.
const v3Path = encodePacked(["address", "uint24", "address"], [WAPE, poolFeeTier, token]);
 
const route = {
  dex: 1,
  v3Path,
  v2Path: [] as Address[],
  fee: 0, // reserved
  tickSpacing: 0, // reserved
  hooks: ZERO_ADDRESS,
} as const;
 
const hash = await walletClient.writeContract({
  address: tradeManager,
  abi: TRADE_MANAGER_ABI,
  functionName: "buy",
  args: [token, route, minAmountOut, deadlineIn(600), ZERO_ADDRESS, "0x"],
  value: sizeWei,
});

The buyer is always msg.sender, and there is no recipient argument. Tokens arrive directly from the adapter. Any unconsumed APE comes back and is emitted as EthRefunded, but you were charged the fee on the whole msg.value, so send the exact size.

On a V2 venue the same trade is:

const route = {
  dex: 0,
  v3Path: "0x" as Hex,
  v2Path: [WAPE, token],
  fee: 0,
  tickSpacing: 0,
  hooks: ZERO_ADDRESS,
};

Sell with the correct path direction

Two steps: approve the manager (not a router, not an adapter), then sell with a token-to-WAPE path.

sell.ts
await walletClient.writeContract({
  address: token,
  abi: ERC20_ABI,
  functionName: "approve",
  args: [tradeManager, 2n ** 255n],
});
 
// dexId 1. Note the reversal. This is the line that breaks integrations.
const v3Path = encodePacked(["address", "uint24", "address"], [token, poolFeeTier, WAPE]);
 
const route = { dex: 1, v3Path, v2Path: [] as Address[], fee: 0, tickSpacing: 0, hooks: ZERO_ADDRESS };
 
const hash = await walletClient.writeContract({
  address: tradeManager,
  abi: TRADE_MANAGER_ABI,
  functionName: "sell",
  args: [token, amountIn, route, minEthOut, deadlineIn(600), ZERO_ADDRESS, "0x"],
});

minEthOut is checked against what you receive, post-fee. The APE is sent with a raw call, so a contract seller needs a working payable receiver.

Never reuse a buy route for a sell

The adapter validates first == token, last == WAPE on a sell. A WAPE-first path reverts with TokenMismatch before any router is touched. Derive both directions from one canonical WAPE-first market definition and reverse it. Do not maintain two path builders. See Path encoding.

A multi-hop route

Multi-hop is legal on every venue kind. The only hard constraint is that every hop lives on the same venue, because one trade is handed to exactly one adapter.

// Armory V3, WAPE -> connector -> token. 66 bytes. poolFeeA and poolFeeB are
// pool tiers, one per hop.
const buyPath = encodePacked(
  ["address", "uint24", "address", "uint24", "address"],
  [WAPE, poolFeeA, connector, poolFeeB, token],
);
 
// The sell is the same market read backwards: tokens AND pool tiers reversed.
const sellPath = encodePacked(
  ["address", "uint24", "address", "uint24", "address"],
  [token, poolFeeB, connector, poolFeeA, WAPE],
);
// Camelot V3 (Algebra, dexId 3). No fee bytes at any hop. 60 bytes.
const buyPath = encodePacked(["address", "address", "address"], [WAPE, connector, token]);
const sellPath = encodePacked(["address", "address", "address"], [token, connector, WAPE]);
// Camelot V2 (dexId 2).
const buyRoute = { dex: 2, v3Path: "0x", v2Path: [WAPE, connector, token], fee: 0, tickSpacing: 0, hooks: ZERO_ADDRESS };
const sellRoute = { dex: 2, v3Path: "0x", v2Path: [token, connector, WAPE], fee: 0, tickSpacing: 0, hooks: ZERO_ADDRESS };

SwapPaths checks only the ends and the byte shape, so a wrong connector or a wrong intermediate pool tier passes validation and reverts inside the router. Always simulate the whole path before signing, and prove both legs exist (a factory getPair or getPool read) before you offer the route.

Solidity: routing your users' trades through the manager

TradeForwarder.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 {ReentrancyGuard} from "@openzeppelin/contracts-v5/utils/ReentrancyGuard.sol";
import {Route} from "./ITradeAdapter.sol";
 
interface IArmoryTradeManager {
    function feeBpsFor(uint8 dexId) external view returns (uint16);
    function adapters(uint8 dexId) external view returns (address);
    function dexPaused(uint8 dexId) external view returns (bool);
 
    function buy(
        address token,
        Route calldata route,
        uint256 minAmountOut,
        uint256 deadline,
        address ref,
        bytes calldata refData
    ) external payable returns (uint256 tokenOut);
 
    function sell(
        address token,
        uint256 amountIn,
        Route calldata route,
        uint256 minEthOut,
        uint256 deadline,
        address ref,
        bytes calldata refData
    ) external returns (uint256 ethToUser);
}
 
/**
 * Routes a caller's trade through ArmoryTradeManager and forwards the proceeds.
 *
 * Two facts drive the whole design:
 *   - The manager pays the OUTPUT to its msg.sender. That is this contract, so
 *     this contract must forward, and must be payable.
 *   - The manager pulls the INPUT from its msg.sender, so this contract holds the
 *     tokens and grants the allowance. Its users approve THIS contract.
 */
contract TradeForwarder is ReentrancyGuard {
    using SafeERC20 for IERC20;
 
    IArmoryTradeManager public immutable tradeManager;
 
    error NotRoutable(uint8 dexId);
    error EthTransferFailed();
 
    constructor(address tradeManager_) {
        tradeManager = IArmoryTradeManager(tradeManager_);
    }
 
    /// APE arrives from the manager: sell proceeds, and buy refunds.
    receive() external payable {}
 
    function buyFor(address token, Route calldata route, uint256 minAmountOut, uint256 deadline)
        external
        payable
        nonReentrant
        returns (uint256 tokenOut)
    {
        _requireRoutable(route.dex);
 
        uint256 balanceBefore = address(this).balance - msg.value;
 
        // The fee is taken off msg.value inside the manager. Do not pre-deduct it.
        tokenOut = tradeManager.buy{value: msg.value}(token, route, minAmountOut, deadline, address(0), "");
 
        // Tokens were delivered here, because the manager pays its msg.sender.
        IERC20(token).safeTransfer(msg.sender, tokenOut);
 
        // Anything the venue did not consume was refunded to us as EthRefunded.
        uint256 refund = address(this).balance - balanceBefore;
        if (refund != 0) _payEth(msg.sender, refund);
    }
 
    function sellFor(address token, uint256 amountIn, Route calldata route, uint256 minEthOut, uint256 deadline)
        external
        nonReentrant
        returns (uint256 ethToUser)
    {
        _requireRoutable(route.dex);
 
        // Balance-delta, so a fee-on-transfer token cannot desync the amount we
        // then approve and sell. The same reason the manager does it.
        uint256 before = IERC20(token).balanceOf(address(this));
        IERC20(token).safeTransferFrom(msg.sender, address(this), amountIn);
        uint256 received = IERC20(token).balanceOf(address(this)) - before;
 
        // Exact-amount approval to the manager. Users approve THIS contract; this
        // contract approves the manager.
        IERC20(token).forceApprove(address(tradeManager), 0);
        IERC20(token).forceApprove(address(tradeManager), received);
 
        // minEthOut is enforced by the manager on the POST-FEE amount.
        ethToUser = tradeManager.sell(token, received, route, minEthOut, deadline, address(0), "");
 
        IERC20(token).forceApprove(address(tradeManager), 0);
        _payEth(msg.sender, ethToUser);
    }
 
    /// Cheap, and it turns two very different failures into one clear revert.
    function _requireRoutable(uint8 dexId) private view {
        if (tradeManager.adapters(dexId) == address(0) || tradeManager.dexPaused(dexId)) {
            revert NotRoutable(dexId);
        }
    }
 
    function _payEth(address to, uint256 amount) private {
        (bool ok,) = to.call{value: amount}("");
        if (!ok) revert EthTransferFailed();
    }
}

Points worth restating, because each one is a real failure mode:

  • You must be payable. Both the sell payout and the buy refund arrive as native APE at your contract, not at your user.
  • Your users approve you, and you approve the manager. There is no permit path and no recipient argument to shortcut this.
  • nonReentrant here is yours, not the manager's. The manager's guard protects the manager. It does not stop a malicious token's callback from re-entering your forwarder.
  • Pass ref = address(0) and refData = "0x" when you are not using the referral hook. See Hooks.
  • Do not pre-deduct the platform fee from the value you forward. The manager takes it from msg.value.

Indexing fills

TradeExecuted is the one event to key on. user, token and isBuy are indexed topics. The venue, both amounts, the total fee, the referrer and the referral share are in the data. Remember that ethAmount is gross on both sides: msg.value including any refund on a buy, and pre-fee APE on a sell. tokenAmount on a sell is what actually arrived, not the requested amountIn.

Full field semantics and every revert you can hit are in the TradeManager reference.