02 / Note lifecycle

Wrap. Carry.
Claim.

A note has three moments: it is minted, it may change hands any number of times, and it is redeemed once. This page walks each step with the exact calls, checks and events involved.

Overview

#
MINTwrap()
MOVEtransferFrom()
REDEEMclaim()
READpositions() · isClaimable()

Every transition is a single transaction on VellumVault. There are no pending states, no queues and no second party who must approve. The full lifecycle is exercised end to end by npm run test:vault (see testing).

1 · Wrap

#

Wrapping deposits an ERC-20 amount and mints a note to the caller. Two transactions are normally required: an ERC-20 approve for the vault, then wrap.

a

Approve

Call approve(vault, amount) on the token. The app skips this step when the existing allowance already covers the amount.

b

Pre-checks

The vault requires: wraps not paused, a non-zero token address, a non-zero amount, and a term that is either 0 or within [1 day, 3650 days].

c

Exact-balance deposit

The vault records its own balance, pulls amount with transferFrom, and requires that its balance grew by exactly amount. Fee-on-transfer or rebasing tokens fail here with Vellum: unsupported token transfer.

d

Mint

The position is written, tokenId = nextTokenId++ is minted to msg.sender, and Transfer(0x0, sender, tokenId) plus NoteWrapped are emitted.

solidityfunction wrap(address token, uint256 amount, uint64 termSeconds)
    external nonReentrant returns (uint256 tokenId);
Reading the token id

The new id is the return value of wrap. From a wallet UI you cannot read return values, so the app reads nextTokenId immediately before sending the transaction and uses that; the NoteWrapped event in the receipt is the authoritative source.

2 · Carry & transfer

#

A live note is a standard ERC-721. Holding it requires no action. Moving it uses the usual surface:

FunctionWho may callEffect
transferFrom(from, to, id)Owner, approved address, or operatorMoves the note. Clears the per-token approval.
safeTransferFrom(from, to, id[, data])Same as aboveSame, then calls onERC721Received if to is a contract and requires the magic value.
approve(to, id)Owner or operatorLets one address transfer this note once.
setApprovalForAll(op, bool)Anyone, for their own notesLets an operator transfer all of the caller's notes.

During a transfer nothing in positions changes and the vault's token balance does not move. The buyer inherits the same token, amount and maturity the seller had, and can verify them with one positions(tokenId) call before accepting.

Transfers are final

The vault keeps no record of who wrapped a note. After a transfer, the previous holder is an ordinary address with no rights over the position; attempting claim reverts with Vellum: holder only.

3 · Claim

#

The current holder redeems the note with a single call. The order of operations matters and is designed so the note can never be claimed twice:

a

Authorise

Requires msg.sender == ownerOf(tokenId). Operators and approved addresses are not allowed to claim.

b

Guard

Requires !position.claimed, then sets claimed = true before any external call (checks-effects-interactions, plus a reentrancy lock).

c

Burn

The note is burned: owner mapping cleared, balance decremented, approval deleted, Transfer(owner, 0x0, tokenId) emitted.

d

Release

The vault transfers amount of token to the holder and emits NoteClaimed. A failed ERC-20 transfer reverts the whole transaction with Vellum: release failed.

solidityfunction claim(uint256 tokenId) external nonReentrant;

Claiming does not check maturity. A note wrapped with a one-year term can be claimed a second later. After a claim, ownerOf(tokenId) reverts with Vellum: unknown note and isClaimable(tokenId) returns false; the position row stays readable with claimed == true.

State machine

#
textwrap()                         claim()
  (none) ───────────▶  LIVE  ──────────────────────────▶  CLAIMED
                        │  ▲                               (note burned,
                        │  │ transferFrom / safeTransferFrom  position.claimed = true)
                        └──┘   (owner changes, position unchanged)

There are exactly two states for a position. LIVE: note exists, claimable, transferable. CLAIMED: note burned, terminal. A pause of wraps only prevents entering LIVE; it does not affect notes already in LIVE.

Events timeline

#
MomentEvents emitted (in order)
WrapTransfer(address(0), owner, tokenId) then NoteWrapped(tokenId, owner, token, amount, maturity)
TransferTransfer(from, to, tokenId)
ApproveApproval(owner, approved, tokenId) or ApprovalForAll(owner, operator, approved)
ClaimTransfer(owner, address(0), tokenId) then NoteClaimed(tokenId, owner, token, amount)
GuardianWrapsPauseSet(paused) · GuardianTransferProposed(current, pending) · GuardianTransferred(previous, next)

Indexers can reconstruct every note's full history from Transfer alone; NoteWrapped and NoteClaimed add the economic payload so no extra storage reads are needed.

Worked example

#

This is the sequence scripts/test-vellum-vault.mjs runs against a local node, expressed as calls:

textdeployer:  MockToken.approve(vault, 250000e18)
deployer:  vault.wrap(MockToken, 250000e18, 90 days)      → tokenId 1, NoteWrapped
anyone:    vault.positions(1)                             → (MockToken, 250000e18, now+90d, false)
anyone:    vault.isClaimable(1)                           → true   (same block as mint)
deployer:  vault.transferFrom(deployer, buyer, 1)         → Transfer(deployer, buyer, 1)
deployer:  vault.claim(1)                                 ✗ "Vellum: holder only"
buyer:     vault.claim(1)                                 → buyer +250000e18, NoteClaimed
buyer:     vault.claim(1)                                 ✗ "Vellum: unknown note"