Roles and Permissions
Roles & Permissions
Companion to ARCHITECTURE.md, written for auditors and counterparty diligence. This document enumerates every privileged role across the three proxies, every function each role can call, and what each role can and cannot cause to happen.
Every entry is sourced from the code. Where a capability is operational policy rather than an onchain constraint, it is marked as such.
Summary
DEFAULT_ADMIN_ROLE
all three
TimelockController (48h)
Yes (via upgrade, emergencyRecover)
Yes (via upgrade)
ADMIN_ROLE
all three
TimelockController (48h)
Yes (emergencyRecover, whitelist)
Indirectly (parameters)
GUARDIAN_ROLE
TownSqToken, Manager
Safe
No
No
OPERATOR_ROLE
Manager
Executor (bot/Safe)
Yes (execute)
No
REBALANCER_ROLE
Yield
Offchain yield engine
No
Yes (entirely)
Role constants:
DEFAULT_ADMIN_ROLE
0x00
ADMIN_ROLE
keccak256("ADMIN_ROLE")
OPERATOR_ROLE
keccak256("OPERATOR_ROLE")
REBALANCER_ROLE
keccak256("REBALANCER_ROLE")
All three contracts use OpenZeppelin AccessControlUpgradeable. DEFAULT_ADMIN_ROLE is the admin of every other role, so it can grant and revoke ADMIN_ROLE, OPERATOR_ROLE and REBALANCER_ROLE at will, on any of the three proxies, with no delay.
DEFAULT_ADMIN_ROLE
Highest privilege. Holds two distinct powers.
Upgrade authority
_authorizeUpgrade(address) — TownSqToken.sol:115, Manager.sol:68, Yield.sol:93
All three contracts are UUPS. DEFAULT_ADMIN_ROLE can replace the implementation of any of them with arbitrary code, after the 48-hour timelock delay and with no upgrade-safety check enforced onchain. This subsumes every other capability in this document: a malicious upgrade can mint shares, rewrite accounting, or transfer custodied assets.
Role administration
grantRole / revokeRole on all five roles, across all three proxies. Effective after the 48-hour timelock delay.
Consequence: DEFAULT_ADMIN_ROLE is the root of trust for the entire protocol. It is held by the timelock, whose only proposer is the Safe — so the Safe's signing threshold, plus 48 hours of public notice, is what stands between the protocol and a hostile upgrade.
ADMIN_ROLE
Governance parameters plus two custody-adjacent powers. Note the mix — this role is not separable into "risk only" as currently written.
TownSqToken
setFee
Exit fee charged on redemption
<= ONE_PERCENT (1%), hard-coded
setCooldownPeriod
Delay between request and withdrawal
<= 7 days, hard-coded
setVestingPeriod
Linear profit vesting window
<= 30 days, hard-coded
setManager
Repoints the Manager address
non-zero only
pause / unpause
Halts deposit, withdraw, redeem, redeemFor, requestRedeem. pause is shared with GUARDIAN_ROLE; unpause is admin-only
none
emergencyRecover
Transfers any ERC-20 or native balance out of the vault
balance only
Two of these are not risk parameters:
emergencyRecovercan withdraw assets already earmarked for pending redemptions.totalRequestedAmountis not consulted. SeeKNOWN_ISSUES.md.setManagerrepoints the address authorised to callupdateTotalAssets,mintVaultTokenandburnVaultToken. A hostile Manager can forge vault accounting.pauseblocks the exit path, not just entry (KNOWN_ISSUES.mdK-01), so it is a fund-freezing power rather than a purely defensive one.
Manager
setWhitelistedAddresses
L100
The strategy/destination allowlist
cannot whitelist townSqToken or yield (L107)
setTreasury
L75
Destination for all fee accrual
non-zero only
setManagerAndYield
L86
Repoints Yield and TownSqToken
non-zero only
setMaxRedeemCap
L168
Max assets per redemption request
none; 0 == uncapped
setMinSharesInVaultToken
L157
Minimum shares per deposit
must be non-zero
removeWhitelistedAddresses
L152
Removes allowlist entries
removal only; shared with GUARDIAN_ROLE
setWhitelistedAddresses is the most consequential ADMIN_ROLE function. The whitelist is the only constraint on where custodied assets can be sent by OPERATOR_ROLE. Additions go through the 48-hour timelock, so a new destination is public 48 hours before capital can reach it.
The guard at L107 is load-bearing — it prevents whitelisting townSqToken or yield, which would otherwise let OPERATOR_ROLE reach mintVaultToken and updateTotalAssets through execute and forge share supply.
Yield
setMaxYieldPercentage
L122
Cap on reported P&L per epoch
1..10_000 bps of totalAssets(); default 50 = 0.5%
setMaxFeePercentage
L128
Performance fee share of profit
<= 10_000 bps; default 5000 = 50%
setMinDistributionInterval
L116
Minimum spacing between epochs
none
setManager
L138
Repoints the Manager address
—
setMaxFeePercentage accepts up to 10_000 — 100% of reported profit assignable to the treasury, after the 48-hour delay. Unlike the exit fee's hard-coded 1% ceiling, there is no structural bound here.
setMinDistributionInterval rejects 0 (L117) but accepts 1, so the rate limit protecting against a compromised rebalancer can be reduced to a single second. There is also no maximum interval and no staleness check anywhere: nothing obliges the rebalancer to report at all, and if reporting stops the share price simply freezes at its last value.
OPERATOR_ROLE
Manager only. Three functions. This role controls where the assets physically are.
execute(address[] targets, bytes[] data) — Manager.sol:119-140
Arbitrary calldata to any whitelisted target, batched, executed from the address custodying 100% of deposited assets. Within the whitelist the operator can:
transfer any amount of any asset to any whitelisted address
grant unlimited ERC-20 approvals to whitelisted targets
call any function with any arguments on any whitelisted contract
batch all of the above atomically
No amount cap, no per-target cap, no rate limit, no reentrancy guard. The whitelist and the L107 guard are the only constraints. This is the function through which capital leaves onchain infrastructure for the offchain trading book.
transferToVault(address token, uint256 amount) — Manager.sol:194-196
Pushes assets from Manager custody into the vault so pending redemptions can settle. The return leg of the flow.
This function is what makes withdrawal liveness an offchain dependency: per INVARIANTS.md I-22, "users can always eventually withdraw" does not hold. A user past cooldown cannot exit until an operator calls this, or until any address sends a plain ERC-20 transfer to the vault.
No zero-amount check, no event emitted, no NatSpec — the only function in Manager lacking all three.
redeemFor(address _townSqUSD, address _user) — Manager.sol:147-150
Executes a matured redemption on a user's behalf. Fully bounded: requires an existing request, cooldown elapsed, and pays the quoted amount to that user only (TownSqToken.sol:492-500). Cannot redirect funds or alter amounts. A UX convenience.
What OPERATOR_ROLE cannot do
Change the whitelist, grant roles, upgrade, pause, set parameters, set the treasury, report yield, or mint/burn shares.
REBALANCER_ROLE
Yield only. One function. This role controls what the books say the assets are worth.
distributeYield(uint256 amount, uint256 feeAmount, uint256 nonce, bool isProfit) — Yield.sol:171-281
There is no oracle in this system. totalAssets() moves only through deposits, redemptions, and this call. The rebalancer asserts P&L; the vault books it.
Effects:
On profit:
amountenters the linear vesting schedule for shareholders;feeAmountis minted to the treasury as shares, split intorecognisedFee(credited immediately) andvestingFee(rides the same schedule) so the recognised share price does not step up.On loss:
amountis debited instantly with no vesting; treasury shares are burned to absorbfeeAmount, clamped to the treasury's actual balance, with any uncovered remainder booked totreasuryDebt.Debt settlement: outstanding
treasuryDebtis repaid out of futurefeeAmountand routed to shareholders before the treasury earns anything new (L201-207).
Constraints, in full:
amount <= totalAssets * maxYieldPercentage / 10_000
L176
Default 0.5% of TVL per epoch
feeAmount <= amount * maxFeePercentage / 10_000
L177
Default 50% of profit
nonce == epoch + 1
L180
Strictly sequential; primary replay defence
trxns[proofHash]
L182-184
Duplicate rejection
lastDistributionTime + minDistributionInterval
L186-188
Default 79200s (22h)
Two notes on these guards:
proofHashhashes(vaultToken, amount, nonce, isProfit)—feeAmountis excluded from the replay identity. Thenoncecheck is what actually prevents replay.The two
requires atL176-177carry no message and no custom error, unlike every other check in the file. A distribution rejected by a cap reverts with no reason string.
What REBALANCER_ROLE cannot do
Move any asset, change the whitelist, grant roles, upgrade, pause, or set parameters. Its entire surface is the accounting.
Separation of duties
The intended split:
OPERATOR_ROLEcontrols where the money is.REBALANCER_ROLEcontrols what the books say it is worth.ADMIN_ROLEdecides the rules.DEFAULT_ADMIN_ROLEdecides the code.
Compromise analysis:
REBALANCER_ROLE
Mispriced redemptions; value leaks gradually
0.5%/epoch, 22h interval
OPERATOR_ROLE
Assets sent to whitelisted destinations
The whitelist
GUARDIAN_ROLE
Vault paused, whitelist emptied — denial of service
ADMIN_ROLE can undo, after 48h
ADMIN_ROLE
Whitelist any address, then emergencyRecover; 100% fee
48h public delay; Safe must propose
DEFAULT_ADMIN_ROLE
Total; can grant itself every other role or upgrade
48h public delay; Safe must propose
Known weaknesses in the current separation:
The timelock delays, it does not prevent. One Safe is proposer, executor, canceller and guardian. It can pause instantly while unpausing takes 48 hours, so a pause can remove depositors' exit window during a pending change.
ADMIN_ROLEmixes risk parameters with custody powers.setFeeandemergencyRecoversit behind the same role, so delegating parameter management is impossible without delegating fund extraction. This is the gap aCURATOR_ROLEwould fill.ADMIN_ROLEandOPERATOR_ROLEmay be the same key. The whitelist setter and the fund mover must be distinct for the whitelist to function as a control.setMaxFeePercentageandsetMinDistributionIntervalare unbounded in the directions that matter (100% fee; a 1-second interval, and no maximum interval or staleness check at all).
Timelock model
Live. The handover batch has executed.
What changed
DEFAULT_ADMIN_ROLE and ADMIN_ROLE moved from the Safe to an OpenZeppelin TimelockController with a 48-hour minDelay, deployed with admin == address(0) so no standing bypass exists. There is no code coupling — no contract imports or stores the timelock address. The vault sees a call from an address holding ADMIN_ROLE and has no idea a delay occurred.
Upgrades inherit the delay at no cost: _authorizeUpgrade was already onlyRole(DEFAULT_ADMIN_ROLE), so the role move is sufficient.
The fifth role
GUARDIAN_ROLE = keccak256("GUARDIAN_ROLE"), declared on TownSqToken and Manager. It exists only because TimelockController applies one uniform delay and two actions cannot tolerate it.
pause() (TownSqToken.sol)
unpause()
removeWhitelistedAddresses() (Manager.sol)
setWhitelistedAddresses()
Any parameter, upgrade, or role grant
The asymmetry is the point: a compromised guardian can only cause a denial of service that ADMIN_ROLE can undo. It can never open a path for funds to leave, and it can never lift a halt it caused. Pause-fast/resume-slow is deliberate — pause blocks user withdrawals (K-01), so lifting it should carry the delay and the visibility.
Roles now
DEFAULT_ADMIN_ROLE
TimelockController
48h
ADMIN_ROLE
TimelockController
48h
GUARDIAN_ROLE
Safe
none — pause, whitelist removal only
OPERATOR_ROLE
Executor
none — unchanged
REBALANCER_ROLE
Offchain engine
none — unchanged
The operational roles are deliberately untouched. A 48-hour delay on execute or distributeYield would break normal operation, and neither can change the rules. script/TimelockRoleChange.s.sol asserts at runtime that its batch never touches either.
Events
Every privileged action emits. Useful for monitoring and for diligence evidence.
WhitelistUpdated
Manager
Destination allowlist change
BatchExecuted
Manager
execute call, with full targets and calldata
TreasurySet
Manager
Fee destination change
MaxRedeemCapSet, MinSharesInVaultTokenSet
Manager
Capacity parameter change
DistributeYield
Yield
Epoch P&L report
TreasuryDebtAccrued, TreasuryDebtRepaid
Yield
Unabsorbed loss / settlement
MaxYieldPercentageSet, MaxFeePercentage, MinDistributionIntervalSet
Yield
Yield parameter change
FeeSet, CooldownPeriodUpdated, VestingPeriodUpdated
TownSqToken
Vault parameter change
EmergencyRecovery
TownSqToken
Asset extraction
Paused, Unpaused
TownSqToken
Pause state (OZ Pausable)
RoleGranted, RoleRevoked
all three
Role rotation (OZ AccessControl)
Upgraded
all three
Implementation change (OZ ERC1967)
Two monitoring gaps:
Manager.transferToVaultemits nothing. Redemption funding — the step withdrawal liveness depends on — is observable only via the underlying ERC-20Transfer.AssetsWithdrawnis declared inIManagerbut never emitted anywhere insrc/. Do not build monitoring against it.
Note also that Yield's fee-cap event is named MaxFeePercentage, not MaxFeePercentageSet — it breaks the naming convention every other setter event follows.
Last updated