Build on
the note.
Everything needed to run the site locally, compile and test the vault, deploy it to a network and call it from your own code.
Stack
#| Layer | Choice |
|---|---|
| Contracts | Solidity ^0.8.30, compiled with solc-js (no Hardhat/Foundry project files) |
| Front end | Next.js (App Router), React, TypeScript, CSS Modules |
| Chain access | viem — public client over HTTP RPC, wallet client over the injected provider |
| Graphics | next/image, three.js for the hero scenes, lucide-react icons |
| Testing | Node script + anvil (Foundry) for end-to-end vault checks |
Local setup
#bashgit clone <repo> vellum-protocol && cd vellum-protocol
npm install
npm run dev # http://localhost:3000Node 18+ is required (the repo is developed on Node 24). npm run build runs a prebuild hook that compiles both contracts into app/lib/*Artifact.ts, so the ABI the app uses is always generated from the current Solidity source.
For the wallet flow you also need a browser wallet and a vault address; see vault address resolution.
Project layout
#textcontracts/
VellumVault.sol production vault (guardian, exact-balance check)
VellumTestVault.sol test-token variant for Base Sepolia
scripts/
compile-test-vault.mjs prebuild: solc → app/lib/*Artifact.ts
verify-vellum-vault.mjs compile + invariant checks (npm run verify:vault)
deploy-vellum-vault.mjs deploy with viem (npm run deploy:vault)
test-vellum-vault.mjs end-to-end checks on anvil (npm run test:vault)
render-sky-video.mjs asset generation for the hero
app/
page.tsx home (VellumExperience)
app/page.tsx the wrap / claim interface
app/note/page.tsx static sample note
api/price/route.ts live token price (DexScreener proxy, 30 s cache)
admin/ password-protected CA override
docs/ this documentation
components/ BearerNote card, hero scenes, DocPage, docs primitives
lib/
vellumNetworks.ts chain list, token presets, env-var mapping
vellumVaultAddress.ts CA resolution hook + localStorage override
tokenPrice.ts useTokenPrice() hook + formatMark()
vellumVaultArtifact.ts generated ABI + bytecode (do not edit)
adminAuth.ts HMAC session for /admin
public/brand, public/tokens imageryScripts
#| Command | What it does |
|---|---|
npm run dev | Next.js dev server |
npm run build | Compiles contracts (prebuild), then builds the site |
npm run start | Serves the production build |
npm run lint | next lint |
npm run verify:vault | Compiles VellumVault and asserts: required functions exist; no selfdestruct/delegatecall/upgradeTo/withdraw in source; exact-balance guard present; claim pays out; claim is not gated by maturity; instant term supported |
npm run test:vault | Starts anvil, deploys the vault plus a mock ERC-20 and a fee-on-transfer ERC-20, and runs 27 behavioural checks |
npm run deploy:vault | Deploys VellumVault to the network named by VELLUM_DEPLOY_NETWORK |
Environment variables
#Put these in .env.local (git-ignored). NEXT_PUBLIC_ values are inlined into the browser bundle at build time.
| Variable | Used by | Purpose |
|---|---|---|
NEXT_PUBLIC_VELLUM_ROBINHOOD_VAULT_ADDRESS | app | Primary vault CA (first fallback after the admin override) |
NEXT_PUBLIC_VELLUM_CONTRACT_ADDRESS | app | Generic CA fallback |
NEXT_PUBLIC_VELLUM_TEST_VAULT_ADDRESS | app | Last CA fallback; also maps to Base Sepolia |
NEXT_PUBLIC_VELLUM_<NETWORK>_VAULT_ADDRESS | networks table | Per-chain CAs — full list on Networks |
VELLUM_ADMIN_PASSWORD | server | Password for /admin. Unset = admin disabled |
VELLUM_DEPLOY_NETWORK | deploy script | robinhood-testnet (default), sepolia, robinhood, ethereum |
VELLUM_DEPLOYER_PRIVATE_KEY | deploy script | Key that pays for deployment. Never commit. |
VELLUM_GUARDIAN_ADDRESS | deploy script | Constructor argument. Use a multisig. |
VELLUM_RPC_URL | deploy script | Optional RPC override |
VELLUM_ALLOW_MAINNET_DEPLOY | deploy script | Must be literally true to deploy to a non-testnet |
Testing the vault
#scripts/test-vellum-vault.mjs is the behavioural spec of the protocol. It needs anvil on your PATH (install Foundry with curl -L https://foundry.paradigm.xyz | bash && foundryup).
bashnpm run verify:vault # static invariants
npm run test:vault # 27 end-to-end checks against a local anvil nodeWhat the end-to-end script asserts, grouped:
- Wrap —
NoteWrappedcarries the right id and amount; the depositor owns the note; the vault holds exactly the amount; the position stores token, amount,now + 90d, unclaimed; the note is claimable in the mint block. - Transfer —
transferFrommoves ownership; the underlying balance does not move; the previous holder cannot claim; strangers cannot transfer. - Claim — the holder receives the full amount; the note is no longer claimable;
ownerOfreverts; a second claim reverts. - Terms — instant notes work; 1 hour and 3651 days revert; zero amount reverts.
- Token safety — a 1 % fee-on-transfer token is rejected;
safeTransferFromto a contract without the receiver hook reverts and the note stays put. - Guardian — non-guardians cannot pause; paused wraps revert while claims still succeed; two-step rotation works and strangers cannot accept; the vault is empty once every note is claimed.
The script compiles an inline MockToken with a configurable fee in basis points. Add cases by deploying more mocks or by calling expectRevert(promise, reason, label) and check(condition, label); the run fails on the first unmet check.
Deploying a vault
#bash# 1. testnet first
VELLUM_DEPLOY_NETWORK=robinhood-testnet \
VELLUM_DEPLOYER_PRIVATE_KEY=0x... \
VELLUM_GUARDIAN_ADDRESS=0xYourMultisig \
npm run deploy:vault
# 2. production (explicit unlock required)
VELLUM_DEPLOY_NETWORK=robinhood \
VELLUM_ALLOW_MAINNET_DEPLOY=true \
VELLUM_DEPLOYER_PRIVATE_KEY=0x... \
VELLUM_GUARDIAN_ADDRESS=0xYourMultisig \
npm run deploy:vaultThe script compiles the contract in-process, deploys with viem, waits for the receipt and prints the address, the env var to set and an explorer link. Then:
- Set the printed
NEXT_PUBLIC_VELLUM_*_VAULT_ADDRESSin the hosting environment and redeploy the site. - Verify source on the explorer using the same compiler settings (0.8.30, optimizer 200 runs) so
positions()is readable by holders. - Publish the CA and the guardian address.
Deploying to any network whose chain definition is not marked testnet throws unless VELLUM_ALLOW_MAINNET_DEPLOY=true. This is the only guard between a test command and a real deployment — keep it.
Integrating with viem
#Import the generated ABI and talk to the vault directly. The examples assume a viem publicClient and walletClient.
tsimport { erc20Abi, parseUnits } from "viem";
import { VELLUM_VAULT_ABI } from "./app/lib/vellumVaultArtifact";
const vault = "0x..." as const;
// Read a note
const [token, amount, maturity, claimed] = await publicClient.readContract({
address: vault, abi: VELLUM_VAULT_ABI, functionName: "positions", args: [1n],
});
const live = await publicClient.readContract({
address: vault, abi: VELLUM_VAULT_ABI, functionName: "isClaimable", args: [1n],
});
// Wrap 250,000 tokens for 90 days
const amountWei = parseUnits("250000", 18);
await walletClient.writeContract({ address: token, abi: erc20Abi, functionName: "approve", args: [vault, amountWei] });
const hash = await walletClient.writeContract({
address: vault, abi: VELLUM_VAULT_ABI, functionName: "wrap",
args: [token, amountWei, BigInt(90 * 86400)],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
// tokenId is in the NoteWrapped log of `receipt`
// Claim
await walletClient.writeContract({ address: vault, abi: VELLUM_VAULT_ABI, functionName: "claim", args: [tokenId] });Gating on a note
An access rule that wants "holds a live note of token X with at least N and at least D days remaining" can be evaluated with two reads:
tsconst owner = await publicClient.readContract({ address: vault, abi: VELLUM_VAULT_ABI, functionName: "ownerOf", args: [id] });
const [token, amount, maturity, claimed] = await publicClient.readContract({ address: vault, abi: VELLUM_VAULT_ABI, functionName: "positions", args: [id] });
const remainingDays = (Number(maturity) - Date.now() / 1000) / 86400;
const qualifies = owner === candidate && !claimed && token === X && amount >= N && remainingDays >= D;Remember the holder can claim at any moment, so re-check at the time the access matters, not once at enrolment.
Indexing notes
#The contract has no enumeration. To list notes per holder, index Transfer events: a note's current owner is the to of its most recent Transfer; to == 0x0 means claimed. NoteWrapped gives you token, amount and maturity at mint without a storage read.
tsconst logs = await publicClient.getContractEvents({
address: vault, abi: VELLUM_VAULT_ABI, eventName: "Transfer",
args: { to: holder }, fromBlock: deployBlock,
});