For the complete documentation index, see llms.txt. This page is also available as Markdown.

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:

Overview

For a new vault factory that will deploy more than a handful of vault instances, the recommended setup is:

  1. Implement the vault as an upgradeable implementation contract with _disableInitializers() in the constructor and an initialize(...) function gated by the initializer modifier.

  2. In the factory's constructor, deploy the implementation and an UpgradeableBeacon pointing at it. The factory becomes the beacon's owner.

  3. In newVault(...), deploy a BeaconProxy and pass abi.encodeCall(YourVault.initialize, (...)) as the proxy's initialization calldata.

  4. Expose upgradeVaultImplementation(address) and lockVaultUpgrades() on the factory, gated to the Flap Guardian only.

  5. 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:

  1. 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.

  2. 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.

  3. Consistent multi-vault upgrades — one beacon coordinates upgrades across every vault instance of the same type.

  4. 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().

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 call initialize on it directly. Only proxies pointing at it can be initialized.

  • initialize(...) replaces the constructor — every freshly deployed BeaconProxy calls this exactly once with the configuration the factory chose. The initializer modifier (from OpenZeppelin's Initializable) makes the second call revert.

  • Use OpenZeppelin's upgradeable variantsReentrancyGuardUpgradeable instead of ReentrancyGuard, plus the matching __*_init() calls inside initialize.

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:

  1. Constructor deploys the implementation and an UpgradeableBeacon pointing at it. The factory becomes the beacon's owner — the only account that can later call upgradeTo on the underlying Ownable beacon.

  2. newVault(...) is invoked by VaultPortal during token launch. It deploys a fresh BeaconProxy, passing abi.encodeCall(initialize, (...)) as the proxy's initialization calldata. The proxy delegates to the beacon's current implementation.

  3. Quote-token whitelist is enforced by isQuoteTokenSupported and 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's owner() is address(0). From that point forward, upgradeTo reverts at the beacon's own onlyOwner check — even when the Guardian re-enters upgradeVaultImplementation, the call bottoms out in Ownable: 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:

#
Scenario
What to assert
Reference test

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, and dispatch() flushes it to the vault. Tests verify that dispatch reaches the vault and that receive() does not revert under that gas budget.

  • Post-DEX (graduated). Once the bonding curve crosses the FOUR_FIFTHS threshold the token graduates to DEX. Sell-side tax is then collected through the token's own swap-and-distribute path, accumulated on the TaxProcessor, 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):

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