07 / Developer guide

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

#
LayerChoice
ContractsSolidity ^0.8.30, compiled with solc-js (no Hardhat/Foundry project files)
Front endNext.js (App Router), React, TypeScript, CSS Modules
Chain accessviem — public client over HTTP RPC, wallet client over the injected provider
Graphicsnext/image, three.js for the hero scenes, lucide-react icons
TestingNode 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:3000

Node 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  imagery

Scripts

#
CommandWhat it does
npm run devNext.js dev server
npm run buildCompiles contracts (prebuild), then builds the site
npm run startServes the production build
npm run lintnext lint
npm run verify:vaultCompiles 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:vaultStarts anvil, deploys the vault plus a mock ERC-20 and a fee-on-transfer ERC-20, and runs 27 behavioural checks
npm run deploy:vaultDeploys 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.

VariableUsed byPurpose
NEXT_PUBLIC_VELLUM_ROBINHOOD_VAULT_ADDRESSappPrimary vault CA (first fallback after the admin override)
NEXT_PUBLIC_VELLUM_CONTRACT_ADDRESSappGeneric CA fallback
NEXT_PUBLIC_VELLUM_TEST_VAULT_ADDRESSappLast CA fallback; also maps to Base Sepolia
NEXT_PUBLIC_VELLUM_<NETWORK>_VAULT_ADDRESSnetworks tablePer-chain CAs — full list on Networks
VELLUM_ADMIN_PASSWORDserverPassword for /admin. Unset = admin disabled
VELLUM_DEPLOY_NETWORKdeploy scriptrobinhood-testnet (default), sepolia, robinhood, ethereum
VELLUM_DEPLOYER_PRIVATE_KEYdeploy scriptKey that pays for deployment. Never commit.
VELLUM_GUARDIAN_ADDRESSdeploy scriptConstructor argument. Use a multisig.
VELLUM_RPC_URLdeploy scriptOptional RPC override
VELLUM_ALLOW_MAINNET_DEPLOYdeploy scriptMust 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 node

What the end-to-end script asserts, grouped:

  • WrapNoteWrapped carries 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.
  • TransfertransferFrom moves 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; ownerOf reverts; 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; safeTransferFrom to 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.
Extending the spec

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:vault

The 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:

  1. Set the printed NEXT_PUBLIC_VELLUM_*_VAULT_ADDRESS in the hosting environment and redeploy the site.
  2. Verify source on the explorer using the same compiler settings (0.8.30, optimizer 200 runs) so positions() is readable by holders.
  3. Publish the CA and the guardian address.
Mainnet lock

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