Axiomatic
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:

FieldDescription
ownerPubkeyPoseidon hash of the owner's spending key
tokenHashSHA-256 hash of the token address
amountValue in the token's smallest unit
salt32-byte random nonce for uniqueness
ephemeralPubKeySender's ephemeral X25519 public key for ECDH
scanTag4-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"]
  end

All 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:

  1. Fetches NoteCreated and NoteNullified events from the ConfidentialLedger
  2. Trial-decrypts each NoteCreated event using the entity's IVK
  3. Classifies the transfer type based on co-occurring events in the same transaction
  4. 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:

ColumnTypeEncryptedDescription
iduuidNoPrimary key
entityIduuidNoOwning entity
noteCommitmenttextNoPoseidon commitment hash
nullifierHashtextNoSet when note is spent
tokentextNoToken address (for querying)
amounttextYesNote value
salttextYesRandom nonce
recipientPubkeytextYesOwner's public key
leafIndexintegerNoPosition in Merkle tree
isSpentbooleanNoTrue when nullifier is recorded
createdInTxHashtextNoTransaction that created the note
spentInTxHashtextNoTransaction that spent the note

On this page