Documentation

Path encoding

SwapPaths byte layouts per venue kind, the WAPE anchor, the buy and sell direction rule, and a TypeScript encoder.

On this page

SwapPaths is the path-validation library used by every adapter before it touches a router. It is small, and knowing exactly what it does and does not check is the difference between a route that fills and a route that reverts somewhere you cannot see.

The direction rule

Buys encode WAPE → token. Sells encode token → WAPE.

This is the single most common way an integration breaks. A sell path encoded WAPE-first reverts with TokenMismatch inside the adapter, before any router is called. It is not symmetric, it is not auto-detected, and there is no flag for it.

buy   path[0] == WAPE   path[last] == token
sell  path[0] == token  path[last] == WAPE

Both shipped adapters enforce this on every venue kind. It applies identically to v2Path (an address[]) and to v3Path (packed bytes). A two-hop path is reversed whole, connector and pool fee tiers included.

What SwapPaths validates

Three validators, one per venue kind. All three do exactly two things: check the byte or array shape, and pin the first and last token.

SwapPaths.sol
function validateV2Path(address[] calldata path, address first, address last) internal pure {
    if (path.length < 2) revert BadPath();
    if (path[0] != first || path[path.length - 1] != last) revert TokenMismatch();
}
 
function validateV3Path(bytes calldata path, address first, address last) internal pure {
    // 20-byte token + N * (3-byte pool fee + 20-byte token), N >= 1
    if (path.length < V3_ADDR_SIZE + V3_HOP_SIZE || (path.length - V3_ADDR_SIZE) % V3_HOP_SIZE != 0) {
        revert BadPath();
    }
    if (firstToken(path) != first || lastToken(path) != last) revert TokenMismatch();
}
 
function validateAlgebraPath(bytes calldata path, address first, address last) internal pure {
    if (path.length < 2 * V3_ADDR_SIZE || path.length % V3_ADDR_SIZE != 0) revert BadPath();
    if (firstToken(path) != first || lastToken(path) != last) revert TokenMismatch();
}

V3_ADDR_SIZE is 20 and V3_HOP_SIZE is 23. firstToken reads bytes [0:20], and lastToken reads the trailing 20 bytes.

Only the first and last hop are checked

Nothing validates the middle of a path. A multi-hop route whose intermediate token or intermediate pool fee tier names a pool that does not exist passes every check the adapter makes and reverts inside the router, with the router's error rather than the aggregator's. Simulate the whole trade before you sign it.

The WAPE anchor

The APE-side platform fee is only correct if WAPE is the entry leg of a buy and the exit leg of a sell, and downstream indexing keys on token being the far leg. So every path is anchored. One end is WAPE, the other is the token argument you passed to buy or sell.

Consequences an integrator must plan around:

  • There is no token-to-token route through the aggregator. If neither side is native APE, the manager is the wrong contract.
  • WAPE itself cannot be the traded token. Both adapters reject token == address(wape) on a sell with TokenMismatch, explicitly to stop a circular WAPE → … → WAPE path from clobbering the standing max WAPE approval their constructors set up. WAPE-for-APE is an unwrap and belongs at the WAPE contract.
  • WAPE must never appear as a connector in the middle of a path. WAPEWAPE → token is a degenerate hop with no purpose, and the off-chain router filters WAPE out of the connector set.

Pool fee tiers are not the platform fee

The 3-byte field inside a Uniswap-encoded path is the pool's fee tier: a big-endian uint24 in hundredths of a bip, the same unit the V3 factory uses. It selects which pool the hop lands in. It has nothing to do with the platform fee the trade manager takes on the APE side, which is not encoded in a path at all and is read from feeBpsFor(dexId).

The enabled tiers are:

Pool fee tier (uint24)As 3 path bytesTick spacing
10000x0003e820
30000x000bb860
100000x002710200
250000x0061a8500

There is no 500 tier, and 25000 is not part of the Uniswap default set, so a tier copied from another deployment can encode a pool that does not exist here. Read feeAmountTickSpacing(fee) when accepting an arbitrary tier.

Byte layout: Armory V3 (dexId 1), Uniswap-encoded

20-byte token || (3-byte pool fee || 20-byte token) * N, tightly packed, with N >= 1. Valid lengths are 20 + 23N: 43 bytes for one hop, 66 for two, 89 for three.

Worked example, with 0x1111 standing in for WAPE, 0x2222 for a connector and 0x3333 for the traded token. These are illustrations, not deployed addresses.

One hop, buy, pool tier 3000 (0x000bb8), 43 bytes:

0x 1111111111111111111111111111111111111111  WAPE           (20)
   000bb8                                    pool fee 3000  (3)
   3333333333333333333333333333333333333333  token          (20)
"0x1111111111111111111111111111111111111111000bb83333333333333333333333333333333333333333"

The same hop as a sell. The whole thing is reversed, still 43 bytes:

"0x3333333333333333333333333333333333333333000bb81111111111111111111111111111111111111111"

Two hops, buy, pool tiers 3000 then 10000 (0x002710), 66 bytes, with the fee bytes at offsets 20 and 43:

"0x1111111111111111111111111111111111111111000bb822222222222222222222222222222222222222220027103333333333333333333333333333333333333333"

Byte layout: Camelot V3 (dexId 3), Algebra V1.9

Algebra paths carry no fee bytes

Camelot V3 is Algebra V1.9, which has one pool per pair and a dynamic fee. There is no tier to name at any hop, so an Algebra ExactInput path is packed 20-byte addresses and nothing else.

Handing a Uniswap-shaped path (with fee bytes) to dexId 3 reverts with BadPath, because the length is not a multiple of 20. The reverse mistake, handing an Algebra-shaped path to dexId 1, also reverts with BadPath unless the length coincidentally satisfies 20 + 23N.

20-byte token * N, with N >= 2. Valid lengths are multiples of 20 and at least 40: 40 bytes for one hop, 60 for two, 80 for three.

One hop, buy, 40 bytes:

"0x11111111111111111111111111111111111111113333333333333333333333333333333333333333"

One hop, sell, 40 bytes:

"0x33333333333333333333333333333333333333331111111111111111111111111111111111111111"

Two hops, buy, 60 bytes:

"0x111111111111111111111111111111111111111122222222222222222222222222222222222222223333333333333333333333333333333333333333"

Route.fee stays reserved on Algebra. The off-chain quoter puts the observed dynamic pool fee there for display, in the same unit as a Uniswap tier, but no adapter reads it.

V2 venues (dexIds 0 and 2)

v2Path is a plain address[], minimum length 2, in swap order. v3Path must be 0x.

const buyPath = [WAPE, token]; // dexId 0 or 2
const sellPath = [token, WAPE];
const twoHopBuy = [WAPE, connector, token];

Multi-hop constraints

Multi-hop works, on every venue kind, with no contract change, because SwapPaths only pins the ends and the shape. What constrains it is the manager's dispatch, not the path:

  • One trade goes to exactly one adapter. A hop that changed venue mid-path has nowhere to run, because the adapter only knows its own routers. Every hop of a route must be on the same venue.
  • Both ends are fixed by the anchor: WAPE at the APE end, token at the far end.
  • Everything between is unvalidated by the aggregator and must be a real pool on that venue.

A TypeScript encoder

This encoder is direction-agnostic: pass the tokens in the order the swap walks them, which means reversing the array for a sell.

encodePath.ts
import { concat, pad, toHex, type Hex } from "viem";
 
/**
 * Uniswap-encoded path (Armory V3, dexId 1):
 *   token | poolFee(uint24) | token | poolFee | token …
 * `tokens` is N+1 addresses for N fees, in swap order.
 * Byte length is 20 + 23N: 43 for one hop, 66 for two.
 */
export function encodeUniswapPath(tokens: readonly string[], fees: readonly number[]): Hex {
  if (tokens.length < 2 || fees.length !== tokens.length - 1) {
    throw new Error(`bad uniswap path: ${tokens.length} tokens, ${fees.length} fees`);
  }
  const parts: Hex[] = [tokens[0]!.toLowerCase() as Hex];
  for (let i = 0; i < fees.length; i += 1) {
    parts.push(pad(toHex(fees[i]!), { size: 3 }));
    parts.push(tokens[i + 1]!.toLowerCase() as Hex);
  }
  return concat(parts);
}
 
/**
 * Algebra-encoded path (Camelot V3, dexId 3): packed 20-byte addresses, NO fee
 * bytes. Byte length is 20N: 40 for one hop, 60 for two.
 */
export function encodeAlgebraPath(tokens: readonly string[]): Hex {
  if (tokens.length < 2) throw new Error(`bad algebra path: ${tokens.length} tokens`);
  return concat(tokens.map((t) => t.toLowerCase() as Hex));
}

And the piece that actually prevents the direction bug. Build the market once in buy orientation, then orient it per side.

buildRoute.ts
import type { Address } from "viem";
import { ZERO_ADDRESS } from "./addresses";
 
type Side = "buy" | "sell";
 
/** `tokens` is stored WAPE-first. A sell is the same market read backwards. */
function orient(tokens: Address[], fees: number[], side: Side) {
  return side === "buy"
    ? { tokens, fees }
    : { tokens: [...tokens].reverse(), fees: [...fees].reverse() };
}
 
export function buildRoute(args: {
  dexId: number;
  /** WAPE-first, always. [WAPE, token] or [WAPE, connector, token]. */
  tokens: Address[];
  /** One pool tier per hop. Ignored on V2 and Algebra. */
  fees: number[];
  side: Side;
}) {
  const { tokens, fees } = orient(args.tokens, args.fees, args.side);
  const isV2 = args.dexId === 0 || args.dexId === 2;
  const isAlgebra = args.dexId === 3;
  return {
    dex: args.dexId,
    v3Path: isV2 ? "0x" : isAlgebra ? encodeAlgebraPath(tokens) : encodeUniswapPath(tokens, fees),
    v2Path: isV2 ? tokens : [],
    fee: 0, // reserved
    tickSpacing: 0, // reserved
    hooks: ZERO_ADDRESS,
  } as const;
}

A cheap self-check before you sign:

const bytes = (hex: string) => (hex.length - 2) / 2;
const okUniswap = bytes(p) >= 43 && (bytes(p) - 20) % 23 === 0;
const okAlgebra = bytes(p) >= 40 && bytes(p) % 20 === 0;

Which validator runs against your path is decided by the adapter, not by you. See Writing an adapter for how a venue picks one, and Integration recipes for the full call shape.