Building Upgradeable Vaults — Beacon Proxy Pattern
This guide explains how to build an upgradeable vault for the Flap protocol. The recommended pattern is OpenZeppelin BeaconProxy + UpgradeableBeacon, with the upgrade authority gated to the Flap Guardian for risk-control reasons.
The reference implementation lives in the FlapVaultExample repository:
Source:
src/FreeCoinBeacon.solBeacon-specific tests:
test/FreeCoinBeacon.mainnet.t.solBonding-curve and post-DEX dispatch tests (non-beacon variant, identical protocol surface):
test/FreeCoin.mainnet.t.sol
Overview
For a new vault factory that will deploy more than a handful of vault instances, the recommended setup is:
Implement the vault as an upgradeable implementation contract with
_disableInitializers()in the constructor and aninitialize(...)function gated by theinitializermodifier.In the factory's constructor, deploy the implementation and an
UpgradeableBeaconpointing at it. The factory becomes the beacon's owner.In
newVault(...), deploy aBeaconProxyand passabi.encodeCall(YourVault.initialize, (...))as the proxy's initialization calldata.Expose
upgradeVaultImplementation(address)andlockVaultUpgrades()on the factory, gated to the Flap Guardian only.Cover bonding-curve dispatch, post-DEX dispatch, the Guardian-only upgrade path, and the irreversible lock with integration tests.
The rest of this guide walks through the rationale and the reference code.
Why beacon proxies
For production vaults, deploy the vault implementation behind an UpgradeableBeacon and create one BeaconProxy per token. Compared with deploying many immutable vault instances:
Operational flexibility — if a bug or protocol change shows up after launch, upgrading a single beacon implementation rolls the fix out to every existing and future vault of this type. No redeployment, no migration ask of token creators.
Cleaner factory design — the factory ABI-encodes initialization data and hands it to a fresh
BeaconProxy, rather than duplicating constructor logic into every deployment path.Consistent multi-vault upgrades — one beacon coordinates upgrades across every vault instance of the same type.
Smaller audit surface — auditors and integrators reason about a single implementation contract plus a single beacon authority model.
If the vault is a one-instance-per-token, immutable-for-life design, inherit VaultBaseV2 directly and skip the proxy. The beacon pattern in this guide is the default recommendation for factories that deploy many vault instances.
Why upgrade authority stays with the Guardian
Once a vault is upgradeable, the implementation address becomes the highest-risk asset in the setup. Anyone who can swap it can drain the vault, rewrite its accounting, or break dispatch. Gating that authority exclusively to the Flap Guardian is a risk-control requirement:
Centralized incident response. If a vulnerability is discovered post-launch — through monitoring, audit follow-up, or an incident on a sibling vault — the Guardian path enables an immediate fix without coordinating with each token creator.
Fewer privileged keys for vault developers to maintain. No HSM, multisig, or deployer EOA needs to remain online for the lifetime of the token on the developer's side. The upgrade key cannot be lost or compromised by the vault developer.
Single trust assumption for users. Users already trust the Flap protocol. A separate non-Guardian upgrade owner widens the trust surface without a corresponding benefit.
Optional irreversible immutability. When the project is ready, the Guardian can call
lockVaultUpgrades()to renounce beacon ownership permanently — making the immutability commitment on-chain and verifiable.
In practice this means: the factory must not retain a separate non-Guardian owner, proxy admin, beacon owner, upgrader role, deployer EOA, or external multisig with equivalent power. The factory should expose exactly two privileged entrypoints — upgradeVaultImplementation and lockVaultUpgrades — both gated to _getGuardian().
A factory that keeps any additional upgrade authority alongside the Guardian will not pass Flap's verification. The risk-control posture described above relies on the Guardian being the only path that can swap the implementation.
How to build it
The implementation contract
The implementation is the logic contract that all BeaconProxy instances delegate to. It looks like a normal vault except for three upgradeable-pattern obligations:
Three things to check off:
_disableInitializers()in the constructor — locks the bare implementation so nobody can callinitializeon it directly. Only proxies pointing at it can be initialized.initialize(...)replaces the constructor — every freshly deployedBeaconProxycalls this exactly once with the configuration the factory chose. Theinitializermodifier (from OpenZeppelin'sInitializable) makes the second call revert.Use OpenZeppelin's upgradeable variants —
ReentrancyGuardUpgradeableinstead ofReentrancyGuard, plus the matching__*_init()calls insideinitialize.
The rest of the contract (claim(), description(), vaultUISchema(), etc.) is shaped exactly like a non-upgradeable vault. Full source is in src/FreeCoinBeacon.sol.
The beacon factory
The factory extends VaultFactoryBaseV2 and owns the UpgradeableBeacon for this vault type:
What's happening:
Constructor deploys the implementation and an
UpgradeableBeaconpointing at it. The factory becomes the beacon's owner — the only account that can later callupgradeToon the underlyingOwnablebeacon.newVault(...)is invoked by VaultPortal during token launch. It deploys a freshBeaconProxy, passingabi.encodeCall(initialize, (...))as the proxy's initialization calldata. The proxy delegates to the beacon's current implementation.Quote-token whitelist is enforced by
isQuoteTokenSupportedand the V2.2 validation hook_validateBeforeLaunch— this factory accepts native BNB only.
Wiring the upgrade authority to the Guardian
Although the factory technically owns the beacon, the only externally callable upgrade path goes through Guardian-gated functions:
Two details worth highlighting:
After
lockVaultUpgrades(), the beacon'sowner()isaddress(0). From that point forward,upgradeToreverts at the beacon's ownonlyOwnercheck — even when the Guardian re-entersupgradeVaultImplementation, the call bottoms out inOwnable: caller is not the owner. The lock is on-chain irreversible.isVaultUpgradesLocked()derives from the beacon's owner, so it stays accurate even if the factory is later replaced or local tooling forgets state.
_getGuardian() is provided by VaultFactoryBaseV2 and resolves to the Flap Guardian address per chain — the address does not need to be hardcoded. Any additional permissioned function added to the factory or vault later should be gated the same way.
Deployment
Both reference scripts construct the factory; the implementation and beacon are deployed inline:
Run them with:
VaultPortal is permissionless, so the factory does not need to be registered anywhere after deployment. To clear the warning flag on Flap.sh, contact the Flap team to schedule the third-party audit.
Required tests
For an upgradeable vault, the test surface is bigger than for a plain vault: alongside the usual logic and dispatch tests, the upgrade-authority model must be asserted directly.
The reference test file test/FreeCoinBeacon.mainnet.t.sol covers the proxy / upgrade / lock surface. The bonding-curve and post-DEX dispatch tests are in test/FreeCoin.mainnet.t.sol and apply unchanged here, because the integration surface is identical (same VaultPortal, same TaxProcessor dispatch).
Minimum coverage expected before audit:
1
Registration — factory deploys an initialized proxy when called by VaultPortal
taxToken, maxReward, cooldown set; non-VaultPortal callers rejected
test_beaconFactoryDeploysInitializedProxyVault, test_factoryRejectsNonVaultPortalCaller
2
Initializer is locked — implementation cannot be re-initialized
initialize() reverts on both proxy and bare implementation after first call
test_beaconProxyClaimAndInitializerLock
3
Bonding-curve buy → dispatch → vault
Buy on BC, call dispatch(), assert vault BNB balance increased
test_buyOnBCAndDispatch (in FreeCoin.mainnet.t.sol)
4
DEX graduation → sell → dispatch → vault
Buy until graduation (status == 4), sell on DEX, dispatch, assert vault received BNB
test_graduateAndDispatchPostDEX (in FreeCoin.mainnet.t.sol)
5
Guardian can upgrade the beacon
Non-Guardian callers revert; Guardian successfully changes beaconImplementation()
test_guardianCanUpgradeBeaconImplementation, test_nonGuardianCannotUpgradeBeaconImplementation
6
Guardian can lock the beacon, and lock is irreversible
After lockVaultUpgrades(), isVaultUpgradesLocked() == true and any further upgrade reverts at the beacon's onlyOwner check
test_guardianCanLockVaultUpgrades, test_lockingIsIrreversibleAndIdempotentlyReverts
7
Vault logic & gating
Vault's own business rules (claim, cooldown, double-claim, failed transfer rollback, getters)
test_claimCooldownAndDoubleClaimReverts, test_claimRevertsWhenRecipientRejectsNativeTransfer, etc.
8
Schema sanity
vaultUISchema() and vaultDataSchema() return the shape the UI expects; factorySpecVersion() is "v2.2"; onBeforeLaunch(bytes) rejects non-native quote tokens with the correct reason
test_vaultUISchemaMetadata, test_factoryMetadataAndValidationHelpers
Why both bonding-curve and DEX tests are required
The vault's receive() is invoked indirectly through TaxProcessor.dispatch(). The dispatch pipeline behaves slightly differently in each phase:
Bonding curve. Each buy or sell on Portal applies the buy or sell tax, accumulates BNB on the
TaxProcessor, anddispatch()flushes it to the vault. Tests verify that dispatch reaches the vault and thatreceive()does not revert under that gas budget.Post-DEX (graduated). Once the bonding curve crosses the
FOUR_FIFTHSthreshold the token graduates to DEX. Sell-side tax is then collected through the token's own swap-and-distribute path, accumulated on theTaxProcessor, and dispatched. Tests verify that the same vault still receives BNB after graduation, since this is the path that runs for the entire post-launch lifetime of the token.
Both phases must be covered. A vault that handles bonding-curve dispatch correctly but reverts after DEX graduation is broken in production, and that bug is invisible without a graduation test.
A condensed sketch of the integration tests (full code in test/FreeCoin.mainnet.t.sol):
Always wrap multi-call helpers like _sell() and _buyOnBC() in vm.startPrank(...) / vm.stopPrank(). vm.prank() only covers the next external call, so the second internal call (e.g. swapExactInput after approve) silently runs as the wrong sender and produces confusing reverts. See the FlapVaultExample README for the full convention.
Tests for the upgrade and lock authority
Excerpts from test/FreeCoinBeacon.mainnet.t.sol:
These three tests are the minimum set demonstrating that the authority model holds: the Guardian is the only path in, non-Guardian callers are rejected, and locking is genuinely irreversible.
Pre-audit checklist
Once those are green, contact the Flap team to schedule the third-party audit and clear the warning flag on Flap.sh. See the Vault & VaultFactory specification page for the verification process.
Last updated