Deployment & Upgradeability
1. Overview
The protocol deploys as, per chain:
- The shared
AccessControlsingleton, oneExternalOracle, and two UUPS singletons behind ERC-1967 proxies (Admin,Flash). - The
Poolreference implementation andPoolFactory. The factory is the beacon:beacon()returnsaddress(this)andimplementation()is the one slot every live pool reads (PoolFactory.sol). - Four deployed, linked libraries auto-deployed by
forgevia CREATE2 and linked into their callers:PoolConfig,PoolLiquidity,Pricing,NUQuartic. See §5. - Per-pool ERC-1967 beacon proxies, deployed via
PoolFactory.createPool(LibClone.deployDeterministicERC1967BeaconProxy). Each proxy has its own storage and readsPoolFactory.implementation()for its code.
The Pool implementation constructor deploys the shared LPToken implementation that every per-leg receipt clones (Pool.sol), so it is not a separate deploy step.
Upgradeability is achieved via:
- Beacon impl swap at
PoolFactory(UPGRADEtier delay), which re-points every live pool at once. See §4.1. - UUPS on
AdminandFlash, gated by theUPGRADEtier delay viarequestUpgrade/executeUpgradeonly (§4.2). TheRouteris deployed but holds no state, no funds between calls and no upgrade path: it is immutable, and a new one is a new deployment (see Composability §2).
Tier durations are deploy-time data, not constants of the design; the schedule is in
Access Control & Roles §5.
Every duration quoted on this page is the PROD_DELAYS value.
Pool bakes the Admin and Flash proxy addresses as immutables and PoolFactory._validateImplementation pins them on every fleet upgrade, so a live pool can never be re-pointed at a different governance or flash contract. Those two must be proxies from the first deploy or their logic is frozen for the fleet’s life.
Cross-contract interactions between distinct contracts (Admin↔Pool, Factory↔Pool, Flash↔Pool) are normal external calls, not delegatecall - the DELEGATECALLs happen only inside a single Pool, to its linked libraries; see AIMM Overview §2.1 for that architecture.
2. Architecture
2.1. Core components
Pool reference impl (
Pool.sol)- Standalone AIMM contract. Reads the external-mark feed (
ExternalOracle); no internal TWAP. - Deployed once per protocol version; never called directly by users.
- Each pool is an ERC-1967 beacon proxy pointing at the factory, initialised via
initialize(...).
- Standalone AIMM contract. Reads the external-mark feed (
PoolFactory (
PoolFactory.sol)- Deploys
Poolbeacon proxies (createPool(...)) and holds the beacon-swap timelock (pendingReferencePool,upgradeTimelock). - Maintains
allPools+officialPools+isPoolregistries.
- Deploys
UUPS singletons, each an ERC-1967 proxy over an implementation that carries the immutable
AC(AccessControl) ref:Admin.sol, per-pool timelock queue + restricted setters. Holds real per-pool state (bootstrapSealed,riskFences,pendingOps), which is why redeploy-and-repoint is not an equivalent to an upgrade: a freshAdminwould arrive withbootstrapSealed == falsefleet-wide and re-open the untimelockedaddAsset/setCurvelanes on live sealed pools.Flash.sol, ERC-3156-style (postFlashLoan variant) flash-loan provider. Holds no persistent state;UpgradeGate’s 50 slots are the whole layout.
Shared
AccessControl.sol, single owner source of truth, plus the independenttreasuryOwnerprincipal, the guardian set, and the packed governance delay schedule.
Fee sink: each pool’s
treasury()is a plain address, not a contract.Pool.initializeleaves it zero, and fee collection reverts until it is set; the pool-seed scripts wire it per chain through theUPDATE_TREASURYop (HIGHcustody tier). Only that exact address may pull the pool’s accrued protocol fees (Admin.sol,collectProtocolFees).
3. Deployment process
3.0. Phase 0: the CREATE3 factory, and why addresses are known before the deploy
Every singleton below is deployed through a CREATE3 factory rather than by plain CREATE, so its
address is f(factory, deployerKey, salt) and does not depend on the contract’s bytecode. Three
things follow. The address is identical on every chain BTR launches on. It is known before the
contract is compiled, so keeper configs, monitoring and integration constants can be filled in
ahead of the ceremony instead of after it. And it survives a contract-generation change: a new
oracle version deploys to the reserved address for that role, not to a new one.
The factory itself is deployed once per chain from a frozen initcode artifact via the
canonical arachnid CREATE2 proxy, never rebuilt from source - a rebuild under different compiler
settings produces different initcode and therefore a different factory, which would move every
address derived from it.
The deployer key is part of the address. The factory salts with
keccak256(deployerEoa ++ salt), so the same salt signed by a different key lands somewhere else -
silently, with no revert. Mainnet addresses are mined for one specific key; signing a mainnet
deploy with any other burns the reservation unrecoverably. The deploy scripts therefore assert the
signing key against the record before broadcasting, and the reserved mainnet addresses are
published in Contract Addresses so that anything appearing at one
of them ahead of a published deployment is recognisably not BTR.
3.1. Phase 1: deploy shared and singletons
Phase 1 deploys the shared AccessControl singleton - owner, treasury, and the packed governance-delay word passed at construction: GOV_DELAYS is read from the environment with no default, so an unset variable aborts the deploy - then the Admin and Flash implementations, each carrying the immutable AC ref, behind ERC-1967 proxies via LibClone.deployERC1967. Every mapping starts empty, so there is nothing to initialise and no uninitialised-proxy window.
A chain whose oracle stack shipped first reuses that chain’s existing AccessControl rather than minting a second one: two ACs would split protocol governance and leave the oracle’s guardian unable to halt the pools it feeds. ExternalOracle takes the AC address as a constructor argument, so this is a deploy-script decision, not a contract-enforced one. The one deliberate exception is a reference oracle instance, which should be governed separately from the primary it polices; see Oracle Price-Push Security §4.6.
3.2. Phase 2: deploy pool reference and factory
Phase 2 deploys the reference Pool implementation - its AC / Admin / Flash wiring is immutable and is re-asserted on every fleet swap, with admin and flash the PROXY addresses, never the implementations, and its constructor also deploys the LPToken implementation the receipts clone - plus the PoolFactory, which is itself the beacon holding the fleet implementation slot.
3.3. Phase 3: deploy a pool
// Permissionless: any address may call this. Deployment ≠ administration -
// there is no `owner` param; every pool's admin functions resolve to the
// single protocol-wide AccessControl owner regardless of who deployed it.
address pool = factory.createPool(
baseToken, // anchor token (e.g. USDC)
tokens, // address[] of assets to register on the pool
initdata // abi-encoded call forwarded to the pool (typically `initialize(baseToken, wnative, feeParams)`)
);
// Pool is now an initialised ERC-1967 beacon proxy reading `factory.implementation()`.3.4. Phase 4: wire per-pool config
admin.requestOp(pool, uint8(IPool.OpType.ADD_ASSET), subject, payload);
// wait the LOW tier delay
admin.executeAddAsset(pool, token); // execute paths stay named, one per op
// The four immediate per-asset params are a separate, untimelocked owner call:
admin.setAssetParams(pool, token, minLiquidity, minFeePbps, vegaBps, haircutSuppressorBps);decimals is not an argument: it is read from the token at listing. Inventory skew takes no per-asset argument either; it is a fixed protocol law.
setAssetParams applies immediately only before bootstrapSealed[pool] or on a defensive tighten; otherwise it queues itself at LOW. Policy: Access Control & Roles §6.
4. Upgrade mechanisms
4.1. Pool beacon upgrade (PoolFactory, 7-day timelock)
Canonical description of the pool upgrade model.
factory.requestReferenceUpgrade(address(newImpl)); // candidate carries the same AC / Admin / Flash immutables
// wait the UPGRADE tier delay (7 days in production), then execute inside GRACE_PERIOD (7 days)
factory.executeReferenceUpgrade(); // writes the factory's own `implementation` slot
// OR cancel before exec: owner or any AC guardian.
factory.cancelReferenceUpgrade();Key properties:
- Fleet-wide, not opt-in. Every deployed pool is an ERC-1967 beacon proxy reading
PoolFactory.implementation().executeReferenceUpgrade()writes that one slot, so a single owner transaction replaces the executable code of every live pool at once, including pools deployed permissionlessly by third parties. There is no per-pool opt-out and no version pinning. This is the protocol’s largest single trust assumption. - New-impl compatibility is asserted on request.
requestReferenceUpgraderequires the candidate to be a contract and to carry the sameAC/admin/flashimmutables as the live impl (PoolFactory.sol). Storage-layout compatibility is not on-chain-checkable and is pinned at build time byArtifactGuards.t.sol, which assertsPooldeclares exactly one storage entry ($at slot 0). - Delay: the
UPGRADEtier ofAccessControl.GOV_DELAYS(), read once into the factory’sDELAY_UPGRADEimmutable at construction (PoolFactory.sol). UnderConstants.PROD_DELAYSthat is 7 days. - Grace window: the matured request expires
SC.GRACE_PERIOD(7 days) after its eta and then revertsErr.Expired(PoolFactory.sol). A stale request cannot be executed months later; it must be re-requested. Shipping in the next release, that re-request needs nocancelReferenceUpgradefirst: an expiredpendingReferencePoolis overwritten in place, announced asReferencePoolUpgradeCancelledthenReferencePoolUpgradeRequested, at the fullUPGRADEdelay (§6.1). This is the longest of the six tiers and therefore the likeliest to age out unnoticed. - Cancellable by the owner or by any
isGuardianaddress viacancelReferenceUpgrade()(PoolFactory.sol). Residual, stated in the source: a fully compromised owner cansetGuardian(false)and re-request, so the guardian veto raises the bar rather than being absolute. - Monitor:
ReferencePoolUpgradeRequestedandReferencePoolUpgradedonPoolFactory. The beacon address isPoolFactory.beacon(), which is the factory itself. PoolStoragefield order/types are append-only across versions, because live pools keep their storage across a beacon swap.
4.2. UUPS upgrades (Admin / Flash)
Admin and Flash are ERC-1967 proxies over implementations inheriting UpgradeGate. A direct upgradeToAndCall reverts Ownable.Unauthorized() even from the authority: _authorizeUpgrade accepts only the matured self-call with the one-shot transient flag set (UpgradeGate.sol). The request/execute timelock is the sole upgrade path.
admin.requestUpgrade(newImpl);
// ... wait the UPGRADE tier delay (7 days in production), then execute within SC.GRACE_PERIOD = 7 days
admin.executeUpgrade(); // takes no initData
// Veto before execution: authority or any AC guardian.
admin.cancelUpgrade();
// Freeze a matured request without cancelling it: authority or any AC guardian.
admin.pause();- Authority is resolved per contract by
UpgradeGate._upgradeAuthority():AccessControl.owner()forAdminandFlash. - The candidate implementation must resolve to the same governance root.
UpgradeGate._pinGovernanceRootcompares itsACagainst the live one and reverts atrequestUpgradeand again atexecuteUpgrade, so an AC-mismatched implementation never reaches the proxy. Note the asymmetry with §4.1: the gate pinsAConly, wherePoolFactory._validateImplementationpinsAC,adminandflash. - The upgrade-pending flag is held in EIP-1153 transient storage so it cannot persist across unrelated calls.
UpgradeGateoccupies the first 50 storage slots of both contracts. Nothing may be inserted aboveAdmin.pendingOps; the layout is pinned byAdminFlashUUPS.t.sol.
Residual, stated in the source: Pool.flashSend books no repayment obligation, so the only enforcement of a flash repayment is the balance check inside Flash.flashLoan. Proxying puts that check behind an upgrade. The same authority already swaps Pool itself through the beacon under the same UPGRADE-tier timelock, so this grants no capability it did not already hold.
4.3. Contracts with no upgrade path
PoolFactory, the LPToken implementation, ExternalOracle and the four linked libraries have no upgrade path at all: none of them sits behind a proxy, and ExternalOracle states the property in-source as the reason its signer cap is 16 rather than 6. They are replaced only by deploying new instances and re-pointing what references them: a new Pool implementation plus a beacon swap (§4.1) for the libraries and the receipt implementation, and a per-asset UPDATE_ORACLE op at the BASE tier for an oracle instance.
A pool cannot be re-pointed at a different Admin or Flash. Those addresses are Pool immutables, they live in implementation code rather than proxy storage, and PoolFactory.requestReferenceUpgrade requires a candidate implementation to carry byte-identical AC / admin / flash immutables (PoolFactory.sol). That is why both ship behind proxies from the first deploy.
5. Non-upgradeable components
A library is deployed and linked when it declares at least one external or public function; otherwise the compiler inlines it into its callers.
- Deployed, linked:
libraries/PoolConfig.sol,PoolLiquidity.sol,Pricing.sol,NUQuartic.sol.Poollinks the first three directly;NUQuarticis linked intoPoolConfigandPricingadditionally intoPoolLiquidity, so a link check that inspects onlyPoolreports three and misses one. - Inlined:
AnchorTreeLib.sol,PoolIOLib.sol,PoolHooksLib.sol,FeedMathLib.sol,TransientCacheLib.sol,PoolConstantsLib.sol. PoolFactory, theLPTokenimplementation,ExternalOracle.- Shared
Constants.sol,Errors.sol,Timelock.sol,AccessControl.sol.
6. Security through timelocks
All dangerous operations require time delays, tiered per the real on-chain constants - see Access Control & Roles §5 for the full duration table, and §4 there for every untimelocked emergency lever and its authority.
6.1. Timelock mechanics
Timelock.sol is a two-function library (pack(delay, grace), validate(packed)), not a queue contract. The queue lives on Admin: one generic request, one cancel, and one named execute per op. The request side is plumbing (pick a tier, key it, store the blob, emit) and is shared; the execute side is not (each op has its own decode shape, validation, pool setter and event), so it stays named and individually testable. There is no generic OperationExecuted event.
admin.requestOp(pool, opType, subject, payload); // emits TimelockRequested(pool, key, opType, executableAt)
// wait the tier delay
admin.executeAddAsset(pool, token); // emits the operation's own event, e.g. AssetAdded
// or, owner OR any guardian, any time before execution
admin.cancelTimelock(pool, opType, subject); // emits TimelockCancelled(pool, key, opType)opType is the IPool.OpType ordinal; declaration order and the op→tier map are in Access Control & Roles §6. subject is the third key component:
- The left-padded asset address for token-keyed ops.
- The preset id for
UPDATE_CURVE. - Ignored for the three pool-wide ops.
Because request and cancel share one key derivation, every op that can be queued can be cancelled by construction rather than by two functions agreeing.
A live pending op cannot be silently re-queued: requestOp reverts AlreadyPending while the key holds an entry inside eta + GRACE_PERIOD. Cancel first, then re-request. Otherwise a payload swap plus an eta reset would restart the LP exit-notice clock unobserved.
An expired entry is different in kind, and shipping in the next release it is overwritten rather than refused. Past eta + GRACE_PERIOD an op can never execute (TimelockLib.validate reverts Expired), so it is nobody’s notice period: it is a dead key holding its own lane shut, and nothing on chain enumerates which keys are in that state. A fresh request on such a key emits TimelockCancelled, then TimelockRequested with a full fresh delay, so no clock is shortened and recovery costs one transaction instead of two. The predicate is TimelockLib.isLive, and it is one definition across six queues: Admin.requestOp (which setAssetParams also routes through), ExternalOracleV4.requestFeedWiden, both NxrSignerSet queues, UpgradeGate.requestUpgrade, and PoolFactory.requestReferenceUpgrade — the last spells the same test out inline, because its upgradeTimelock is a raw eta with no packed op word. Every one of those queues previously charged an extra cancel transaction to recover from its own dead entry, on levers whose purpose is to be reachable during an incident.
Exact event signatures: TimelockRequested(address indexed pool, bytes32 indexed id, uint8 opType, uint48 executableAt) and TimelockCancelled(address indexed pool, bytes32 indexed id, uint8 opType) (IAdmin.sol). Execution emits the operation-specific event, never a generic one.
Grace period: ops auto-expire after eta + grace. PoolFactory.referenceUpgrade is no exception: it reverts Err.Expired past upgradeTimelock + SC.GRACE_PERIOD (§4.1).
7. Deployment configurations
7.1. Mainnet
- AccessControl owner: a governance multisig whose threshold satisfies
Quorum.admin(n) = ceil(2n/3)overnin[3, 16], so 2-of-3, 3-of-4 or 4-of-5 (transitions to elected DAO council). A 3-of-5 is rejected:armQuorumPolicy()revertsErr.ThresholdViolation(3, 4). This is the owner resolved by everyonlyAdmincheck across the DEX:Admin,PoolFactory,ExternalOracle, and everyPool(official or permissionless). The full arming preflight is Access Control & Roles §1.1. - Treasury owner:
AccessControl.treasuryOwner(), a second, independent governance principal with its own rotation and veto, gating fee custody so it can live on a different multisig from the param/admin owner. The two may be the same key but are not the same role. A pool’s fee sink itself is a plain address, not a contract; only that address may pull the pool’s accrued protocol fees. - Pool deployment: permissionless - any address may call
PoolFactory.createPool. Pool administration (add asset, halt, risk config, etc.) is not permissionless: it is gated to the single AC owner for every pool, regardless of deployer. There is no per-pool owner or curator role in the deployed contracts.
7.2. Governance delays are deploy-time data
The delay schedule is a constructor argument, not a chain-dependent branch. AccessControl(owner, treasury, govDelays) stores the packed word in the immutable GOV_DELAYS, and every governed contract derives its own tier delays from it at construction. No contract reads block.chainid to pick a delay, so a testnet and a mainnet deployment run identical code on different data.
Deploy scripts read GOV_DELAYS from the environment with no default: an unset variable aborts the deploy rather than silently picking a side. Production passes Constants.PROD_DELAYS; public testnets pass Constants.TESTNET_DELAYS. There is no throwaway zero-delay option: the deploy path rejects any schedule under Constants.MIN_ARMED_DELAY, so every deployed fleet arms its timelocks. Read a delay quoted anywhere in these docs as the PROD_DELAYS value; a testnet fleet runs the shorter schedule for the same tier.
7.3. Risk parameters are deliberately not timelocked
setRiskFences and setAssetParamsBounded bypass the timelock entirely. The steward lane writes exactly three fields (minFeePbps, vegaBps, haircutSuppressorBps) and never queues; minLiquidity is an argument only so the call can prove it is unchanged, and any delta reverts InvalidInput. The matching RiskFences are minFeeHardMinPbps, minFeeHardMaxPbps, vegaHardMinBps, vegaHardMaxBps, haircutSuppressorHardMaxBps, haircutSuppressorHardMinBps and maxDeltaBps: a hard min/max pair for each of the three fields, plus the one relative step bound.
This is not an oversight, it is a design boundary: BTR’s edge is fast risk-parameter adaptivity, so those calls stay immediate, and the control is the bounded-delta guard plus a dedicated, revocable risk role, not a delay. The exemptions are narrower than “tightening is free”; see Access Control & Roles §3 for which direction each field is clamped in and why the suppressor is never exempt. Structural changes (oracle mode, asset add/remove, curve preset repoint) still route through requestOp with UPDATE_RISK / ADD_ASSET / UPDATE_PROFILE and the full LOW-tier queue.
8. Permissionless pool deployment
address myPool = factory.createPool(baseToken, tokens, initdata);
admin.requestOp(myPool, uint8(IPool.OpType.ADD_ASSET), subject, payload);
// ... wait timelock, then execute (as the AC owner - the deployer does NOT
// automatically gain admin rights over the pool they deployed).- Timelocks are enforced on every pool, which protects pool users regardless of who administers it.
- Each asset picks its oracle mode at listing, and every non-base spoke carries a mandatory reference band (
requireExternalSpokeBound,PoolConfig.sol); the modes are defined in Oracles §1 and the band in Depeg Halt §2.4.
9. Best practices
9.1. For pool reference-impl developers
- Storage layout is append-only.
PoolStoragefield order and types are frozen across implementation versions. New fields appended only. This is load-bearing: a beacon swap moves every live pool onto the new code while keeping its existing storage. - No constructors with side effects.
Poolruns behind a proxy; onlyinitialize(...)runs per-instance. - No new external dependencies without an explicit migration story. A beacon swap migrates the whole fleet in one transaction, so a new dependency must be safe for every live pool simultaneously.
- Test the implementation AND a deployed proxy. A proxy is what users hit; impl in isolation will skip the real DELEGATECALL edge cases - the linked-library DELEGATECALLs only trigger through a deployed proxy.
9.2. For pool operators
- Use a multisig for the
AccessControlowner, with a threshold satisfyingceil(2n/3): 2-of-3, 3-of-4, 4-of-5. - Monitor
TimelockRequestedandTimelockCancelledonAdminfor your pool, plus the per-operation execution events (AssetAdded,RiskConfigUpdated,OracleUpdated,CurveUpdated,FeeParamsUpdated,TreasuryUpdated,BaseTokenMigrated). There is no genericOperationExecuted.
9.3. For users
- Verify pool address and admin. Query
factory.isOfficialPool(pool)to distinguish official vs. permissionless pools; admin authority is the same single owner either way (see §7.1). - Monitor timelock events. Subscribe to
TimelockRequestedfor your pool; review pending ops; exit if suspicious. - Understand the upgrade model.
- Your pool’s code is not immutable. It is a beacon proxy: one owner transaction at the factory, after the
UPGRADEdelay, changes the code of every live pool including yours. WatchReferencePoolUpgradeRequestedonPoolFactoryfor the full notice window (§4.1). - A queued upgrade can be vetoed by the owner or any guardian, and expires 7 days after maturity if not executed.
AdminandFlashare themselves upgradeable behind proxies, under the sameUPGRADEdelay; watchUpgradeRequestedon both.
- Your pool’s code is not immutable. It is a beacon proxy: one owner transaction at the factory, after the
10. Emergency procedures
Authority, delays and the full lever list: Access Control & Roles §4. The two calls this page owns:
factory.cancelReferenceUpgrade(); // veto a queued fleet swap: owner or any guardian
admin.pause(); // freeze a matured UUPS request without cancelling it11. Upgrade checklist
PoolStoragelayout unchanged or safely extended (append-only).- No constructor side effects in the reference impl.
forge testgreen against a deployed proxy, not the implementation alone.- Fleet-wide blast radius reviewed: the swap re-points every live pool, including third-party ones.
- Timelock requested and announced; the delay window is the review period.
- Testnet swap executed and observed.
- ABIs regenerated and republished to the backend ABI endpoint (
GET /v1/abis/<ContractName>); abi-freshness check green. - Emergency halt paths verified.
- Monitoring + alerting wired to the new impl + factory.
- Cancel path tested (so the swap can be aborted if needed).