Single-hop router that quotes and executes swaps against a proprietary AMM (FermiSwap, Kipseli, Bebop, Tempest, TaurusFi, Metric, or El Zorro) or directly against Uniswap V3, and falls back to Uniswap V3 when the chosen proprietary venue cannot fill the swap.
The Prop AMM router contract is deployed on mainnet at 0x4ddf368080cd7946db5b459ad591c350158175e1.
The PropAMMs the router interacts with are deployed at:
- Bebop:
0xB09AaA5614916d7AEb59C295C52c92ca82aDdD76 - Fermi:
0x5979458912f80b96d30d4220af8e2e4925a33320 - Kipseli:
0x71e790dd841c8a9061487cb3e78c288e75ce0b3d - Tempest:
0x00000003f1ec2379e79F58E12EC6C4F51Ee92149 - TaurusFi:
0x217d58931A8549ca539426AA8152E33dAfc3d95A - Metric:
0xE715Dc29d2c273D0FC5A03e5Cca9CcB0Abb1dCDB - El Zorro:
0xCF211B4dD0D2be5C173Ea57Bcf938FC61d1d3bd3
Venues are identified by address: the proprietary AMM routers (FermiSwap, Kipseli, Bebop, Tempest, TaurusFi, Metric, El Zorro) plus the Uniswap V3 fallback, denoted by the SwapRouter02 address wired in at deployment. The router exposes the following external functions (see src/interfaces/IPropAMMRouter.sol for the full NatSpec and the rationale document with some design decisions):
swapV1(tokenIn, tokenOut, amountIn, amountOutMin, recipient, deadline): pullsamountInoftokenInfrommsg.sender, routes through the best-quoting venue, and falls back to Uniswap V3 if that venue reverts or under-delivers. Returns(amountOut, executedVenue), whereexecutedVenueis the proprietary venue that filled or the SwapRouter02 address when the fallback ran. Routes to Uniswap V3 if the best propAMM quote is belowamountOutMin, and checksamountOutMinagainst the measured balance delta ofrecipientafter execution (InsufficientOutput). Reverts when the contract is paused (see Pausing the contract); quote functions remain callable.swapViaVenueV1(venue, tokenIn, tokenOut, amountIn, amountOutMin, recipient, deadline): attempts the caller-specifiedvenuefirst. A proprietary venue still falls back to Uniswap V3 if it fails to fill; naming the Uniswap V3 SwapRouter02 address routes directly to Uniswap V3 (it is the fallback, so there is nothing further to fall back to). RevertsUnknownVenueifvenueis neither a whitelisted proprietary AMM nor the SwapRouter02 address.swapViaSelectedVenuesV1(venues, tokenIn, tokenOut, amountIn, amountOutMin, recipient, deadline): likeswapV1, but considers only the caller-suppliedvenuessubset. An on-chain requote across them selects the best, which executes — with the Uniswap V3 fallback still applying as the transparent safety net if the chosen venue fails to fill. Routes to Uniswap V3 if none of the listed venues can be priced. Returns(amountOut, executedVenue). List the SwapRouter02 address amongvenuesto opt Uniswap V3 into the selection (it is not a selection candidate otherwise, only the execution-time safety net).quoteV1(tokenIn, tokenOut, amount): quotes every venue (the proprietary AMMs and the Uniswap V3 fallback) and returns the bestamountOutalong with the venue address that produced it. RevertsNoQuotesAvailableif every venue is skipped or reverts.quoteVenueV1(venue, tokenIn, tokenOut, amount): quotes a single venue by address. RevertsUnknownVenuefor any address that is neither a proprietary AMM nor the SwapRouter02 fallback, and bubbles up any underlying venue revert.quoteSelectedVenuesV1(venues, tokenIn, tokenOut, amountIn): quotes only the caller-suppliedvenuessubset and returns the best(bestAmountOut, bestVenue). Venues that revert (including non-whitelisted addresses) are skipped; revertsNoQuotesAvailableif none of them can be priced.
The Uniswap V3 fallback prices and swaps at a per-pair fee tier, resolved on every quote and swap: the per-pair override for that pair if one exists, otherwise the global fallbackFee (3000, i.e. the 0.30% tier, by default). Callers never pass a fee. An UPGRADER_ROLE holder sets overrides with setPairFee(tokenA, tokenB, fee) or setPairFees(tokenA[], tokenB[], fee[]) (order-independent; fee == 0 clears an override), and retunes the global default with setFallbackFee — all without a contract upgrade. This lets stablecoin pairs use their tight tier (e.g. USDC/USDT at 100) while volatile pairs keep 3000/10000. Query the effective tier with resolvedFee(tokenIn, tokenOut) and the raw override with getPairFee(tokenA, tokenB).
A from-scratch deploy is pre-seeded by initialize with the global fallbackFee (3000) and the deep mainnet tiers — USDT/USDC at 100, USDT/WETH and USDC/WETH at 500 — so the Uniswap fallback needs no post-deploy fee configuration. This runs only in initialize (initializer-gated), so it does not re-apply when an existing proxy upgrades. An UPGRADER_ROLE holder can clear or retune any seeded tier afterward with setPairFee.
The venue whitelist is not seeded: initialize leaves _whitelistedVenues empty, so a freshly deployed router can only take the Uniswap V3 fallback until a LISTING_ROLE holder lists each propAMM with addVenue (see Deployment). The venues to list are the addresses in Deployed Contracts above. Because _pickBestVenue iterates the live set, a venue added this way is picked up by swapV1 / quoteV1 immediately, with no contract upgrade.
Kipseli does not expose a usable on-chain quote function. To price it, the router calls Kipseli.simulateKipseliSwap, which executes a real swap and then reverts with the resulting amountOut ABI-encoded in the revert payload. The router decodes that payload to recover the quote.
The Uniswap V3 fallback branch also prices via revert-based simulation, since QuoterV2 reverts with the simulated amountOut.
As a consequence:
quoteV1andquoteVenueV1are notview. They must be called viaeth_call(staticcall) from off-chain so the simulated swaps are rolled back automatically.- The Kipseli simulation pulls
tokenInfrom the router's own balance. When quoting against Kipseli (directly viaquoteVenueV1, or implicitly throughquoteV1), the RPC call must include astateDiffoverride that gives the router a sufficient balance oftokenIn. Without the override, the Kipseli branch is silently skipped while the other branches still quote.
Three implementation-only entrypoints take a per-call, basis-point fee from the swap
output token and forward it to a caller-supplied recipient. They are not part of
IPropAMMRouter; encode them against the deployed PropAMMRouter.
swapWithFeeV1(tokenIn, tokenOut, amountIn, amountOutMin, recipient, deadline, fee)swapViaVenueWithFeeV1(venue, tokenIn, tokenOut, amountIn, amountOutMin, recipient, deadline, fee)swapViaSelectedVenuesWithFeeV1(venues, tokenIn, tokenOut, amountIn, amountOutMin, recipient, deadline, fee)
fee is a FrontendFee { uint16 bps; address recipient }:
bpsis the fee in basis points, capped atMAX_FEE_BPS(100 = 1.00%).recipientreceives the fee intokenOut; must be non-zero.
amountOutMin is the net amount the user must receive after the fee — the router
grosses it up internally, so the user always nets at least amountOutMin. The returned
amountOut and the Swapped event's amountOut are the net delivered to recipient.
A FrontendFeeCharged event is emitted whenever a non-zero fee is taken. Quote functions
are unchanged and return gross output; a frontend nets out by subtracting its own bps.
- Foundry installed.
- Node.js installed (required by
openzeppelin-foundry-upgradesfor storage layout validation during upgrades). - A mainnet RPC URL (Infura, Alchemy, etc.) exported as
MAINNET_RPC_URL.
Important: the PropAMMRouter depends on Ethereum mainnet state, since it interacts with the propAMM router addresses listed in Deployed Contracts (and the UniswapV3 router fallback). Therefore, for local development we suggest using a fork of mainnet. You can start an anvil instance forking mainnet with:
anvil --fork-url https://ethereum-rpc.publicnode.comTo deploy the PropAMMRouter contract, run the scripts/Deploy.s.sol script. It deploys a RouterAccessManager (an OpenZeppelin AccessManager) as the authority, deploys the router proxy under it, and activates the policy with a single configureRouter call.
The roles and their delays are contract constants:
UPGRADER_ROLE— UUPS upgrades, fallback config (setFallbackSwapRouter/setFallbackQuoter/setFallbackFee), per-pair fee tuning (setPairFee/setPairFees), andrescueTokens.UPGRADE_DELAY= 7 days, so queued changes are publicly visible before they apply.GUARDIAN_ROLE—pause(). Zero delay: an instant circuit breaker for a security council.RESUMER_ROLE—unpause().RESUME_DELAY= 2 hours, kept separate from the guardian because resuming is fail-open and should be deliberate.LISTING_ROLE— venue whitelist management (addVenue/removeVenue).LISTING_DELAY= 1 day — a separate, operations-paced account, since listing is lower blast-radius than an upgrade (a bad venue just reverts and the Uniswap fallback engages).
Role grants happen in the manager's constructor; configureRouter(proxy) then wires the selectors and sets the re-gating delay (ADMIN_DELAY = 7 days). It is one-shot — it cannot be re-run to bypass ADMIN_DELAY; later changes go through the standard, delayed AccessManager interface. (To tune delays per network, change the constants in RouterAccessManager or promote them to immutable constructor args.)
The script only supplies the deployment-specific role holders and runs the optional governance handoff. Required environment variables:
ROUTER_ADMIN— bootstrap admin of the manager; must be the account you broadcast from, sinceconfigureRouterand the handoff are admin-gated.ROUTER_UPGRADER— holder ofUPGRADER_ROLE.ROUTER_GUARDIAN— holder ofGUARDIAN_ROLE.
Optional (with defaults): ROUTER_RESUMER (defaults to ROUTER_UPGRADER), ROUTER_LISTER (defaults to ROUTER_UPGRADER), GOV_ADMIN (governance multisig to receive ADMIN_ROLE), ADMIN_EXEC_DELAY (7 days), and RENOUNCE_BOOTSTRAP (when true, the bootstrap admin renounces ADMIN_ROLE after the handoff). If GOV_ADMIN is omitted, ROUTER_ADMIN remains a delay-0 master key — hand it off and renounce it for production.
Example:
export ROUTER_ADMIN=<bootstrap admin = broadcaster>
export ROUTER_UPGRADER=<upgrader multisig>
export ROUTER_GUARDIAN=<security council>
export GOV_ADMIN=<governance multisig> # optional but recommended
forge clean && forge script scripts/Deploy.s.sol \
--broadcast \
--rpc-url $RPC_URL \
--private-key $DEPLOYER_KEY$DEPLOYER_KEY must correspond to ROUTER_ADMIN.
The proprietary AMMs maintain off-chain liquidity that is not reflected by mainnet state, so a plain eth_call to PropAMMRouter.quoteV1 against the anvil fork only sees stale liquidity. To get accurate quotes, Titan exposes the JSON-RPC method titan_getPammStateOverrides, whose result is passed as the third parameter of eth_call.
The Titan endpoint only serves titan_getPammStateOverrides. The eth_call itself is sent to the anvil fork.
The response is keyed by proprietary AMM router address, with one entry per venue:
{
"result": {
"blockNumber": "0x...",
"0x5979458912f80b96d30d4220af8e2e4925a33320": { "stateOverride": { ... } },
"0xb09aaa5614916d7aeb59c295c52c92ca82addd76": { "stateOverride": { ... } },
"0x71e790dd841c8a9061487cb3e78c288e75ce0b3d": { "stateOverride": { ... } }
}
}The keys are propAMM router addresses — above, FermiSwap, Bebop, and Kipseli respectively. A snapshot only carries the venues Titan has fresh state for, so the set of keys varies between calls. When calling quoteVenueV1(venue, ...), pick the entry matching the venue address you want to quote. Kipseli additionally requires the router to hold tokenIn, since simulateKipseliSwap transfers it to Kipseli; we fund it with a stateDiff over the token contract's _balances slot for the router. The other venues ignore the balance override, so the same snippet works for every proprietary venue (only the venue address needs to change). For the Uniswap V3 fallback (naming the SwapRouter02 address) the Titan overrides aren't needed at all — QuoterV2 only reads on-chain pool state — so you can skip the titan_getPammStateOverrides call entirely and pass an empty state override (or omit the third eth_call parameter).
Example: quote 1 WETH for USDC against the deployed router on the anvil fork.
RPC_URL=http://127.0.0.1:8545
ROUTER=<PropAMMRouter proxy address logged by Deploy.s.sol>
WETH=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
USDC=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
# === Pick the venue to quote (only edit this line) ===
# Venues are addresses: the Uniswap V3 fallback is the SwapRouter02 address;
# the proprietary AMM routers are listed in the table below.
VENUE_ADDR=0x71e790dd841c8a9061487cb3e78c288e75ce0b3d # Kipseli
# WETH stores balances in storage slot 3 (mapping(address => uint)).
# balanceOf[ROUTER] lives at keccak256(abi.encode(ROUTER, 3)).
WETH_BAL_SLOT=$(cast index address $ROUTER 3)
# 1. Fetch the venue's Titan overrides and fund the router with 10 WETH.
# (Skip this step for the Uniswap V3 fallback — QuoterV2 only needs on-chain state.)
OVERRIDES=$(curl -s -X POST https://eu.data.titanbuilder.xyz \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"titan_getPammStateOverrides","params":[]}' \
| jq -c --arg pamm "$VENUE_ADDR" --arg weth "$WETH" --arg slot "$WETH_BAL_SLOT" '
.result[$pamm].stateOverride
+ { ($weth): { stateDiff: { ($slot): "0x0000000000000000000000000000000000000000000000008ac7230489e80000" } } }
')
# 2. eth_call PropAMMRouter.quoteVenueV1(venue, WETH, USDC, 1e18) with the overrides.
# The Uniswap V3 fallback always prices at the configured `fallbackFee` tier
# (3000 by default); there is no per-call fee argument.
DATA=$(cast calldata "quoteVenueV1(address,address,address,uint256)" $VENUE_ADDR $WETH $USDC 1000000000000000000)
curl -s -X POST $RPC_URL -H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":[{\"to\":\"$ROUTER\",\"data\":\"$DATA\"},\"latest\",$OVERRIDES]}" \
| jq -r .result \
| xargs cast --abi-decode "f()(uint256)"This prints amountOut, e.g. 2115659878 (≈ 2115.66 USDC for 1 WETH, with USDC's 6 decimals) at the current state. To quote a different venue, edit only VENUE_ADDR:
| Venue | VENUE_ADDR |
|---|---|
| Uniswap V3 fallback (SwapRouter02) | 0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45 — skip the Titan call |
| FermiSwap | 0x5979458912f80b96d30d4220af8e2e4925a33320 |
| Kipseli | 0x71e790dd841c8a9061487cb3e78c288e75ce0b3d |
| Bebop | 0xB09AaA5614916d7AEb59C295C52c92ca82aDdD76 |
| Tempest | 0x00000003f1ec2379e79F58E12EC6C4F51Ee92149 |
| TaurusFi | 0x217d58931A8549ca539426AA8152E33dAfc3d95A |
| Metric | 0xE715Dc29d2c273D0FC5A03e5Cca9CcB0Abb1dCDB |
| El Zorro | 0xCF211B4dD0D2be5C173Ea57Bcf938FC61d1d3bd3 |
The router is PausableUpgradeable. Access is governed by the RouterAccessManager:
pause()— held byGUARDIAN_ROLEwith zero delay, so a security council can halt swaps instantly. While paused,swapV1,swapViaVenueV1, andswapViaSelectedVenuesV1revert with OpenZeppelin'sEnforcedPauseerror.quoteV1,quoteVenueV1, andquoteSelectedVenuesV1are unaffected and remain callable.unpause()— held byRESUMER_ROLEwith a short execution delay (default 2 hours). Because resuming is fail-open, it is deliberately not on the guardian's instant path: it is scheduled on the manager and executed after the delay.
The state initializes to unpaused on initialize.
Upgrades are gated through the RouterAccessManager: _authorizeUpgrade is restricted, and the upgradeToAndCall selector is assigned to UPGRADER_ROLE, which carries an execution delay (default 7 days). Upgrades therefore can no longer be applied in a single transaction — they are scheduled on the manager and executed after the delay, by the account holding UPGRADER_ROLE.
Config precondition: The whole fresh-deploy configuration is seeded only in
initialize— i.e. on a fresh deploy — and is never re-applied on upgrade. This bites a proxy that predates that config in two ways. (1) Unconfigured pairs resolve their Uniswap fallback tier to the globalfallbackFee; an enum-era deployment carriesfallbackFee = 0after the upgrade, so the fallback resolves to tier0(invalid on Uniswap V3) for every unconfigured pair and reverts. (2) An enum-era deployment also has an empty venue whitelist, soswapV1can only ever take the Uniswap fallback and never routes the propAMMs. After upgrading such a proxy, restore the config manually to match a freshinitialize: callsetFallbackFee(3000)andsetPairFeesfor the deep tiers (USDT/USDC at100, USDT/WETH and USDC/WETH at500) as anUPGRADER_ROLEholder, thenaddVenuefor each propAMM in Deployed Contracts as aLISTING_ROLEholder. Until the fee backfill runs, the Uniswap fallback is unusable, so considerpause()-ing the router across the upgrade + config window if swaps could arrive in between (see "Running the upgrade" below).
Place the new implementation under src/ (for example src/PropAMMRouterV2.sol). It must:
- Have a compatible storage layout: and only append new storage variables at the end. Never reorder, rename, or remove existing storage slots.
- Include the
@custom:oz-upgrades-fromannotation pointing to the previous contract name. This is what triggers automatic storage-layout validation byopenzeppelin-foundry-upgrades. - Use
reinitializer(n)(notinitializer) if the new version needs to initialize new state, wherenis the next version number.
Example skeleton:
/// @custom:oz-upgrades-from PropAMMRouter
contract PropAMMRouterV2 {
uint256 private _newField; // appended at the end of storage
function initializeV2(uint256 newField_) public reinitializer(2) {
_newField = newField_;
}
}Because upgrades carry an execution delay, the flow is two steps:
1. Schedule with scripts/Upgrade.s.sol. It deploys and upgrade-safety-validates the new implementation via Upgrades.prepareUpgrade (without applying it), then schedules the upgradeToAndCall on the manager. It logs the new implementation address, the operation id, the timestamp at which it becomes executable, and the exact EXEC_TARGET / EXEC_DATA to use in step 2.
It reads:
ACCESS_MANAGER: theRouterAccessManageraddress (logged byDeploy.s.sol).ROUTER_PROXY: address of the deployed proxy to upgrade.ROUTER_IMPL_NAME: filename of the new implementation insrc/(e.g."PropAMMRouterV2.sol").ROUTER_IMPL_REFERENCE(optional): reference contract for storage-layout validation, unless the new implementation carries an@custom:oz-upgrades-fromannotation.
export ACCESS_MANAGER=<manager address logged by Deploy.s.sol>
export ROUTER_PROXY=<proxy address logged by Deploy.s.sol>
export ROUTER_IMPL_NAME=PropAMMRouterV2.sol
forge clean && forge script scripts/Upgrade.s.sol \
--broadcast --rpc-url $RPC_URL --private-key $UPGRADER_KEY2. Execute with scripts/Execute.s.sol once the delay has elapsed, using the EXEC_TARGET / EXEC_DATA logged in step 1:
export ACCESS_MANAGER=<manager address>
export EXEC_TARGET=<proxy address>
export EXEC_DATA=<calldata logged by Upgrade.s.sol>
forge script scripts/Execute.s.sol \
--broadcast --rpc-url $RPC_URL --private-key $UPGRADER_KEY$UPGRADER_KEY must hold UPGRADER_ROLE, and the same account must run both steps — the scheduled operation is keyed by (caller, target, data). Alternatively, after the delay the upgrader can call upgradeToAndCall(newImpl, "") on the proxy directly; the restricted modifier consumes the schedule.
The scheduled call passes no reinitializer calldata. If the new implementation defines a reinitializer, edit scripts/Upgrade.s.sol to encode it (e.g. abi.encodeCall(PropAMMRouterV2.initializeV2, (newField))) in place of the empty "" before scheduling.