跳到主要内容

Building a custom vault

你可以编写自己的 vault——一个接收 CheesePad tax token 收入的合约,并对它做你设计的任何事: 拆分、流式支付、锁定、回购、捐赠。你不需要把任何东西加入白名单,也不接触 CheesePad 自己的 合约。你交付两个合约,一个 vault 和一个 vault 工厂,代币创建者把他们的上线指向你的 工厂。如果你的工厂通过了上线检查,你的 vault 就会被自动部署并接线。

最快的路径是扩展 CheesePad 提供的两个基础合约 VaultBase 和 VaultFactoryBase,它们处理样板 代码,把有意思的部分留给你。

目录​

心智模型​

                        tax on every buy/sell
trader ──swap──▶ tax token ──▶ settles tax ──splits revenue──▶ YOUR VAULT
│
you decide what happens here
(distribute / claim / lock / ...)
  • 代币的 tax 是分批收取并结算的,而不是在每笔交易上。
  • 结算时,fee-receiver 份额会被发送到无论哪个地址是该代币的 fee receiver。对于一次 vault 上线,那个地址就是你的 vault。
  • 如果销售的 quote token 是 WBNB,收入以原生 BNB 到达;否则以 quote ERC20 到达。你的 vault 在 构造时被告知是哪一种。

两个合约,两项职责:

ContractJob
Vault持有收入,实现你的支付逻辑(distribute、claim……)。每次上线一个。
Vault factory校验一次上线并为其部署一个全新的 vault。一个工厂服务多次上线。

上线握手​

当一个创建者用你的工厂上线时(createMicroSaleTaxTokenWithVaultV2,或 Melt Pool 的等价函数 createBondingCurveTaxTokenWithVaultV2),CheesePad 会以 fail-closed 的方式运行这些检查。任何 一步失败都会 revert 整个上线:

  1. factory.diamond() 返回 launchpad 地址,证明你的工厂瞄准的是正确的平台。
  2. factory.supportsQuoteToken(quoteToken) 返回 true。
  3. factory.supportsLaunchRevision(1) 返回 true。
  4. factory.reviewLaunch(payload) 返回 (true, ""),即你的否决 hook。
  5. factory.createVault(quoteToken, creator, vaultData) 部署 vault。
  6. vault 的 isNativeRevenue() 等于 quoteToken == WBNB。不匹配则 revert。
  7. launchpad 把 vault 绑定为代币的 fee receiver,并一次性调用 vault.bind(token)。

在此之后,你的 vault 已上线并在接收收入。你从不调用 CheesePad;是它调用你。

扩展基础合约​

Vault:扩展 VaultBase​

VaultBase 免费为你提供:

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

你实现三个函数(来自 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

构造函数接收上线上下文:

constructor(address _diamond, address _quoteToken, address _creator, bool _isNativeRevenue)

Factory:扩展 VaultFactoryBase​

VaultFactoryBase 处理版本管道和仅限 launchpad 的守卫。你实现:

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 是你的否决:它接收上线参数(LaunchDataV1),并返回 (false, reason) 来阻止一次 你不想服务的上线。它不接收 vaultData,因此你要独立地校验上线参数和各 vault 专属参数。

接收收入的三条规则​

收入会在代币结算中途被交付给你的 vault。违反这些规则,你可能会让该代币的每一笔被收税转账瘫痪:

  1. receive() 和 onRevenue() 必须廉价且只做累计。 只做记账和事件。不做 swap,不做有影响 的外部调用。
  2. 绝不从 receive() 或 onRevenue() 回调进代币。 在结算中途读取它可能会 revert 或 重入。
  3. 把所有支付工作放在一笔独立的交易中。 一次无需许可的 distribute() poke,或一次 claim() 提取。绝不在收入接收期间把资金推送出去。

这就是为什么每个 CheesePad vault 都在接收时累计、稍后再支付。

两个 schema​

你的 vault 和工厂是自描述的。一个 CheesePad UI 读取两个 schema,并通用地渲染你的创建表单和你的 vault UI,无需任何针对你的 vault 的专属代码。

工厂上的 vaultDataSchema():创建表单​

描述 vaultData 如何 ABI 编码,以便 UI 能渲染上线表单并编码创建者的输入。

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)");
}

随后你的 createVault 会严格按照 schema 声明的顺序和类型解码 vaultData:

(address[] memory payees, uint16[] memory bps) = abi.decode(vaultData, (address[], uint16[]));

Vault 上的 vaultUISchema():操作与可读状态​

一个方法列表。每个方法要么是一个写入操作(UI 渲染一个按钮和一个输入表单),要么是一个读取字段 (isWrite = false;UI 调用它并渲染带类型的结果)。这就是 UI 如何在不了解你的合约的情况下 展示你的 vault 状态,包括它从未见过的字段。

要暴露一个状态字段,声明一个带类型输出的读取方法:

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");

UI 调用这个 selector,用声明的类型解码返回值,并格式化它。它只展示你所声明的内容,因此把你想 被展示的每一项读取都列出来。完整的类型列表(Field、Method、FieldType、ApprovalHint) 见 events-and-data.md。

原生 vs ERC20 收入模式​

isNativeRevenue 在构造时设置,且必须等于 quoteToken == WBNB。launchpad 会在上线时断言这一 点,不匹配则 revert。

  • Native 模式(quote 是 WBNB):收入通过 receive() 以 BNB 到达。_revenueBalance() 读取 vault 的 BNB 余额;_pay 发送 BNB。
  • ERC20 模式:收入以 quote token 到达(余额增长,没有 receive() 调用)。 VaultBase.receive() 会拒绝孤立的 BNB,因此资金不会被搁浅。_revenueBalance() 读取 quote 余额;_pay 执行一次 ERC20 转账。

如果你基于 VaultBase 构建并使用 _revenueBalance() 和 _pay(),两种模式都会为你处理好。

一个完整的最小示例​

一个把 100% 收入流式支付给上线时设定的单个接收方的 vault。

// 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");
}
}

只需部署工厂一次。它的构造函数需要 launchpad 地址加上 WBNB 地址;请像 scripts/launchpad/deploy-vault-factory.ts 那样,从 launchpad 的 router 推导出 WBNB,并断言 factory.weth() == router.WETH()。之后创建者在上线时把你的工厂地址和 abi.encode(recipient) 作为 vaultData 传入即可。

交付前的清单​

  • Vault 扩展 VaultBase;实现 distribute()、description()、vaultUISchema()。
  • Factory 扩展 VaultFactoryBase;实现 _reviewV1、createVault(onlyDiamond)、 supportsQuoteToken、vaultDataSchema()。
  • createVault 以 isNativeRevenue == (quoteToken == weth) 构建 vault。
  • createVault 严格按照 vaultDataSchema() 的顺序和类型解码 vaultData。
  • receive() 和 onRevenue() 廉价、只做累计,且从不回调进代币。
  • 每一次支付都发生在 distribute() 或 claim() 中,绝不在收入接收时。
  • 使用 _pay() 并检查它的返回值。它以报告失败代替 revert,因此把一个支付失败的收款人的余额 保留为 owed 供稍后 claim(),这样一个坏收款人无法让所有人瘫痪。
  • vaultUISchema() 声明你希望 UI 展示的每一个读取字段,并带类型化输出。
  • 金额字段对 WBNB 使用 decimals = 18,否则使用 quote token 的小数位数。

注意事项​

  • 按设计不可清扫。 CheesePad vault 意在做到主权且不可变,好让买家能验证不存在管理员抽走 资金的通道。如果你添加一个逃生舱口,就要预期它会被解读为信任负面。如果你想要一个平台后备 机制,请像 GiftVault 为它的救援路径所做的那样,通过 platform() 和 onlyPlatform 显式地 选择加入。
  • distribute() 是无需许可的。 任何人都可以 poke 它,所以要让支付计算在任何人任何时刻 调用时都是安全的,并在余额为零时是一个 no-op。
  • 拆分逻辑中的整数除法余尘应结转到下一次 distribute,而不是 revert。
  • reviewLaunch 从不看到 vaultData。 你无法把上线参数与各 vault 专属参数交叉校验;它们 按设计是分开校验的。
  • 一个工厂版本是一个地址。 工厂是无状态且不可变的。新版本是一次新部署。要支持未来的 launch-data 版本,请交付一个带有新 review hook 的更新工厂,并扩宽 supportsLaunchRevision。

参考实现​

位于 contracts/launchpad/vaults/:

  • SplitterVault.sol 和 SplitterVaultFactory.sol:按 bps 固定分给收款人。
  • GiftVault.sol 和 GiftVaultFactory.sol:带 EIP-712 relayer 绑定和持有者分红回退的身份绑定 流式支付。也展示了可选加入的平台后备机制。
  • VaultBase.sol、VaultFactoryBase.sol、IVault.sol、IVaultFactory.sol、 IVaultSchema.sol:你所基于构建的基础合约与接口。