Specification v0.1

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.

OverviewPositions and keysBatches and nettingCalldataExits and the escape hatchTrust modelContract interfaceParametersReference operatorStatus

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.

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:

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.

The attesters sign a batch only after rebuilding the trees from the blob themselves and checking that the roots match. A batch whose calldata does not reproduce its roots must never be signed, because it would leave owners unable to prove their leaves.

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

PropertyEnforced byDepends on
Tokens leave only against a leaf in a signed root, once, to where its key signedthe contractnothing
The ledger never promises more of a token than the vault holdsthe contract, every batchnothing
An uncredited deposit returns to its depositorthe contract, after 24h or when frozennothing
An owner can always leave with their last committed balancethe contract, via demand → freeze → forceExitthe calldata reproducing the roots
Balances move only as their keys instructed between two rootsthe attesters' review before signingthe quorum's honesty
The public cannot see positions, orders or allocationsthe designthe 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

FunctionWhoWhat
deposit(token, amount, tag)anyonePulls a listed token in; returns the deposit id. Emits Deposited.
reclaimDeposit(id)depositorRefunds an uncredited deposit after CREDIT_WINDOW, or at once when frozen.
postBatch(batch, blob, sigs)anyone with a quorumAdvances the ledger by one batch. Emits BatchPosted(number, dataHash, roots, sums).
demandExit(leaf, index, proof)ownerStarts the exit clock for a leaf in the current root.
refuteDemand(leaf, order, sig)anyoneClears a demand with the owner's signed order that spent the leaf.
freeze(leafHash | 0)anyone, or the ownerFreezes on an unanswered demand, a stale operator, or the owner's word.
forceExit(exit)ownerPays a leaf in the last root once frozen.
list(token)ownerLists a token. Listing is permanent.
proposeRotation(attesters, threshold) / executeRotation()ownerReplaces the quorum after ROTATION_DELAY.
batchDigest, exitDigest, orderDigest, leafHashviewThe 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

ConstantValue
CREDIT_WINDOW24 hours
EXIT_WINDOW24 hours
STALE_WINDOW48 hours
ROTATION_DELAY24 hours
Tree depth20 (1,048,576 slots per token)
EIP-712 domainNoma, version 1, chain 4663, the vault
PoolManager0x8366a39CC670B4001A1121B8F6A443A643e40951
USDG (6 decimals)0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168
Listed at launchSPY, 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:

  1. Watch Deposited events; match tags to registered meta-addresses.
  2. Accept signed orders and exits; reject anything whose signature does not recover to the leaf's key.
  3. Read pool mids, plan() the residual per equity, simulate each swap with eth_call.
  4. build(prices, fills): new leaves, sealed notes, trees, roots, blob, attester signatures.
  5. Send postBatch. If it reverts on Slippage or Conservation, 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