Skip to content

Embed a vault

testnet The vault layer is deployed and verified on Robinhood Chain testnet: factory 0x2BB2324cC49C6D15367A70DF64D4740A80363F1E, reference vault 0xc56438427718233B6701b5642D4A506742eDf7d3 (nyUSDG), full list on the Addresses page. Interfaces on this page are copied from contracts/src/vaults/. Mainnet follows external review.

NoteVault is an ERC-4626 vault over USDG. Idle quote is posted as COUPON in rolled Note series that fit a published mandate (per-underlying bands on barrier, coupon floor, observations and tenor, plus sizing caps). Coupons are harvested into the share price. At autocall, maturity or breach the position is realised and the proceeds return to idle quote. Allocation, harvesting and realisation are permissionless and bountied; nobody runs a keeper for you.

ContractRole
NoteVaultShares, deposits, exits, allocation and lifecycle. No owner
NoteVaultMandateThe published rules: bands, limits, fees, partners. Tightening is immediate, loosening waits out a delay
NoteCoreAdapterHolds the vault’s COUPON legs and talks to NoteCore; the vault’s only counterparty
NoteVaultFactoryDeploys the three as a bound set and lists them

Shares have 18 decimals over a 6-decimal asset (decimal offset 12), so a fresh vault prices one share at one USDG.

uint256 n = factory.vaultCount();
// paged, oldest first
address[] memory vs = factory.vaults(0, 20);
NoteVault v = NoteVault(vs[0]);
NoteVaultMandate m = v.mandate();
// managementBps per year, performanceBps on coupons
INoteVault.Fees memory fees = m.fees();
// maxSeriesBps, minIdleBps, stockHaircutBps, depositCap
INoteVault.Limits memory lim = m.limits();
// stocks the vault may take
address[] memory u = m.underlyings();
// barrier, coupon floor, observations, tenor, caps
INoteVault.Band memory b = m.band(u[0]);

Show the mandate next to the deposit button. It is the whole risk disclosure: which stocks, how deep a barrier, how many observations, how much of the book one series may take.

// ERC-4626 preview at the current share price
uint256 shares = v.previewDeposit(assets);
uint256 assets = v.previewRedeem(shares);
// depositCap less total assets; type(uint256).max when uncapped
uint256 cap = v.maxDeposit(receiver);
// idle quote a withdraw can take this block
uint256 now_ = v.instantAvailable();

totalAssets() is idle quote plus the adapter’s valuation of every open position’s principal, plus any breach stock the vault holds at the haircut price. Coupons accrued but not yet harvested are excluded; they enter the share price when sync() harvests them, less the performance fee, and then release over seven days. Positions are valued from NoteCore views and the series feed; a mark below par lowers the share price rather than being hidden. Idle quote itself excludes the fee pot, deferred fee payments and profit still locked.

usdg.approve(address(v), assets);
uint256 shares = v.deposit(assets, receiver, partnerId);

partnerId is a bytes32 the mandate has registered for you (m.partner(id) returns your payee and share): at creation through InitParams.partners when you deploy your own vault, or through the curator’s timelocked setPartner on a vault you do not curate. A receiver is tagged on their first deposit and keeps the tag; your share of vault fees follows the shares they hold. An unregistered id reverts with UnknownPartner(id). Plain deposit(assets, receiver) and mint work too, untagged.

Deposits revert with VaultPaused() while the mandate is paused (owner or guardian), and are capped by maxDeposit.

Three routes, chosen by the holder.

RouteCallWhen it settles
Instantwithdraw(assets, receiver, owner) or redeem(shares, receiver, owner)Same block, from idle quote up to instantAvailable()
QueuedrequestRedeem(shares, receiver, owner) then claimRedeem(id)Filled oldest first by fillQueue() as idle quote returns; partial fills pay out as they happen
In kindexitInKind(shares, receiver) or exitInKind(shares, receiver, takeStock)Same block: a pro rata slice of idle quote, settled COUPON legs and any breach stock; the slice of raw deposits still inside the core is queued instead
uint256 id = v.requestRedeem(shares, receiver, owner);
// later, from any account
v.fillQueue();
// pays the filled part to receiver
uint256 paid = v.claimRedeem(id);
// returns the unfilled shares to owner
v.cancelRedeem(id);

Queued shares are escrowed by the vault and priced at the share price of the block that fills them, not the block that queued them. fillQueue fills at most 32 requests per call. An in-kind receiver must accept ERC-1155 transfers.

uint256 k = v.requestsOfCount(owner);
// paged
uint256[] memory ids = v.requestsOf(owner, 0, 20);
// shares, filledShares, filledAssets, claimedAssets, cancelled
INoteVault.Request memory r = v.request(ids[0]);
// series the vault currently holds (at most 64)
uint256[] memory open = v.openSeries();
// quote at par committed to one stock
uint256 exp = v.exposure(underlying);
uint256 idle = v.idle();

For indexers, the vault emits Allocated, Settled, Harvested, Realised, FeesAccrued, FeesCollected, RedeemRequested, RedeemFilled, RedeemClaimed, RedeemCancelled, ExitedInKind and PartnerTagged.

You do not need to call these; they are listed so your interface can explain what moves the share price.

CallWhoEffect
allocate(seriesId)anyonePosts idle quote as COUPON in a rolled, in-band series during the allocation window. Size comes from the mandate. Pays the mandate bounty to the caller from the vault’s fee pot, capped by what the pot holds; a vault whose pot is empty pays none
sync()anyoneAccrues the management fee, then settles, harvests and realises whatever is due across the book, at most 8 actions, and fills the redemption queue. Pays the bounty when it did at least one action
realise(seriesId)anyoneCloses a position whose series autocalled, matured, was cancelled or unwound
collectFees()anyoneConverts accrued fee shares to quote from idle and splits them three ways (see Economics)

Constants: MAX_POSITIONS 64, SYNC_MAX 8, QUEUE_MAX_FILL 32, ALLOCATION_COOLDOWN 1 hour per underlying, HARVEST_MIN 10 USDG, adapter.PRICE_MAX_AGE() 1 day.

ErrorMeaning
UnknownPartner(id)partnerId not registered on the mandate
VaultPaused()The mandate is paused (owner or guardian); deposits and allocations stop, exits still work
BelowMinimum(amount, minimum)Allocation smaller than the larger of the mandate minAllocation and the series minimum ticket
ERC4626ExceededMaxDeposit(receiver, assets, max)Deposit above the cap, or the mandate is paused (maxDeposit is then zero)
OutsideBand(seriesId, field)Series fails the band: 0 feed, 1 barrier, 2 coupon floor, 3 observations, 4 tenor
Cooldown(underlying, until)Second allocation to one stock inside the hour
NothingToClaim(id)Request has no filled, unclaimed assets
NotRequestOwner(id)cancelRedeem from an account other than the request owner, or claimRedeem from an account that is neither the owner nor the receiver
Locked(owner, until)Exit inside the exit lock

@note-systems/sdk (repository sdk/) wraps the same calls for wallets and dashboards, typed against the compiled ABIs and bundling the pinned NoteVault creation code so factory.create needs no build step. Reads are batched through Multicall3; writes simulate first, send with a gas margin (vault writes are estimated with the once-per-block fee accrual forced to run, so a write that follows another in the same block cannot be under-estimated), and decode every vault layer, NoteCore and RollPolicy error by name. Every result is read from the receipt’s events, never from the simulation. Parameters are checked against the mandate bounds before anything is sent.

Install the release tarball; viem is a peer dependency:

Terminal window
npm install viem https://note.systems/sdk/note-systems-sdk-0.1.1.tgz

latest.json carries the current version, tarball URL and sha512; every tarball is served next to its .sha512 file, and npm pins the integrity in your lockfile. The package name is @note-systems/sdk, so imports are unchanged when the registry release follows.

The snippet needs a viem public client and a wallet client with an account; receiver is the address that holds the shares, usually the wallet’s own account.

import { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { NoteError, createNoteClient, partnerId, robinhoodTestnet } from '@note-systems/sdk';
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const publicClient = createPublicClient({ chain: robinhoodTestnet, transport: http() });
const walletClient = createWalletClient({ chain: robinhoodTestnet, transport: http(), account });
const note = createNoteClient({ publicClient, walletClient });
const receiver = account.address;
const [vault] = await note.factory.list(0n, 20n); // discover
if (!vault) throw new Error('no vault listed');
if (!(await note.vaults.verifyBinding(vault))) throw new Error('not a bound vault set');
const s = await note.vaults.summary(vault); // mandate, fees, share price, idle, open series
const shares = await note.vaults.previewDeposit(vault, 100_000_000n); // quote
await note.vaults.approve(vault, 100_000_000n);
const d = await note.vaults.deposit({ vault, assets: 100_000_000n, receiver, partnerId: partnerId('wallet.example') });
console.log(d.result.shares, d.result.tagged); // shares minted, whether the tag was applied
const h = await note.vaults.holding(vault, receiver); // shares, value, tag, unlock time, maxWithdraw
try {
const r = await note.vaults.requestRedeem(vault, h.shares / 2n, receiver, receiver); // exit through the queue
} catch (e) {
if (e instanceof NoteError && e.decoded) console.log(e.decoded.name, e.decoded.message); // e.g. Locked
else throw e;
}

Every write returns { hash, receipt, gasUsed, result }. A revert before sending throws NoteError with decoded.name and decoded.args (for example Locked, UnknownPartner, VaultPaused, or a mandate InvalidParameter bubbled through factory.create); a transaction that reverted after inclusion throws TransactionRevertedError with the hash, the receipt and an outOfGas flag. note.factory.codeMatches() confirms the bundled creation code is the one the factory pins before you deploy; note.vaults.verifyBinding(vault) checks the factory lists the vault and that its registry, the vault, the mandate and the adapter all point at each other; summary refuses a vault the factory did not create unless asked. examples/partner-integration.ts in the package walks the six steps end to end; the package README lists every method.

Releases are served from https://note.systems/sdk/. Each release is a tarball produced by npm pack after the package’s own gate (ABI check against the compiled artifacts, examples type check, tests, build), so a tarball cannot come from a stale or failing tree. The same content always produces the same bytes, so a digest recorded once stays valid.

VersionTarballsha512Contracts
0.1.1note-systems-sdk-0.1.1.tgz6e19d34d9094d125…f9aec1440a263d22 (full)Robinhood Chain testnet 46630, factory 0x2BB2324cC49C6D15367A70DF64D4740A80363F1E
0.1.0note-systems-sdk-0.1.0.tgz4cb537a1183134ef…ea6d21500455d966 (full)Same contracts. 0.1.1 changes the README and the example header only

Earlier tarballs stay at their URLs, so a committed lockfile keeps resolving. Verify before you pin:

Terminal window
curl -sO https://note.systems/sdk/note-systems-sdk-0.1.1.tgz
curl -s https://note.systems/sdk/note-systems-sdk-0.1.1.tgz.sha512 | sha512sum -c

After npm install, the lockfile entry for @note-systems/sdk carries resolved (the tarball URL) and integrity (sha512- followed by the base64 digest); both must match latest.json. Commit the lockfile so every install in your pipeline resolves the same bytes. To upgrade, install the new tarball URL; npm replaces the pin. The package name is @note-systems/sdk in every release, so imports never change, and when the package reaches the npm registry a plain npm install @note-systems/sdk will resolve the same versions.

factory.codeMatches() confirms at runtime that the vault creation code bundled in the release you installed is the one the factory on your chain pins; vaults.verifyBinding(vault) confirms the vault set. Run both at start-up and refuse to render a vault that fails either.

Protocols and treasuries can deploy a vault with their own mandate through the factory. The factory deploys the mandate and the adapter, then the vault at the address it predicted, binds the three to each other and rejects a mismatched set (BadBinding()). The vault’s creation code is pinned by hash at factory deployment, so the caller passes the canonical type(NoteVault).creationCode and anything else reverts WrongVaultCode(expected, actual). Every vault the factory lists therefore runs the same audited bytecode.

INoteVault.BandInit[] memory bands = new INoteVault.BandInit[](1);
bands[0] = INoteVault.BandInit({ underlying: AAPL, band: INoteVault.Band({
enabled: true, feed: AAPL_FEED, minBarrierBps: 5_500, maxBarrierBps: 8_000, minCouponFloorBps: 25,
maxObservations: 12, maxTenor: 12 weeks, maxOpenSeries: 4, relativeCapBps: 3_000, absoluteCap: 50_000e6
}) });
INoteVault.PartnerInit[] memory partners = new INoteVault.PartnerInit[](1);
partners[0] = INoteVault.PartnerInit({ id: keccak256("wallet.example"), payee: payee, shareBps: 2_500 });
(address vault, address mandate, address adapter) = factory.create(INoteVault.InitParams({
name: "Partner USDG Notes", symbol: "pnUSDG",
owner: curator, guardian: guardian, feeRecipient: payee,
fees: INoteVault.Fees({ managementBps: 50, performanceBps: 1_000 }),
limits: INoteVault.Limits({
maxSeriesBps: 2_000, minIdleBps: 500, stockHaircutBps: 1_000,
allocateLead: 2 days, minAllocation: 1_000e6, depositCap: 0, exitLock: 24 hours
}),
mandateDelay: 3 days, bounty: 2e6, bountyReserve: 50e6,
bands: bands,
partners: partners
}), type(NoteVault).creationCode);

The protocol share of fees is fixed by the factory at creation and cannot be changed by the curator. Initial bands and partners are set at creation, so your first attributed deposit can follow in the next block. Partner ids must be distinct and a payee may not be the mandate or the vault itself (InvalidParameter("partners"), InvalidParameter("payee")); at most 32 distinct ids for the life of the vault (a partner is retired by setting its share to zero, which keeps the id usable for attribution), each paid shareBps of the fee pro rata to the shares attributed to it, so a partner can earn at most half of the fee its own holders generate. Later bands, new partners and any loosening go through the mandate’s timelocked path (submit then execute after the delay); tightening (disableBand, decreaseCaps, tightenLimits, lowerFees) is immediate. factory.nextVault() returns the address the next creation will land on, for integrators who register a vault before it exists.

Fresh shares wait exitLock (reference vault: 24 hours; mandate bounds one hour to seven days) before any exit path opens to them. Harvested coupons enter lockedProfit() and reach the share price linearly over seven days; a holder who exits inside that window is paid the released part and the remainder accretes to those who stay. The most a mint-and-exit round trip can capture is lockedProfit x exitLock / 7 days, pro rata to its share, while carrying full series risk for the lock period.

Breach stock is a Robinhood Stock Token. Those tokens are standard ERC-20s behind an issuer-controlled registry that can block individual accounts and pause transfers (block list, not allow list; contracts may hold them). The vault values stock after the mandate’s haircut, exitInKind(shares, receiver, false) exits without touching stock, and a failing delivery isolates to its position, so no issuer action on a stock can trap a quote exit. Disclose the haircut and this dependency to your users.