Privacy & Confidential Transfers
Shielded Notes
Note lifecycle, key management, and scanner architecture for the UTXO-based privacy system.
Note Lifecycle
stateDiagram-v2
[*] --> Created: NoteCreated event emitted on-chain
Created --> Scanned: Scanner polls block range
Scanned --> Decrypted: Trial decrypt with IVK succeeds
Scanned --> Ignored: Trial decrypt fails (not ours)
Decrypted --> Stored: recordNote() inserts into DB
Stored --> Selected: selectNotes() picks for transfer
Selected --> Spent: NoteNullified event confirmed
Stored --> Spent: NoteNullified event confirmed
Ignored --> [*]
Spent --> [*]A shielded note is a UTXO (unspent transaction output) in the ConfidentialLedger. Each note contains:
| Field | Description |
|---|---|
ownerPubkey | Poseidon hash of the owner's spending key |
tokenHash | SHA-256 hash of the token address |
amount | Value in the token's smallest unit |
salt | 32-byte random nonce for uniqueness |
ephemeralPubKey | Sender's ephemeral X25519 public key for ECDH |
scanTag | 4-byte keccak256 fingerprint of recipient's X25519 public key for indexed filtering |
The commitment is Poseidon(ownerPubkey, Poseidon(tokenHash, amount, salt)) and is stored on-chain in the Merkle tree.
On-Chain Event
NoteCreated(commitment, leafIndex, scanTag, ephemeralPubKey, encryptedNote)
- ephemeralPubKey = sender's ephemeral X25519 public key R
- scanTag = keccak256(recipientX25519Pub)[0:4]
- encryptedNote = AES-GCM(HKDF(x25519(r, recipientPub), commitment, "axiomatic.note.ecdh.v1"), noteData)The nullifier is Poseidon(spendingKey, commitment) and is revealed when spending the note, preventing double-spends.
Key Management
flowchart TB
MSK["Master Spending Key (MSK)<br/>32 random bytes"]
MSK -->|"Poseidon(msk, domain:spending)"| SK["Spending Key (SK)"]
MSK -->|"Poseidon(msk, domain:ivk)"| IVK["Incoming Viewing Key (IVK)"]
MSK -->|"Poseidon(msk, domain:ovk)"| OVK["Outgoing Viewing Key (OVK)"]
SK -->|"Poseidon(sk, sk)"| PK["Owner Public Key"]
IVK -->|"decrypt incoming notes"| DEC["Trial Decryption"]
OVK -->|"decrypt outgoing notes"| ODEC["Outgoing Decryption"]
SK -->|"derive nullifiers"| NULL["Nullifier = Poseidon(sk, commitment)"]
subgraph grants["Viewing Key Grants"]
IVK -->|"encrypt under auditor key"| GRANT["Auditor Grant"]
GRANT -->|"time-limited, revocable"| AUDIT["Auditor reads notes"]
endAll keys are derived from the MSK using domain-separated Poseidon hashing. The MSK is encrypted at rest under the entity's data encryption key (DEK).
Scanner Architecture
flowchart TB
subgraph sync["sync.ts — Orchestrator"]
LOOP["Per-wallet sync loop"]
GS["getShieldedScanner(chain, entityId)"]
end
subgraph scanner["ShieldedScanner"]
POLL["Poll eth_getLogs"]
NC["NoteCreated events"]
NN["NoteNullified events"]
TD["Trial decrypt with IVK"]
CLASS["Classify transfer type"]
end
subgraph db["Database"]
SN["shielded_notes table"]
VKG["viewing_key_grants table"]
end
LOOP --> GS
GS -->|"fetch IVK from viewing_key_grants"| VKG
GS -->|"fetch known nullifiers"| SN
GS --> POLL
POLL --> NC
POLL --> NN
NC --> TD
TD -->|"success"| CLASS
TD -->|"fail (not our note)"| SKIP["Skip"]
CLASS -->|"no nullifiers in same tx"| DEP["shielded_deposit"]
CLASS -->|"nullifiers in same tx"| TIN["shielded_transfer_in"]
NN -->|"nullifier in known set"| NCLASS["Classify nullification"]
NCLASS -->|"NoteCreated in same tx"| TOUT["shielded_transfer_out"]
NCLASS -->|"no NoteCreated"| WITH["shielded_withdrawal"]The scanner runs as part of the regular on-chain sync loop. For each wallet, it:
- Fetches
NoteCreatedandNoteNullifiedevents from the ConfidentialLedger - Trial-decrypts each
NoteCreatedevent using the entity's IVK - Classifies the transfer type based on co-occurring events in the same transaction
- Records new notes and marks spent notes in the database
Database Schema
The shielded_notes table stores decrypted note data encrypted under the entity's DEK:
| Column | Type | Encrypted | Description |
|---|---|---|---|
id | uuid | No | Primary key |
entityId | uuid | No | Owning entity |
noteCommitment | text | No | Poseidon commitment hash |
nullifierHash | text | No | Set when note is spent |
token | text | No | Token address (for querying) |
amount | text | Yes | Note value |
salt | text | Yes | Random nonce |
recipientPubkey | text | Yes | Owner's public key |
leafIndex | integer | No | Position in Merkle tree |
isSpent | boolean | No | True when nullifier is recorded |
createdInTxHash | text | No | Transaction that created the note |
spentInTxHash | text | No | Transaction that spent the note |