A private portfolio ledger for tokenized equities.
Noma is one vault contract on Robinhood Chain and a ledger it never sees. The vault holds everyone's USDG and stock tokens together, executes the net of each batch against Uniswap v4, and commits one Merkle sum root per token. Who owns which leaf is known to the owner, to the operator that keeps the ledger, and to nobody else.
1. Overview
A wallet on a public chain is a public brokerage statement. Noma's premise is that settlement has to be public and the statement does not. The chain is given exactly what it needs to settle and to let owners leave, and nothing that would let an observer reconstruct anyone's book.
- Deposits are ordinary transfers into the vault, tagged with a hash the depositor registered off chain. Public.
- Positions are leaves
(key, token, amount, salt)in a Merkle sum tree per token. Only roots and sums go on chain. - Orders are EIP-712 messages signed by a leaf's one-time key and sent to the operator. Never posted.
- Batches credit deposits, execute the net residual of all orders on Uniswap v4, pay exits, and commit new roots the vault can cover. Signed by a quorum of attesters.
- Exits pay a leaf to any address, against a proof and the leaf key's signature. They work with the operator, without it, and against it.
2. Positions and keys
Meta-addresses
An owner holds two secp256k1 key pairs: a spend key and a view key. The pair of public keys is their meta-address. It is given to the operator and never appears on chain.
One-time keys
For every new position the operator runs the stealth handshake from ERC-5564: draw an ephemeral key e, compute s = keccak(ECDH(e, view)), and set the position's key to the address of spend + s·G. The ephemeral public key is published inside the position's note. The owner recomputes s with the view key, recognises the address, and signs for it with spendPriv + s. The operator can derive the address but cannot sign for it.
Leaves and trees
leafHash = keccak256(abi.encode(key, token, amount, salt))
node(l, r) = (0, 0) if l.h == 0 and r.h == 0
= (keccak256(abi.encode(l.h, l.s, r.h, r.s)), l.s + r.s) otherwise
Each listed token has its own tree of depth 20. A node carries a hash and the sum of every amount beneath it, so the root commits to the total. Leaves are placed in numbered slots; a slot freed by a spent leaf is reused. A proof is 20 sibling pairs, hash then sum, leaf level first, and the contract walks it with the leaf's slot index.
Notes
Every new leaf is sealed under AES-256-GCM with a key derived from the handshake secret and written to calldata as ephemeralPub(33) | iv(12) | len(2) | ciphertext. An owner tries every note in every batch with their view key. The ones that authenticate are theirs and carry the leaf, its salt and its slot. A note that is not theirs fails authentication and reveals nothing.
3. Batches and netting
The struct
struct Batch {
uint64 number; // previous + 1
bytes32 dataHash; // keccak256 of the calldata blob
uint256[] deposits; // deposit ids credited by this batch
Exit[] exits; // proven against the roots this batch replaces
Swap[] swaps; // net legs against Uniswap v4, exact input, minOut pinned
bytes32[] roots; // per listed token, listing order
uint256[] sums;
}
digest = keccak256("\x19\x01" ‖ DOMAIN_SEPARATOR ‖ keccak256(abi.encode(keccak256("NomaBatch"), batch)))
postBatch verifies a quorum of attester signatures over that digest (ascending signer addresses, so nobody is counted twice), consumes the credited deposits, runs the swaps inside one unlock against the PoolManager, pays each exit, and then, for every listed token, requires balanceOf(vault) ≥ sums[i] + uncredited[token] before storing the new root and sum.
Netting
Orders are USDG against one equity, in either direction. For each equity the operator sums the USDG wanting to buy it, B, and the shares wanting to sell, S, and reads the pool's mid price P. The two sides cross at P for min(B, S·P) and only the remainder goes to the pool:
- Buy-heavy (
B ≥ S·P): sellers are filled entirely atP; buyers shareS + poolOutpro rata, wherepoolOutis whatB − S·PUSDG bought from the pool. - Sell-heavy: buyers are filled entirely at
P; sellers shareB + poolOutUSDG pro rata, wherepoolOutis what the surplus shares sold for.
Everyone in a batch gets the same blended price. The operator simulates the residual swap before posting and pins minOut to that fill, so the batch either fills at least as well as the ledger assumed or reverts and is rebuilt. Anything filled better than assumed, and any rounding dust, stays in the vault unclaimed by any leaf and is folded into a later batch.
Cadence
The contract does not fix a cadence; the reference operator posts on a timer and whenever the queue is large enough to be worth netting. A single order at a single moment is a fingerprint. A batch's residual is the net of everyone in it, which is not.
4. Calldata
Only dataHash is stored, but the blob it hashes rides in the calldata of every postBatch, so it is as available as the chain itself:
blob = 0x01
| uint16 tokenCount
| per token: uint32 n, n × (uint32 slot | bytes32 leafHash | uint256 amount)
| uint32 noteCount
| notes…
A removed leaf is written as an empty slot. Replaying the slot updates of every batch rebuilds every tree exactly, which is how an owner produces a proof for their own leaf without asking anyone. The leaf hashes say nothing about who owns them; only the notes do, and only to their addressees.
5. Exits and the escape hatch
Cooperative
The owner signs Exit(leaf, to) with the leaf's key and sends it to the operator, which includes the leaf, its slot, its proof, the destination and the signature in the next batch. The vault verifies the proof against the root it is about to replace, verifies the signature, marks the leaf hash spent, and transfers the amount to to. The operator removes the leaf from the new root.
Demanded
If the operator does not answer, the owner calls demandExit(leaf, index, proof) on chain. This reveals the leaf, as any exit does, and starts a 24 hour clock. The operator can settle it by including the exit in a batch, or refute it with refuteDemand(leaf, order, sig): the owner's own signed order that spent that leaf, which is the only way a live leaf can legitimately disappear without an exit.
Frozen
Anyone can call freeze(leafHash) for a demand that passed its deadline unsettled and unrefuted, or freeze(0) when no batch has been posted for 48 hours. The owner can freeze at any time to wind down. A frozen vault accepts no deposits and no batches. Every leaf in the last roots can be withdrawn by its owner with forceExit, and every uncredited deposit is immediately reclaimable. Because the last roots are final, force exits cannot double spend against a root the operator might replace.
6. Trust model
| Property | Enforced by | Depends on |
|---|---|---|
| Tokens leave only against a leaf in a signed root, once, to where its key signed | the contract | nothing |
| The ledger never promises more of a token than the vault holds | the contract, every batch | nothing |
| An uncredited deposit returns to its depositor | the contract, after 24h or when frozen | nothing |
| An owner can always leave with their last committed balance | the contract, via demand → freeze → forceExit | the calldata reproducing the roots |
| Balances move only as their keys instructed between two roots | the attesters' review before signing | the quorum's honesty |
| The public cannot see positions, orders or allocations | the design | the operator and attesters not leaking |
The quorum is the trusted component and this document does not pretend otherwise. What it is trusted for is narrow: the honesty of one state transition. A validity proof of that transition, checked in postBatch instead of signatures, removes the quorum without changing the ledger, the notes, the trees, or anything an owner does. That is the intended end state.
Things a careful owner still does: deposit from an address that does not need to be linked to their book, exit to a fresh address, and avoid depositing and exiting the same unusual amount in adjacent batches, since amounts and timing are the correlations that remain.
7. Contract interface
| Function | Who | What |
|---|---|---|
deposit(token, amount, tag) | anyone | Pulls a listed token in; returns the deposit id. Emits Deposited. |
reclaimDeposit(id) | depositor | Refunds an uncredited deposit after CREDIT_WINDOW, or at once when frozen. |
postBatch(batch, blob, sigs) | anyone with a quorum | Advances the ledger by one batch. Emits BatchPosted(number, dataHash, roots, sums). |
demandExit(leaf, index, proof) | owner | Starts the exit clock for a leaf in the current root. |
refuteDemand(leaf, order, sig) | anyone | Clears a demand with the owner's signed order that spent the leaf. |
freeze(leafHash | 0) | anyone, or the owner | Freezes on an unanswered demand, a stale operator, or the owner's word. |
forceExit(exit) | owner | Pays a leaf in the last root once frozen. |
list(token) | owner | Lists a token. Listing is permanent. |
proposeRotation(attesters, threshold) / executeRotation() | owner | Replaces the quorum after ROTATION_DELAY. |
batchDigest, exitDigest, orderDigest, leafHash | view | The exact hashes the contract checks, so clients cannot drift. |
Swaps run through the vault's own unlockCallback, direct to the PoolManager, exact input, with both currencies required to be listed. There is no router in the path and no approval left standing.
8. Parameters
| Constant | Value |
|---|---|
CREDIT_WINDOW | 24 hours |
EXIT_WINDOW | 24 hours |
STALE_WINDOW | 48 hours |
ROTATION_DELAY | 24 hours |
| Tree depth | 20 (1,048,576 slots per token) |
| EIP-712 domain | Noma, version 1, chain 4663, the vault |
| PoolManager | 0x8366a39CC670B4001A1121B8F6A443A643e40951 |
| USDG (6 decimals) | 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 |
| Listed at launch | SPY, NVDA, AAPL, TSLA, each on its 0.3% / 60 pool against USDG, no hook |
9. Reference operator
The operator is a small JavaScript library: stealth.js (the handshake), notes.js (sealing), tree.js (the sum tree), da.js (the blob), batch.js (the exact bytes that get signed), ledger.js (netting and batch building) and client.js (everything an owner's wallet does with the chain alone). The demo on the home page runs this library unchanged in your browser.
A batch's life, from the operator's side:
- Watch
Depositedevents; match tags to registered meta-addresses. - Accept signed orders and exits; reject anything whose signature does not recover to the leaf's key.
- Read pool mids,
plan()the residual per equity, simulate each swap witheth_call. build(prices, fills): new leaves, sealed notes, trees, roots, blob, attester signatures.- Send
postBatch. If it reverts onSlippageorConservation, re-simulate and rebuild.
The same scenario the tests run, three batches and a forced exit, is written out as calldata and replayed byte for byte against the compiled contract in Foundry. Any drift between the JavaScript and the Solidity in hashing, domains, proofs or signatures fails that replay.
10. Status
NomaVault.solandSumTree.sol: written; 31 unit tests, 2 fork tests against the live TSLA/USDG pool on Robinhood Chain, 1 operator replay. All green.- Reference operator and owner client: written; 6 tests.
- Vault: live on Robinhood Chain at
0x710a9Da5cF5ccfBa3daF5c7182aBdb31210ac4C9, deployed at block 61053149 with one attester (threshold 1) and USDG, SPY, NVDA, AAPL, TSLA listed. - Operator: hosted; it reads the vault's inbox and posts batches. Its public key is in
markets.json, so anyone can seal a message to it. - App: /app connects a wallet, derives keys from one signature, and does everything else against the chain.
- Next: more attesters, then the validity proof that retires the quorum.