Access control
Who owns what across the DEX, what the admin and fee manager contracts can do, what the multisig gates, and what none of it can touch.
On this page
The shape of it
The vendored factories run on Solidity 0.5.16 and 0.7.6 and cannot inherit the platform's 0.8.28 ownership base. So instead of editing them, two purpose-built 0.8.28 contracts are installed as their privileged addresses and forward each capability behind a single check.
Both admin contracts inherit ApeOwnable, whose onlyOwner is:
require(msg.sender == manager.owner(), 'Only Owner');manager is a constant pointing at the platform GovernanceManager. Two
consequences worth internalizing:
- The Safe address is never stored in the DEX contracts. Rotating the Safe at the governance manager rotates authority over the whole DEX automatically, with no transaction on any DEX contract.
- The governance manager address is a compile-time constant. It cannot be
changed without redeploying every
ApeOwnablecontract.
ArmoryDexAdmin
function setV2FeeTo(address factory, address feeTo) external onlyOwner;
function setV2FeeToSetter(address factory, address newSetter) external onlyOwner;
function enableV3FeeAmount(address factory, uint24 fee, int24 tickSpacing) external onlyOwner;
function setV3FactoryOwner(address factory, address newOwner) external onlyOwner;
function setV3PoolFeeProtocol(address pool, uint8 feeProtocol0, uint8 feeProtocol1) external onlyOwner;
function collectV3Protocol(address pool, address recipient, uint128 amount0Requested, uint128 amount1Requested)
external onlyOwner returns (uint128 amount0, uint128 amount1);
function execute(address target, bytes calldata data) external payable onlyOwner returns (bytes memory);execute is an unrestricted call behind onlyOwner, reverting
Execute Failed. It exists because a 78-line adapter cannot anticipate every
future admin surface. Treat it as: the Safe can make this contract do anything
this contract is authorized to do.
V3 passthroughs require factory ownership
enableV3FeeAmount, setV3FactoryOwner, setV3PoolFeeProtocol and
collectV3Protocol all require this contract to be the V3 factory owner. It is
not; the fee manager is. Call the corresponding fee-manager surface unless
factory ownership changes.
ArmoryDexFeeManager
Owns the V3 factory. It closes a real gap in vanilla Uniswap V3: pools launch
with feeProtocol = 0 and the factory has no default hook, so a protocol
wanting the fee on has to remember to configure every pool one at a time.
Permissionless. Anyone, including you, can call these:
function applyDefaultFeeProtocol(address pool) public;
function applyDefaultFeeProtocolMany(address[] calldata pools) external;
function collectProtocol(address pool) public returns (uint128 amount0, uint128 amount1);
function collectProtocolMany(address[] calldata pools) external;
function feeReceiver() public view returns (address);applyDefaultFeeProtocol writes the configured default onto a pool, and is a
no-op rather than a revert in every case where it should not act: when the
default is zero, when this contract is not the factory owner, when the pool
carries a multisig override, or when the pool's feeProtocol is already
non-zero. That makes it safe to call unconditionally after creating a pool.
collectProtocol sweeps type(uint128).max of both sides to feeReceiver().
It is permissionless because the destination is fixed by config, not by the
caller. There is no recipient parameter to abuse.
Multisig-gated:
function setDefaultFeeProtocol(uint8 feeProtocol0, uint8 feeProtocol1) external onlyOwner;
function setPoolFeeProtocol(address pool, uint8 feeProtocol0, uint8 feeProtocol1) external onlyOwner;
function clearPoolFeeProtocolOverride(address pool) external onlyOwner;
function setFeeReceiver(address newReceiver) external onlyOwner;
function enableFeeAmount(uint24 fee, int24 tickSpacing) external onlyOwner;
function setFactoryOwner(address newOwner) external onlyOwner;_validateFeeProtocol enforces the pool's own rule on the default and on
setDefaultFeeProtocol: each side is 0, or an integer in [4, 10]. Anything
else reverts Invalid Fee Protocol.
setPoolFeeProtocol sets feeProtocolOverridden[pool] = true, which the
permissionless crank checks first. A deliberate setPoolFeeProtocol(pool, 0, 0)
is therefore durable: a keeper cannot undo it.
clearPoolFeeProtocolOverride returns the pool to default-tracking.
feeReceiver() returns feeReceiverOverride when set, otherwise
manager.owner(). Setting the override to zero resets it to the Safe.
Events: DefaultFeeProtocolUpdated, DefaultApplied,
PoolFeeProtocolOverridden, FeeReceiverUpdated, ProtocolFeesCollected.
What the admin layer can and cannot do
That last block is the substantive answer for anyone deciding whether to build on these contracts. The admin layer's entire reach is fee routing and fee-tier enablement. It cannot stop a swap, seize liquidity, or change pricing on a pool that already exists.
What a Safe rotation does and does not follow
Two protocol-fee destinations resolve the multisig differently, and only one follows a rotation on its own.
feeTo is the one static pointer in the system. Rotating the Safe without also
calling ArmoryDexAdmin.setV2FeeTo(v2Factory, newSafe) leaves V2 protocol LP
minting to the old address, with no failed transaction to signal it. The V2 fee
is realized lazily on mint and burn, as described on
V2 core, so it just accrues quietly to the wrong owner.
Reading the live state
Never infer authority from documentation. These are all plain reads:
const [v2FeeTo, v2FeeToSetter, v3Owner, feeReceiver] = await Promise.all([
publicClient.readContract({ address: CONTRACTS.v2Factory, abi: v2FactoryAbi, functionName: "feeTo" }),
publicClient.readContract({ address: CONTRACTS.v2Factory, abi: v2FactoryAbi, functionName: "feeToSetter" }),
publicClient.readContract({ address: CONTRACTS.v3Factory, abi: v3FactoryAbi, functionName: "owner" }),
publicClient.readContract({ address: CONTRACTS.dexFeeManager, abi: feeManagerAbi, functionName: "feeReceiver" }),
]);
// per-pool: nibble 0 is token0's side, nibble 1 is token1's
const slot0 = await publicClient.readContract({ address: pool, abi: v3PoolAbi, functionName: "slot0" });
const feeProtocol0 = Number(slot0[5]) % 16;
const feeProtocol1 = Number(slot0[5]) >> 4;Configured values can change through governance. Read the relevant getter before building a transaction that depends on one.