Building a custom vault
You can write your own vault, a contract that receives a CheesePad tax token's revenue and does whatever you design with it: split it, stream it, lock it, buy back, donate. You do not need anything whitelisted and you do not touch CheesePad's own contracts. You ship two contracts, a vault and a vault factory, and a token creator points their launch at your factory. If your factory passes the launch checks, your vault is deployed and wired up automatically.
The quickest path is to extend the two base contracts CheesePad provides, VaultBase and
VaultFactoryBase, which handle the boilerplate and leave you the interesting parts.
Contents
- The mental model
- The launch handshake
- Extend the base contracts
- Three rules for receiving revenue
- The two schemas
- Native vs ERC20 revenue mode
- A complete minimal example
- Checklist before you ship
- Gotchas
- Reference implementations
The mental model
tax on every buy/sell
trader ──swap──▶ tax token ──▶ settles tax ──splits revenue──▶ YOUR VAULT
│
you decide what happens here
(distribute / claim / lock / ...)
- A token's tax is collected and settled in batches, not on every trade.
- When it settles, the fee-receiver slice is sent to whatever address is the token's fee receiver. For a vault launch, that address is your vault.
- Revenue arrives as native BNB if the sale's quote token is WBNB, or as the quote ERC20 otherwise. Your vault is told which at construction.
Two contracts, two jobs:
| Contract | Job |
|---|---|
| Vault | Holds revenue, implements your payout logic (distribute, claim, ...). One per launch. |
| Vault factory | Validates a launch and deploys a fresh vault for it. One factory serves many launches. |
The launch handshake
When a creator launches with your factory (createMicroSaleTaxTokenWithVaultV2, or the Melt Pool
equivalent createBondingCurveTaxTokenWithVaultV2), CheesePad runs these checks fail-closed. Any
failure reverts the whole launch:
factory.diamond()returns the launchpad address, proving your factory targets the right platform.factory.supportsQuoteToken(quoteToken)returns true.factory.supportsLaunchRevision(1)returns true.factory.reviewLaunch(payload)returns(true, ""), your veto hook.factory.createVault(quoteToken, creator, vaultData)deploys the vault.- The vault's
isNativeRevenue()equalsquoteToken == WBNB. A mismatch reverts. - The launchpad binds the vault as the token's fee receiver and calls
vault.bind(token)once.
After this, your vault is live and receiving revenue. You never call CheesePad; it calls you.
Extend the base contracts
The vault: extend VaultBase
VaultBase gives you, for free:
| Provided | What it is |
|---|---|
diamond, quoteToken, creator, isNativeRevenue | immutable launch context |
token, bound | the bound tax token, set once by the launchpad via bind |
bind(token) | launchpad-only, one-time; you do not override it |
receive() | accepts native revenue only when isNativeRevenue, rejects the wrong asset |
onRevenue(amount) | best-effort signal from the token when it settles; emits RevenueReceived |
_revenueBalance() | your current revenue balance, in the right asset |
_pay(payee, amount) | pays in the right asset; returns false instead of reverting, and tolerates no-return-value tokens like USDT |
platform() | the platform backstop address, if you opt into it |
You implement three functions (from IVault):
function distribute() external; // permissionless payout poke
function description() external view returns (string memory); // one-liner for listings
function vaultUISchema() external view returns (VaultUISchema memory); // see The two schemas
The constructor takes the launch context:
constructor(address _diamond, address _quoteToken, address _creator, bool _isNativeRevenue)
The factory: extend VaultFactoryBase
VaultFactoryBase handles the revision plumbing and the launchpad-only guard. You implement:
function _reviewV1(LaunchDataV1 memory d) internal view returns (bool ok, string memory reason);
function createVault(address quoteToken, address creator, bytes calldata vaultData)
external onlyDiamond returns (address vault);
function supportsQuoteToken(address quoteToken) external view returns (bool);
function vaultDataSchema() external view returns (VaultDataSchema memory);
_reviewV1 is your veto: it receives the launch parameters (LaunchDataV1) and returns
(false, reason) to block a launch you do not want to serve. It does not receive vaultData, so you
validate launch params and per-vault params independently.
Three rules for receiving revenue
Revenue is delivered to your vault while the token is mid-settle. Break these and you can brick every taxed transfer of the token:
receive()andonRevenue()must be cheap and accrue-only. Bookkeeping and events only. No swaps, no external calls of consequence.- Never call back into the token from
receive()oronRevenue(). Reading it mid-settle can revert or reenter. - Do all payout work in a separate transaction. A permissionless
distribute()poke, or aclaim()pull. Never push funds out during revenue receipt.
This is why every CheesePad vault accrues on receipt and pays out later.
The two schemas
Your vault and factory are self-describing. A CheesePad UI reads two schemas and renders your creation form and your vault UI generically, with no code specific to your vault.
vaultDataSchema() on the factory: the creation form
Describes how vaultData is ABI-encoded, so a UI can render the launch form and encode the creator's
inputs.
function vaultDataSchema() external pure returns (VaultDataSchema memory s) {
s.description = "Fixed payee list; revenue splits by bps";
s.isArray = false; // vaultData = abi.encode(tuple); true => tuple[]
s.fields = new Field[](2);
s.fields[0] = Field("payees", FieldType.Address, true, 0, "Recipient addresses");
s.fields[1] = Field("bps", FieldType.Uint16, true, 0, "Basis points per payee (sum 10000)");
}
Your createVault then decodes vaultData in the exact order and types the schema declares:
(address[] memory payees, uint16[] memory bps) = abi.decode(vaultData, (address[], uint16[]));
vaultUISchema() on the vault: actions and readable state
A list of methods. Each is either a write action (a UI renders a button and an input form) or a read
field (isWrite = false; a UI calls it and renders the typed result). This is how a UI shows your
vault's state, including fields it has never seen, without knowing your contract.
To expose a state field, declare a read method with a typed output:
m.name = "lockDuration";
m.selector = bytes4(keccak256("lockDuration()"));
m.isWrite = false;
m.outputs = new Field[](1);
m.outputs[0] = Field("lockDuration", FieldType.Time, false, 0, "Seconds until unlock");
A UI calls the selector, decodes the return with the declared type, and formats it. It only shows
what you declare, so list every read you want surfaced. The full type list (Field, Method,
FieldType, ApprovalHint) is in events-and-data.md.
Native vs ERC20 revenue mode
isNativeRevenue is set at construction and must equal quoteToken == WBNB. The launchpad asserts
this at launch and reverts on a mismatch.
- Native mode (quote is WBNB): revenue arrives as BNB through
receive()._revenueBalance()reads the vault's BNB balance;_paysends BNB. - ERC20 mode: revenue arrives as the quote token (the balance grows, no
receive()call).VaultBase.receive()rejects stray BNB so funds cannot be stranded._revenueBalance()reads the quote balance;_paydoes an ERC20 transfer.
If you build on VaultBase and use _revenueBalance() and _pay(), both modes are handled for you.
A complete minimal example
A vault that streams 100% of revenue to a single recipient set at launch.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {VaultBase} from "./VaultBase.sol";
import {VaultFactoryBase} from "./VaultFactoryBase.sol";
import {LaunchDataV1} from "./IVaultFactory.sol";
import {VaultUISchema, VaultDataSchema, Method, Field, FieldType} from "./IVaultSchema.sol";
contract SingleRecipientVault is VaultBase {
address public recipient;
constructor(address _diamond, address _quote, address _creator, bool _native, address _recipient)
VaultBase(_diamond, _quote, _creator, _native)
{
require(_recipient != address(0), "bad recipient");
recipient = _recipient;
}
// Permissionless: push the whole balance to the recipient.
function distribute() external nonReentrant {
uint256 bal = _revenueBalance();
if (bal == 0) return;
_pay(recipient, bal); // production should track owed[] on failure; see the checklist
}
function description() external pure returns (string memory) {
return "Streams all revenue to a single recipient";
}
function vaultUISchema() external pure returns (VaultUISchema memory s) {
s.vaultType = "SingleRecipient";
s.description = "Streams all revenue to a single recipient";
s.methods = new Method[](2);
s.methods[0].name = "distribute";
s.methods[0].selector = bytes4(keccak256("distribute()"));
s.methods[0].isWrite = true;
s.methods[0].hint = "Send accrued revenue to the recipient";
s.methods[1].name = "recipient";
s.methods[1].selector = bytes4(keccak256("recipient()"));
s.methods[1].isWrite = false;
s.methods[1].outputs = new Field[](1);
s.methods[1].outputs[0] = Field("recipient", FieldType.Address, false, 0, "Who receives the revenue");
s.methods[1].hint = "Revenue recipient";
}
}
contract SingleRecipientVaultFactory is VaultFactoryBase {
address public immutable weth;
constructor(address _diamond, address _weth) VaultFactoryBase(_diamond) { weth = _weth; }
function supportsQuoteToken(address) external pure returns (bool) { return true; }
// Veto launches that would never fund the vault.
function _reviewV1(LaunchDataV1 memory d) internal pure override returns (bool, string memory) {
if (d.feeReceiverBps == 0) return (false, "feeReceiver bps must be > 0");
if (d.buyTaxBps == 0 && d.sellTaxBps == 0) return (false, "tax must be > 0");
return (true, "");
}
function createVault(address quoteToken, address creator, bytes calldata vaultData)
external onlyDiamond returns (address vault)
{
address recipient = abi.decode(vaultData, (address));
// isNativeRevenue MUST be (quoteToken == weth); the launchpad enforces this.
vault = address(new SingleRecipientVault(diamond, quoteToken, creator, quoteToken == weth, recipient));
}
function vaultDataSchema() external pure returns (VaultDataSchema memory s) {
s.description = "The single address that receives all revenue";
s.isArray = false;
s.fields = new Field[](1);
s.fields[0] = Field("recipient", FieldType.Address, false, 0, "Revenue recipient");
}
}
Deploy the factory once. Its constructor needs the launchpad address plus the WBNB address; derive
WBNB from the launchpad's router the way scripts/launchpad/deploy-vault-factory.ts does, and assert
factory.weth() == router.WETH(). Creators then pass your factory address and
abi.encode(recipient) as vaultData at launch.
Checklist before you ship
- Vault extends
VaultBase; implementsdistribute(),description(),vaultUISchema(). - Factory extends
VaultFactoryBase; implements_reviewV1,createVault(onlyDiamond),supportsQuoteToken,vaultDataSchema(). createVaultbuilds the vault withisNativeRevenue == (quoteToken == weth).createVaultdecodesvaultDatain the exact order and types ofvaultDataSchema().receive()andonRevenue()are cheap, accrue-only, and never call back into the token.- Every payout happens in
distribute()orclaim(), never on revenue receipt. - Use
_pay()and check its return value. It reports failure instead of reverting, so keep a failed payee's balance asowedfor a laterclaim(), and one bad payee cannot brick everyone. vaultUISchema()declares every read field you want a UI to display, with typed outputs.- Amount fields use
decimals = 18for WBNB, or the quote token's decimals otherwise.
Gotchas
- Un-sweepable by design. CheesePad vaults are meant to be sovereign and immutable, so buyers can
verify there is no admin drain. If you add an escape hatch, expect it to read as a trust negative.
If you want a platform backstop, opt in explicitly through
platform()andonlyPlatform, the way GiftVault does for its rescue path. distribute()is permissionless. Anyone can poke it, so make the payout math safe when called by anyone at any time, and a no-op on a zero balance.- Integer-division dust in split logic should carry to the next
distribute, not revert. reviewLaunchnever seesvaultData. You cannot cross-validate launch params against per-vault params; they are validated separately by design.- One factory version is one address. Factories are stateless and immutable. A new version is a
new deployment. To support a future launch-data revision, ship an updated factory with a new review
hook and widen
supportsLaunchRevision.
Reference implementations
In contracts/launchpad/vaults/:
SplitterVault.solandSplitterVaultFactory.sol: fixed payees by bps.GiftVault.solandGiftVaultFactory.sol: identity-bound streaming with an EIP-712 relayer bind and a holder-dividend fallback. Also shows the opt-in platform backstop.VaultBase.sol,VaultFactoryBase.sol,IVault.sol,IVaultFactory.sol,IVaultSchema.sol: the base contracts and interfaces you build against.