Every Monero transaction is a stack of cryptographic tricks designed to answer one impossible question: "How do you prove a payment is valid without revealing who sent it, who received it, or how much?" This schema unpacks the four primitives that make it work — stealth addresses, Pedersen commitments, ring signatures (CLSAG), and key images — and shows how they fit together inside one transaction blob.
A public blockchain leaks sender, receiver, and amount by default. Monero closes each leak with a distinct piece of cryptography, then adds a key image to keep the system sound under those blinders. CLSAG is the ring-signature half of the bundle; RingCT is the umbrella term for the whole construction (rings + commitments + range proofs).
Hides the receiver. Each output is sent to a fresh one-time pubkey derived from the recipient's address + a per-tx nonce. The recipient's main address never appears on chain.
Hides the amount. Each output value is locked inside C = aG + xH. Nodes verify sum(in) − sum(out) − fee = 0 without ever seeing a.
Hides the sender. Real input is mixed with 15 decoy outputs pulled from the chain. Signature proves one of the 16 keys signed, without revealing which.
Prevents double-spend. A deterministic tag I = x·Hp(P) derived from the real input's private key. Same input → same tag → network rejects duplicates.
A Monero address is two public keys: Ks (public spend key) and Kv (public view key). When Alice pays Bob, she does not use his address as the destination on chain. Instead, she derives a fresh one-time public key P from his address plus a random per-tx scalar r. Only Bob can recognize that P belongs to him, because the recognition equation requires his private view key kv.
Reusing an address makes payments trivially linkable. Stealth keys mean the chain never sees Bob's main address — only fresh one-time keys he can claim.
Bob's private view key kv recognizes incoming outputs. His private spend key ks is needed to actually spend them. Auditors get kv, not ks.
To spend output P, Bob computes the one-time private key x = Hs(kv·R, t) + ks. From x, the ring signature & key image follow.
Amounts are never written in clear. Each output's value a is wrapped in a commitment C = aG + xH, where x is a random blinding scalar and G, H are independent generators on edwards25519. The commitment is binding (you can't change a later) and hiding (nobody learns a from C alone). The genius is in the arithmetic: commitments add linearly, so balance can be verified on the blinded values.
Nothing in C = aG + xH stops a malicious sender from picking a negative a (i.e. a scalar larger than the curve order's midpoint, wrapping mod-L). A negative output amount would let two real outputs cancel out and conjure XMR from thin air. The fix: every output ships with a range proof showing 0 ≤ a < 264.
Original range proof used bit-decomposition + ring sigs per bit. Worked, but ~6.2 kB per 2-output tx.
Logarithmic-size range proofs via inner-product argument. Dropped tx size by ~80%. No trusted setup required.
Tighter weighted inner-product. ~5-7% smaller than BP, faster to verify. Current default in RCTTypeBulletproofPlus.
The recipient needs to know a and x to later spend the output. They are encrypted with a shared secret derived from r·Kv and stored in the ecdhInfo field. Only the holder of the matching private view key can decrypt them.
For each real input the wallet wants to spend, it picks 15 decoys from the chain's pool of past RingCT outputs (gamma-distributed by age — older outputs are weighted lower). The 16 keys form a ring. CLSAG proves that one of the 16 was signed by its true owner, without telling verifiers which. The decoys' commitments are not stored in the signature (a CLSAG win over MLSAG) — only their stealth addresses are referenced via key_offsets.
The key image I is a deterministic function of the real input's one-time private key. Two spends of the same output would produce the same key image, but two different outputs cannot collide (collision-resistance of Hp). Every node maintains a set of seen key images — any tx whose I is already in the set is rejected.
Wallet samples 15 prior outputs from the chain with a gamma distribution matched to real-world spend timing — recent outputs are more likely, mimicking organic behavior.
Real key inserted at a random index l. The 16 entries are stored as key_offsets (delta-encoded indices into the global output set) — saves bytes vs absolute indices.
Wallet computes I = x·Hp(P*) for the real input P* and places it in vin[i].k_image. This is the lifetime ID of that output's spend.
On receive: node checks I against its key-image set. Already present → invalid tx, score peer down. Validated → I permanently added.
CLSAG (deployed October 2020, replacing MLSAG) is a linkable ring signature that proves two facts at once: (1) the signer knows the private key for one of the ring's pubkeys and (2) the signer knows the blinding factor that makes C[l] − C_offset a commitment to zero (i.e. the pseudo-output commits to the same hidden amount as the real input). The "concise" part: by aggregating the two proofs with hashed scalars μP and μC, the signature only stores one ring instead of two.
| Symbol | Type | Meaning |
|---|---|---|
| m | 32-byte hash | Message digest: H(H(P1) ∥ H(P2) ∥ H(P3)) — covers every tx field except the CLSAGs themselves. |
| p | scalar | Private spend key of the real input (the one-time key the receiver derived earlier). |
| P[16] | point[16] | The ring's stealth addresses. P[l] = p·G at the secret index l. |
| z | scalar | Blinding mask of the pseudo-output commitment C_offset. |
| C[16] | point[16] | Ring commitments. C[l] = z·G after subtracting C_offset. |
| C_offset | point | The pseudoOut: a fresh commitment to the same amount as the real input, with a sender-chosen mask. Lets the balance equation close. |
Hash ring data into domain-separated scalars μP and μC. They fold the two proofs (key & commitment) into a single ring.
Pick random scalar α. Compute L = αG, R = α·Hp(P[l]). These are the "real" challenge inputs at index l.
Starting at l+1, pick random s[i] and rebuild Li, Ri, hash forward to next challenge ci+1. Wrap around back to l.
Set s[l] = α − cl(p·μP + z·μC). The loop closes only if the signer truly knows p and z at index l.
A verifier doesn't know l, α, p, or z. It re-derives μP, μC from public data, then walks the ring from i=0: at each step, recompute Li = s[i]·G + c·μP·P[i] + c·μC·(C[i]−C_offset) and Ri = s[i]·Hp(P[i]) + c·μP·I + 8·c·μC·D, hash to the next challenge. After 16 hops, the loop should land back on c1. If it does, the signature is valid — and the verifier still has no idea which index was real.
A serialized Monero tx (RCTTypeBulletproofPlus, current default) is split into a non-prunable core and a prunable proofs section. The prunable part can be discarded by pruned nodes after deep confirmation — they keep the key images and commitments forever, but drop the bulky range proofs and ring signatures.
| Field | Where | What's in it |
|---|---|---|
| Core (always kept) | ||
| version | tx prefix | Tx format version. Currently 2 (post-RingCT). |
| unlock_time | tx prefix | Block height or timestamp until which outputs are locked. Almost always 0. |
| vin[] | tx prefix | For each input: amount (always 0 in RingCT), key_offsets[] (delta-encoded indices of the 16 ring members), k_image (the key image I). |
| vout[] | tx prefix | For each output: dummy amount=0, target.key (stealth address P), view_tag (1 byte, since v15). |
| extra | tx prefix | Tag-length-value blob. Holds the tx public key R, optional additional pub keys (for multi-output to subaddresses), and the encrypted payment ID. |
| rct_signatures (non-prunable parts) | ||
| type | rct | 6 = RCTTypeBulletproofPlus (current). Older types: 1 Full, 2 Simple, 3 BP, 4 BP2, 5 CLSAG. |
| txnFee | rct | Fee in plaintext (verifiers need it for the balance equation). Paid to whichever miner includes the tx. |
| ecdhInfo[] | rct | Per output: encrypted (amount, mask) blob using shared secret H(r·Kv). Only receiver can decrypt. |
| outPk[] | rct | The output commitments C_out = a·G + x·H — these are public. |
| rct_prunable (can be dropped by pruned nodes) | ||
| bpp[] | prunable | Bulletproof+ aggregated range proof. One per tx, proves all output amounts are in [0, 264). |
| CLSAGs[] | prunable | One CLSAG σ = {s, c1, D} per input. ~96 bytes + 32·ring_size for the s vector. |
| pseudoOuts[] | prunable | One C_offset per input. Sums to Σ outPk + fee·H — closing the balance equation. |
When monerod receives a tx (whether from a peer's stem-relay or fluff broadcast — see the P2P schema), it runs the full battery below before admitting it to the local tx_pool. Any single failure = reject, peer offense score down.
For each input: re-derive μP, μC, walk the ring from c1, confirm the loop closes. Proves some ring member signed.
Check I is not in the chain's key-image set, nor in any other pool tx. Also: I must lie in the main subgroup (no torsion attacks).
Verify the aggregated range proof. Confirms every output amount is in [0, 264) — no negative-amount inflation.
Σ pseudoOuts − Σ outPk − fee·H = 0. The single equation that proves no XMR was created or destroyed.
Every key_offset resolves to a real, on-chain output of compatible age (10-block lock) and matching version.
Tx weight under cap. Fee ≥ dynamic min (depends on network load). Version matches current hard-fork rules.
The tx is added to tx_pool, indexed by hash, sorted by fee-per-byte. The daemon then relays it via Dandelion++ (stem if in stem mode, fluff otherwise). The included key images are not yet committed to the chain — they only become permanent once the tx lands in a block. Until then a pool tx can be evicted, expired, or replaced by an alternate spend of the same input.