Skip to main content

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​

                        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:

ContractJob
VaultHolds revenue, implements your payout logic (distribute, claim, ...). One per launch.
Vault factoryValidates 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:

  1. factory.diamond() returns the launchpad address, proving your factory targets the right platform.
  2. factory.supportsQuoteToken(quoteToken) returns true.
  3. factory.supportsLaunchRevision(1) returns true.
  4. factory.reviewLaunch(payload) returns (true, ""), your veto hook.
  5. factory.createVault(quoteToken, creator, vaultData) deploys the vault.
  6. The vault's isNativeRevenue() equals quoteToken == WBNB. A mismatch reverts.
  7. 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:

ProvidedWhat it is
diamond, quoteToken, creator, isNativeRevenueimmutable launch context
token, boundthe 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:

  1. receive() and onRevenue() must be cheap and accrue-only. Bookkeeping and events only. No swaps, no external calls of consequence.
  2. Never call back into the token from receive() or onRevenue(). Reading it mid-settle can revert or reenter.
  3. Do all payout work in a separate transaction. A permissionless distribute() poke, or a claim() 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; _pay sends 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; _pay does 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; implements distribute(), description(), vaultUISchema().
  • Factory extends VaultFactoryBase; implements _reviewV1, createVault (onlyDiamond), supportsQuoteToken, vaultDataSchema().
  • createVault builds the vault with isNativeRevenue == (quoteToken == weth).
  • createVault decodes vaultData in the exact order and types of vaultDataSchema().
  • receive() and onRevenue() are cheap, accrue-only, and never call back into the token.
  • Every payout happens in distribute() or claim(), never on revenue receipt.
  • Use _pay() and check its return value. It reports failure instead of reverting, so keep a failed payee's balance as owed for a later claim(), 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 = 18 for 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() and onlyPlatform, 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.
  • reviewLaunch never sees vaultData. 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.sol and SplitterVaultFactory.sol: fixed payees by bps.
  • GiftVault.sol and GiftVaultFactory.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.