Privacy Suite
Ring Signatures

Ring Signatures

Core Concept: Your address is mixed with others in a ring of 2-128. Rings are split evenly between senders and recipients — only half are possible senders. The blockchain can't determine which one actually sent.

How It Works

Traditional signature:

Alice signs → Everyone knows Alice sent it ❌

Ring signature:

[Alice + N-1 others] sign → ONE of N/2 possible senders sent it, which one? Unknown ✅

Ring Size Impact

Ring SizePrivacy LevelTX Size (bytes)Naive Guess Probability
2Minimal15531 in 1 (no sender privacy)
4Low20131 in 2
8Medium26051 in 4
16Good34611 in 8
32Strong48251 in 16
64Very Strong72851 in 32
128Maximum118391 in 64

Note: Rings are split evenly — half the members are potential senders, half are potential recipients. The naive guess probability is therefore 1/(ring_size/2), not 1/ring_size. Ring size 2 (1 sender slot, 1 recipient slot) provides no meaningful sender privacy.

Sources:

  • Ring size limits: config/config.go:58-59
  • Transaction sizes: README.md (Transactions Sizes table)
const MIN_RINGSIZE = 2
const MAX_RINGSIZE = 128

Real Example

Transaction: 4b2ebb476849260f077865756b74248a3ced966628b82eb10cdc56482b852d59

Ring Members (16):
  0: dero1qy...83dd4s
  1: dero1qy...jssfzr
  2: dero1qy...0q74k4
  3: dero1qy...tr9wrs
  4: dero1qy...qa33r9m ← Unknown if sender or decoy
  5: dero1qy...73jc3d
  ...
  15: dero1qy...9nql7m

ONE sent 200,000 DERO
Which one? HIDDEN

Blockchain shows (for this example ring):

  • ✓ 8 possible senders (half the ring)
  • ✗ Amount (encrypted)
  • ✗ Cannot determine real sender
  • ✗ Cannot link to other TXs

How Ring Members Are Selected

From source code (cmd/derod/rpc/rpc_dero_getrandomaddress.go):

// Get addresses with UNCHANGED balances (past 5 blocks)
for i := 0; i < 100; i++ {
    address := balance_tree.Random()
    old_balance := balance_tree_old.Get(address)
    balance := balance_tree.Get(address)
    
    if balance != old_balance {
        continue  // Skip if balance changed recently
    }
    
    candidates = append(candidates, address)
}
 
// Returns up to ~140 candidates
// Wallet selects ringsize-1 decoys for the ring

Why unchanged balances?

  • Better decoys (less likely to exclude)
  • Prevents timing attacks
  • More plausible anonymity

The Parity Constraint: How the Ring Is Split

The even/odd split between sender and recipient positions is not a convention — it is a hard constraint enforced at both the wallet layer and the cryptographic proof layer.

The Rule

When the wallet shuffles ring positions, it keeps shuffling until the sender and receiver land on indices of different parity (one even, one odd).

From source code (walletapi/transaction_build.go:79-90):

for {
    crand.Shuffle(len(witness_index), func(i, j int) {
        witness_index[i], witness_index[j] = witness_index[j], witness_index[i]
    })
 
    // make sure sender and receiver are not both odd or both even
    // sender will always be at  witness_index[0] and receiver will always be at witness_index[1]
    if witness_index[0]%2 != witness_index[1]%2 {
        break
    }
}

The comment in the source says it plainly: sender and receiver must not share parity. One occupies an even index; the other occupies an odd index.

Cryptographic Enforcement

The proof generation encodes this split into two separate polynomial sets — P for even-indexed ring members, Q for odd-indexed ring members. An observer cannot determine which parity the sender occupies.

From source code (cryptography/crypto/proof_generate.go:700-710):

for i := 0; i < N; i++ {
    var poly [][]*big.Int
    if i%2 == 0 {
        poly = P   // Even indices: one role (sender or recipient)
    } else {
        poly = Q   // Odd indices: the other role
    }
    // ... poly[j][(witness.Index[0]+N-(i-i%2))%N] used for sender amounts
    // ... poly[j][(witness.Index[1]+N-(i-i%2))%N] used for recipient amounts
}

The witness index itself is encoded as an 8-bit string (for ring size 16, m=4 bits per role) that interleaves sender and receiver positions (proof_generate.go:523):

witness_index := reverse(fmt.Sprintf("%0"+fmt.Sprintf("%db", m)+"%0"+fmt.Sprintf("%db", m), witness.Index[1], witness.Index[0]))

This structure means the proof system treats even and odd positions as fundamentally separate roles — an attacker cannot forge a valid proof by swapping one for the other.

Concrete Example: Ring Size 16

Ring positions:  0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
Parity:          E   O   E   O   E   O   E   O   E   O   E   O   E   O   E   O
                 └── Sender candidates ──┘   └── Recipient candidates ──┘
                     (8 even slots)               (8 odd slots)
                                    (or vice versa — observer cannot tell which)
  • 8 even indices (0, 2, 4, 6, 8, 10, 12, 14) — one parity role
  • 8 odd indices (1, 3, 5, 7, 9, 11, 13, 15) — the other parity role
  • The sender is one of the 8 members in their parity group → probability of correct identification: 1/8 = 12.5%

Why Ring Size 2 Offers No Sender Privacy

With ring size 2: one even slot (index 0), one odd slot (index 1). The sender occupies one slot, the recipient the other. An observer who knows which parity role is "sender" trivially identifies both parties. This is why ring size 2 is listed as "no sender privacy" in the table above.

Verify It Yourself

ProtectionFileLines
Parity constraint (wallet)walletapi/transaction_build.go79-90
P/Q polynomial split (proof)cryptography/crypto/proof_generate.go700-710
Witness index encodingcryptography/crypto/proof_generate.go523
Ring size limitsconfig/config.go58-59
git clone https://github.com/deroproject/derohe.git
cd derohe
 
sed -n '79,90p' walletapi/transaction_build.go          # Parity constraint
sed -n '700,710p' cryptography/crypto/proof_generate.go # P/Q polynomial split
sed -n '523p' cryptography/crypto/proof_generate.go     # Witness index encoding
grep "RINGSIZE" config/config.go                        # Ring size limits

Encrypted Balance Changes for Ring Members

When an address is included as a ring member (decoy) in a transaction, its encrypted balance changes even though the address performed no actions. This is normal behavior and part of how ring signatures provide privacy.

From source code (walletapi/daemon_communication.go):

// When address is a ring member:
if bytes.Compare(compressed_address, tx.Payloads[t].Statement.Publickeylist_compressed[j][:]) == 0 {
    // Encrypted balance changes for ALL ring members
    changes := crypto.ConstructElGamal(tx.Payloads[t].Statement.C[j], tx.Payloads[t].Statement.D)
    changed_balance_e := previous_balance_e_tx.Add(changes)
    
    // This happens even if address did nothing!
    switch {
    case previous_balance == changed_balance:
        ring_member = true  // Address identified as ring decoy
    }
}

What this means:

  1. Address with zero balance (never used) is selected as ring decoy
  2. Encrypted balance changes due to homomorphic operations
  3. This is by design - all ring members' encrypted balances participate in the transaction
  4. The address didn't send anything; it was just included in the ring

Why this is important:

  • Encrypted balance changes don't indicate the address was the sender
  • Ring members are passive participants in the privacy mechanism
  • This behavior is cryptographically required for ring signatures to work
  • Observing encrypted balance changes alone cannot identify the real sender

This is normal ring signature behavior, not a protocol flaw.


What Ring Signatures Protect

ThreatProtected?How
Sender Identity✅ YesHidden among ring members
Transaction Linking✅ YesEach TX has independent ring
Third-Party Attribution✅ YesPlausible deniability
Transaction Amounts❌ NoNeeds homomorphic encryption
Network Activity❌ NoNeeds TLS/Tor

Ring signatures = Sender privacy ONLY

For complete privacy: Ring sigs + Homomorphic encryption + Bulletproofs + TLS


Why ANY Ring Member Can Generate Proof

All ring members use same amount in commitments:

C[k] = (amount × G) + pubkey[k]

C[0] = (200000 × G) + addr_0
C[1] = (200000 × G) + addr_1
...
C[7] = (200000 × G) + addr_7  ← Same 200000!
...

Result: Anyone can pick ANY member, calculate commitment, create "valid" proof.

This is privacy working as designed - you can't identify sender by inspection!

Learn more: Payload Proofs


Best Practices

For Maximum Privacy:

  • Ring size: 64-128
  • Vary transaction timing
  • Run own node

For Balanced Privacy (Recommended):

  • Ring size: 16
  • Standard network
  • Trusted or own node

For Developers:

// Let users choose
transfer({ ringsize: 16 })  // Example
transfer({ ringsize: 64 })  // High privacy
transfer({ ringsize: 128 }) // Maximum

Common Misconceptions

MythReality
"Ring sigs = complete anonymity"Strong privacy, not absolute. 1 in (N/2) chance
"Bigger always better"Trade-off: Privacy vs TX size
"Ring members know they're in ring"No - passive selection, no notification
"Decoys must be online"No - only their public keys used
"Encrypted balance change = sender"No - ring members' balances change by design even as decoys

Key Takeaways

What ring signatures provide:

  • ✅ Sender identity hidden (1 in 1-64, i.e. 1 in ring_size/2)
  • ✅ Plausible deniability ("might be decoy")
  • ✅ Transaction unlinkability
  • ✅ No trusted setup needed
  • ✅ Proven cryptography

What they don't provide:

  • ❌ Amount privacy (use homomorphic encryption)
  • ❌ Network privacy (use TLS/Tor)
  • ❌ Absolute anonymity (probabilistic hiding)
⚠️

Plausible Deniability: If accused of sending a transaction, you can truthfully say "I might be just a decoy" - and nobody can prove otherwise!


Related Pages

Privacy Suite:

How It Works:

For Developers: