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.
What a vault is
Section titled “What a vault is”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.
| Contract | Role |
|---|---|
NoteVault | Shares, deposits, exits, allocation and lifecycle. No owner |
NoteVaultMandate | The published rules: bands, limits, fees, partners. Tightening is immediate, loosening waits out a delay |
NoteCoreAdapter | Holds the vault’s COUPON legs and talks to NoteCore; the vault’s only counterparty |
NoteVaultFactory | Deploys 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.
Discover
Section titled “Discover”uint256 n = factory.vaultCount();// paged, oldest firstaddress[] memory vs = factory.vaults(0, 20);NoteVault v = NoteVault(vs[0]);NoteVaultMandate m = v.mandate();// managementBps per year, performanceBps on couponsINoteVault.Fees memory fees = m.fees();// maxSeriesBps, minIdleBps, stockHaircutBps, depositCapINoteVault.Limits memory lim = m.limits();// stocks the vault may takeaddress[] memory u = m.underlyings();// barrier, coupon floor, observations, tenor, capsINoteVault.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 priceuint256 shares = v.previewDeposit(assets);uint256 assets = v.previewRedeem(shares);// depositCap less total assets; type(uint256).max when uncappeduint256 cap = v.maxDeposit(receiver);// idle quote a withdraw can take this blockuint256 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.
Deposit with attribution
Section titled “Deposit with attribution”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.
| Route | Call | When it settles |
|---|---|---|
| Instant | withdraw(assets, receiver, owner) or redeem(shares, receiver, owner) | Same block, from idle quote up to instantAvailable() |
| Queued | requestRedeem(shares, receiver, owner) then claimRedeem(id) | Filled oldest first by fillQueue() as idle quote returns; partial fills pay out as they happen |
| In kind | exitInKind(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 accountv.fillQueue();// pays the filled part to receiveruint256 paid = v.claimRedeem(id);// returns the unfilled shares to ownerv.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);// pageduint256[] memory ids = v.requestsOf(owner, 0, 20);// shares, filledShares, filledAssets, claimedAssets, cancelledINoteVault.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 stockuint256 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.
Lifecycle
Section titled “Lifecycle”You do not need to call these; they are listed so your interface can explain what moves the share price.
| Call | Who | Effect |
|---|---|---|
allocate(seriesId) | anyone | Posts 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() | anyone | Accrues 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) | anyone | Closes a position whose series autocalled, matured, was cancelled or unwound |
collectFees() | anyone | Converts 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.
Errors you will see
Section titled “Errors you will see”| Error | Meaning |
|---|---|
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 |
TypeScript SDK
Section titled “TypeScript SDK”@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:
npm install viem https://note.systems/sdk/note-systems-sdk-0.1.1.tgzlatest.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); // discoverif (!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 seriesconst shares = await note.vaults.previewDeposit(vault, 100_000_000n); // quoteawait 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 appliedconst h = await note.vaults.holding(vault, receiver); // shares, value, tag, unlock time, maxWithdrawtry { 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.
SDK releases
Section titled “SDK releases”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.
| Version | Tarball | sha512 | Contracts |
|---|---|---|---|
| 0.1.1 | note-systems-sdk-0.1.1.tgz | 6e19d34d9094d125…f9aec1440a263d22 (full) | Robinhood Chain testnet 46630, factory 0x2BB2324cC49C6D15367A70DF64D4740A80363F1E |
| 0.1.0 | note-systems-sdk-0.1.0.tgz | 4cb537a1183134ef…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:
curl -sO https://note.systems/sdk/note-systems-sdk-0.1.1.tgzcurl -s https://note.systems/sdk/note-systems-sdk-0.1.1.tgz.sha512 | sha512sum -cAfter 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.
Deploy your own vault
Section titled “Deploy your own vault”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.
Exit lock and released profit
Section titled “Exit lock and released profit”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.
Stock legs and issuer controls
Section titled “Stock legs and issuer controls”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.