This document describes the security features, access control model, threat mitigations, and audit considerations for the Ajuna Token Swap system.
- Security Architecture Overview
- UUPS Proxy Upgradeability
- Access Control: AjunaERC20
- Access Control: AjunaWrapper
- Reentrancy Protection
- Pausable Circuit Breaker
- Initial Allowlist Gate
- Token Rescue
- The Mint-and-Lock Invariant
- BurnFrom Approval Pattern
- Storage Gaps
- Implementation Sealing
- Initializer Validation
- Immutable Foreign Asset Address
- Known Risks & Mitigations
- Production Hardening Checklist
- Audit Scope
┌────────────────────────────────────────────────────────────────────┐
│ Security Layers │
├────────────────────────────────────────────────────────────────────┤
│ Layer 1: UUPS Proxy — Upgradeable with authorization │
│ Layer 2: Access Control — Role-gated mint/burn/upgrade │
│ Layer 3: Reentrancy Guard — Prevents re-entrant calls │
│ Layer 4: Pausable — Emergency circuit breaker │
│ Layer 5: Input Validation — Zero-address and zero-amount │
│ Layer 6: Invariant Checks — totalSupply == locked balance │
│ Layer 7: Approval Pattern — burnFrom requires allowance │
└────────────────────────────────────────────────────────────────────┘
Both contracts use the UUPS (Universal Upgradeable Proxy Standard) pattern from OpenZeppelin v5.
- The proxy (ERC1967Proxy) stores all state and delegates calls to the implementation
- The
upgradeTo()/upgradeToAndCall()function is on the implementation, not the proxy - This means the implementation itself controls who can authorize an upgrade
- If the implementation does not include UUPS logic, it becomes permanently non-upgradeable
| Contract | Who Can Upgrade | Enforcement |
|---|---|---|
| AjunaERC20 | UPGRADER_ROLE holders |
_authorizeUpgrade() uses onlyRole(UPGRADER_ROLE) |
| AjunaWrapper | Contract owner | _authorizeUpgrade() uses onlyOwner |
- Gas efficiency: No admin-slot check on every call (unlike TransparentUpgradeableProxy)
- Smaller proxy: ERC1967Proxy is minimal — just stores implementation address + delegates
- Explicit opt-in: Each upgrade requires authorization in the implementation itself
- Fail-safe: If a new implementation omits
_authorizeUpgrade, the contract becomes immutable
For detailed upgrade procedures, see UPGRADE.md.
AjunaERC20 uses OpenZeppelin's AccessControlDefaultAdminRulesUpgradeable
with three roles. The DEFAULT_ADMIN_ROLE follows a two-step transfer with
a configurable delay (typo-resistant); MINTER_ROLE and UPGRADER_ROLE use
the standard single-step grantRole / revokeRole flow.
| Role | Hash | Granted To | Permissions |
|---|---|---|---|
DEFAULT_ADMIN_ROLE |
0x00 |
Exactly one address (deployer initially) | Grant/revoke UPGRADER_ROLE; call bindMinter once. Transfer is two-step with a delay. |
MINTER_ROLE |
keccak256("MINTER_ROLE") |
AjunaWrapper proxy (via bindMinter) |
mint(), burnFrom() |
UPGRADER_ROLE |
keccak256("UPGRADER_ROLE") |
Deployer (initially) | upgradeTo(), upgradeToAndCall() |
Audit ATS-04 (audit/REPORT.md) flagged the unenforced
coupling between wrapper.owner() and erc20.defaultAdmin(): a divergent
ERC20 admin could grant MINTER_ROLE to themselves and mint unbacked wAJUN.
The fix is on-chain: AjunaERC20 exposes a one-shot
bindMinter(address) callable by DEFAULT_ADMIN_ROLE. It atomically sets
boundMinter = wrapperProxy and grants MINTER_ROLE to that address.
Subsequent grants of MINTER_ROLE to any address other than boundMinter
revert with "AjunaERC20: MINTER_ROLE bound to a single address". The
deploy script (scripts/deploy_wrapper.ts) calls bindMinter immediately
after wrapper deployment.
The bound address is the wrapper proxy, which is stable across UUPS
upgrades — only the wrapper's implementation changes, not its address. So
bindMinter does not constrain future wrapper logic upgrades.
- The deployer does NOT receive
MINTER_ROLE— only the Wrapper can mint and burn. DEFAULT_ADMIN_ROLEis the admin for all roles — it can grant/revokeMINTER_ROLEandUPGRADER_ROLE.- Role hierarchy:
DEFAULT_ADMIN_ROLE→ manages →MINTER_ROLE,UPGRADER_ROLE. - Exactly one
DEFAULT_ADMIN_ROLEholder at all times — the rules contract enforces this. There is never an interregnum where role management is impossible due to a half-finished transfer.
The DEFAULT_ADMIN_ROLE is the highest-impact role on the ERC20 (it can grant MINTER_ROLE to anyone, breaking the 1:1 backing). To prevent typo-irreversibility — exactly the failure mode the wrapper avoids via Ownable2Step — the contract uses OZ's AccessControlDefaultAdminRulesUpgradeable. Transfer is a two-step flow with a delay:
// Step 1 (current admin): propose transfer. Optionally cancellable.
token.beginDefaultAdminTransfer(newAdmin);
// (wait for `defaultAdminDelay()` seconds — production deploy sets ~5 days)
// Step 2 (proposed admin): accept. Atomic, in a single tx by the new admin.
token.acceptDefaultAdminTransfer();If the current admin spots a typo before the new admin accepts:
token.cancelDefaultAdminTransfer();grantRole(DEFAULT_ADMIN_ROLE, ...) and direct renounceRole(DEFAULT_ADMIN_ROLE, ...) are blocked by the rules contract — they would bypass the two-step flow and the exactly-one-admin invariant.
The initial delay is set in initialize() (5 days for production via ADMIN_DELAY_SECS=432000 in the deploy script; 0 for local tests).
For production:
- Grant
UPGRADER_ROLEto the multisig (single-step, immediate). beginDefaultAdminTransfer(multisig)from the deployer (starts the delay timer).- Wait for the configured delay (e.g., 5 days).
- Multisig calls
acceptDefaultAdminTransfer()— atomic transfer ofDEFAULT_ADMIN_ROLE. - Renounce
UPGRADER_ROLEfrom the deployer (single-step from deployer).
// Step 1: deployer grants UPGRADER to the multisig.
token.grantRole(UPGRADER_ROLE, multisigAddress);
// Step 2: deployer initiates the two-step admin handoff.
token.beginDefaultAdminTransfer(multisigAddress);
// (wait `defaultAdminDelay()` seconds — production: 5 days)
// Step 3: multisig completes the handoff.
token.connect(multisig).acceptDefaultAdminTransfer();
// Step 4: deployer drops their no-longer-needed UPGRADER_ROLE.
token.renounceRole(UPGRADER_ROLE, deployerAddress);The deployer never has a window where they can be locked out by a typo — beginDefaultAdminTransfer is reversible until acceptDefaultAdminTransfer lands, and the multisig must demonstrate it can sign a transaction by calling acceptDefaultAdminTransfer before the role moves.
AjunaWrapper uses OpenZeppelin's OwnableUpgradeable with a single owner.
| Action | Access |
|---|---|
pause() / unpause() |
onlyOwner |
rescueToken() |
onlyOwner |
upgradeTo() / upgradeToAndCall() |
onlyOwner |
transferOwnership() |
onlyOwner |
AjunaWrapper inherits Ownable2StepUpgradeable, which uses the standard
two-step ownership transfer flow:
// Step 1: current owner proposes
wrapper.transferOwnership(newOwner);
// → emits OwnershipTransferStarted(currentOwner, newOwner)
// → owner() unchanged; pendingOwner() == newOwner
// Step 2: proposed owner accepts (must be msg.sender)
wrapper.acceptOwnership();
// → emits OwnershipTransferred(currentOwner, newOwner)
// → owner() == newOwner; pendingOwner() clearedThe current owner retains full control until acceptOwnership() is called by
the proposed owner. This prevents transfers to wrong, uncontrolled, or
unaware addresses — a single typo no longer hands the wrapper to the wrong
party irrecoverably.
Cancelling a pending transfer is done by re-calling transferOwnership with
a different address (or address(0) to clear the pending owner without
re-proposing).
renounceOwnership() is overridden to always revert. The wrapper relies on a
live owner for pause / unpause, rescueToken, and UUPS upgrade
authorization. Renouncing ownership would permanently brick all of these
levers on a treasury that holds user funds — an unrecoverable state.
If a contract needs to be made permanently non-upgradeable in the future,
the correct path is a deliberate UUPS upgrade to an implementation whose
_authorizeUpgrade always reverts (see UPGRADE.md →
"Making a Contract Non-Upgradeable"). Pause and rescue capabilities are
preserved by such an upgrade; renouncing ownership would discard them.
Both deposit() and withdraw() on AjunaWrapper are protected by ReentrancyGuard (OpenZeppelin's stateless guard from @openzeppelin/contracts/utils/ReentrancyGuard.sol, namespaced storage at openzeppelin.storage.ReentrancyGuard):
function deposit(uint256 amount) external nonReentrant whenNotPaused { ... }
function withdraw(uint256 amount) external nonReentrant whenNotPaused { ... }The deposit() function calls foreignAsset.transferFrom() which is an external call to an untrusted contract. Without reentrancy protection, a malicious foreign asset contract could re-enter deposit() or withdraw() during the transfer.
The nonReentrant modifier uses a mutex lock — if the function is called while already executing, it reverts with ReentrancyGuardReentrantCall().
The owner can pause all user-facing operations in an emergency:
// Pause — blocks deposit() and withdraw()
wrapper.pause();
// Resume
wrapper.unpause();| Function | Paused? |
|---|---|
deposit() |
Yes — whenNotPaused |
withdraw() |
Yes — whenNotPaused |
pause() / unpause() |
No — always callable by owner |
rescueToken() |
No — always callable by owner |
upgradeTo() |
No — always callable by owner |
ERC20 transfer(), approve() |
No — wAJUN transfers remain active |
- Critical vulnerability discovered in the contract
- Suspicious activity detected (e.g., unusual large wraps/unwraps)
- Foreign asset precompile change pending — pause, update address, unpause
- During a planned contract upgrade
The wrapper ships with an owner-controlled allowlist that gates deposit() only. It exists so a fresh production deployment can be smoke-tested under real on-chain conditions before opening to the public. withdraw() is never gated by the allowlist — once a user holds wAJUN, redemption is permissionless and cannot be revoked by the owner.
| Variable | Type | Default after initialize() |
|---|---|---|
allowlistEnabled |
bool |
true |
allowlisted |
mapping(address => bool) |
empty |
When allowlistEnabled == true, the onlyAllowedUser modifier on deposit requires either:
msg.sender == owner()— implicitly always allowed, regardless of the mapping or the flag, ORallowlisted[msg.sender] == true.
When allowlistEnabled == false, the modifier is a no-op and deposit behaves as an open wrapper. withdraw does not consult the allowlist in either state — redemption is always permissionless.
Why deposit-only — gating withdraw would create a censorship surface where the owner can freeze users' redemption rights post-deposit. The system already has a global circuit breaker (pause()) for emergencies; per-user redemption gating is unnecessary and qualitatively worse than denying entry. See docs/REVIEW_v2.md MED-1 for the full rationale.
The current owner() is implicitly always allowed to deposit / withdraw. Concretely:
- The owner can never be locked out, even if the allowlist mapping is empty.
- Calling
setAllowlist(owner, false)has no effect on the owner's ability to swap — the modifier short-circuits before reading the mapping. - After a multisig handoff, the moment the multisig calls
acceptOwnership(), it gains immediatedeposit/withdrawaccess without needing a separatesetAllowlist(multisig, true)transaction. This is what enables Phase 6B (seeding AJUN dust) to be executed by the multisig directly. - The previously-current owner loses this implicit privilege as soon as the new owner accepts; it tracks
owner(), not a snapshot.
| Function | Effect |
|---|---|
setAllowlistEnabled(bool) |
Flip the gate on or off |
setAllowlist(address, bool) |
Add or remove a single account |
setAllowlistBatch(address[], bool) |
Bulk add or bulk remove |
All three revert when called by a non-owner. setAllowlist and setAllowlistBatch reject address(0).
After Phase 7 verification on production succeeds, opening the wrapper to everyone is a single transaction:
wrapper.setAllowlistEnabled(false);This is reversible — calling it again with true re-restricts immediately, so the gate doubles as a fine-grained "soft pause" for surgical interventions (e.g. blocking a flagged address) without freezing the whole system the way pause() does.
The owner can re-enable the allowlist after going public. This is intentional and is strictly less powerful than capabilities the owner already has (pause, _authorizeUpgrade). If a stronger guarantee of "permanently permissionless" is desired, deploy a UUPS upgrade to an implementation that hard-codes allowlistEnabled = false and removes the setters — but this trades a low-risk operational lever for a UUPS upgrade event, which is the highest-risk operation in the system.
The allowlist gate only applies to deposit and withdraw on the wrapper. It does not restrict:
- ERC20 transfers / approvals on the wAJUN token (those follow the standard ERC20 semantics on the AjunaERC20 contract)
- View functions on either contract
- Owner-only admin functions (already access-controlled)
A non-allowlisted account can still hold and transfer wAJUN that someone else minted for them — it just cannot mint new wAJUN or unwrap existing wAJUN until either it gets allowlisted or the gate is disabled.
If someone accidentally sends ERC20 tokens to the Wrapper contract, the owner can rescue them:
wrapper.rescueToken(tokenAddress, recipientAddress, amount);The rescue function cannot be used to withdraw the locked Foreign Asset:
require(tokenAddress != address(foreignAsset), "Cannot rescue locked foreign asset");This prevents the owner from breaking the 1:1 backing invariant by draining the treasury.
The core security property of the system:
This invariant holds because:
deposit(): transfers N foreign tokens into the wrapper, then mints N wAJUNwithdraw(): burns N wAJUN, then transfers N foreign tokens out of the wrapper- No other function can mint, burn, or move the locked foreign asset
The test suite verifies this invariant after every operation:
const totalSupply = await erc20.totalSupply();
const treasuryBalance = await foreignAsset.balanceOf(wrapperAddress);
expect(totalSupply).to.equal(treasuryBalance);| Threat | Mitigation |
|---|---|
Owner drains locked tokens via rescueToken |
rescueToken blocks foreignAsset address |
| Direct transfer to wrapper (no mint) | Invariant becomes totalSupply < locked — safely over-collateralized |
| Re-entrancy double-mint | nonReentrant modifier on both functions |
| Foreign asset rebasing | Not applicable — AJUN is a fixed-supply asset |
The Wrapper cannot burn user tokens without explicit permission:
User → approve(wrapper, amount) → Wrapper → burnFrom(user, amount)
This uses the standard ERC20 _spendAllowance pattern:
function burnFrom(address from, uint256 amount) public onlyRole(MINTER_ROLE) {
_spendAllowance(from, _msgSender(), amount);
_burn(from, amount);
}- Users must opt-in to each withdrawal by approving the Wrapper
- The Wrapper cannot unilaterally drain user balances
- Each
burnFromdeducts from the caller's allowance, so users control exactly how much can be burned
Both contracts include reserved storage gaps for safe future upgrades:
// AjunaERC20 — 49 reserved slots
uint256[49] private __gap;
// AjunaWrapper — 48 reserved slots
uint256[48] private __gap;When adding new state variables to an upgraded implementation, the new variables occupy slots from the gap. This prevents storage collision with inherited contracts.
When adding N new state variables to an upgrade:
- Add the variables before
__gap - Reduce
__gapsize by N
Example: Adding one new mapping to AjunaWrapper:
mapping(address => uint256) public newMapping; // Uses 1 slot
uint256[47] private __gap; // Was 48, now 47For more details, see UPGRADE.md.
Both implementations have their initializers disabled in the constructor:
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}Without this, someone could call initialize() directly on the implementation contract (not the proxy), setting themselves as admin/owner of the implementation. While this doesn't affect the proxy's state, it's a defense-in-depth measure that prevents confusion and potential exploits in edge cases.
it("should prevent calling initialize on implementation directly", async () => {
const impl = await ethers.deployContract("AjunaERC20");
await expect(
impl.initialize("X", "X", owner.address, 12)
).to.be.revertedWithCustomError(impl, "InvalidInitialization");
});Both initialize() functions validate their inputs:
// AjunaERC20
require(admin != address(0), "AjunaERC20: admin is zero address");
// AjunaWrapper
require(_token != address(0), "AjunaWrapper: token is zero address");
require(_foreignAssetPrecompile != address(0), "AjunaWrapper: precompile is zero address");Re-initialization is blocked by OpenZeppelin's initializer modifier, which prevents calling initialize() more than once.
The foreign asset precompile address is set once during initialize() and cannot be changed. This is a deliberate security decision — it prevents an owner key compromise from redirecting the wrapper to a malicious token contract.
If the precompile address ever needs to change (e.g., asset ID reassignment), the recommended procedure is:
- Pause the old wrapper:
wrapper.pause() - Deploy a new implementation with the updated address via UUPS upgrade
- Verify the new precompile responds correctly
- Unpause:
wrapper.unpause()
| Risk | Severity | Mitigation |
|---|---|---|
| Owner key compromise | Critical | Transfer to multisig post-deployment; use hardware wallets |
| Upgrader key compromise | Critical | Transfer UPGRADER_ROLE to multisig; renounce from deployer |
| Malicious upgrade | Critical | Multisig governance; timelock on upgrades (recommended) |
| Foreign asset precompile removed | High | Deploy new implementation via UUPS upgrade; pause first |
| Existential deposit reaping | Medium | Fund Wrapper with 1–2 DOT after deployment |
| Storage collision on upgrade | Medium | Use __gap correctly; test upgrade with OpenZeppelin plugin |
| Front-running approval | Low | Standard ERC20 issue; use increaseAllowance pattern |
| Wrapper receives tokens directly | Low | Over-collateralizes invariant; no negative impact |
| PVM SELFDESTRUCT semantics (audit ATS-10) | Informational | The _authorizeUpgrade check newImplementation.code.length > 0 is a snapshot at upgrade time. On EVM-Cancun, EIP-6780 makes SELFDESTRUCT a no-op for non-creation-tx contracts, so a deployed implementation cannot be cleared. pallet-revive is assumed to follow the same semantics; the contract does not defensively re-check extcodehash post-upgrade. Verify against the runtime version targeted at deploy time. |
| PVM EIP-1153 (transient storage) (audit ATS-09 follow-on) | Informational | The contract uses an inline reentrancy guard at the namespaced storage slot (not transient). No dependency on EIP-1153. A future migration to ReentrancyGuardTransient would require explicit verification of pallet-revive TSTORE/TLOAD support. |
Before going live, ensure:
- All roles transferred to a multisig (3-of-5 or 4-of-7 recommended)
- Deployer has renounced all privileged roles
- Existential deposit sent to Wrapper proxy (1–2 DOT)
- Timelock contract deployed in front of multisig (recommended: 24–48h delay).
The contract is
TimelockControllerfrom OpenZeppelin; the deploy scriptscripts/deploy_timelock.tsis in-repo and ready. The ownership-handover procedure isdocs/PRODUCTION-CHECKLIST.mdPhase 10B. The timelock is intentionally NOT installed at deploy time — under the allowlist gate (Phases 4–10), only the team has funds at risk, so a fast multisig owner is correct. Install the timelock before flippingsetAllowlistEnabled(false)in Phase 11. - Monitoring set up for:
Deposited/Withdrawnevents (unusual volumes)Paused/UnpausedeventsUpgradedevents (proxy implementation change)- Invariant drift:
totalSupply != foreignAsset.balanceOf(wrapper)
- Audit completed and findings addressed
- Bug bounty program established
- Emergency response plan documented (who can pause, when to pause, communication channels)
An audit of this system should cover:
contracts/AjunaERC20.sol— UUPS upgradeable ERC20 with AccessControlcontracts/AjunaWrapper.sol— UUPS upgradeable treasury with Pausable, Reentrancy, Ownablecontracts/Proxy.sol— ERC1967Proxy import (standard OpenZeppelin)contracts/interfaces/IERC20Precompile.sol— Interface definition
- Deposit flow:
approve→transferFrom→mint— correct ordering, reentrancy safety - Withdraw flow:
burnFrom(with allowance) →transfer— correct ordering, reentrancy safety - Upgrade flow:
upgradeTo→ storage preserved, authorization enforced - Pause flow:
pause→ blocks deposits/withdrawals →unpause→ resumes - Role management: Grant, revoke, renounce — proper access control hierarchy
- Invariant maintenance:
totalSupply == locked balanceacross all code paths
- OpenZeppelin library contracts (separately audited)
- Hardhat configuration and deployment scripts
- Frontend (frontend/app.html, frontend/test-ui.html)
polkadot-sdksubtree