Stealth addresses and shielded pools for transaction unlinkability
identiPay provides strong privacy guarantees through stealth addresses and shielded pools. These cryptographic primitives ensure that transactions cannot be linked to users’ public identities or to each other.
Stealth addresses enable one-time addresses for every transaction. The sender derives a unique address for the recipient, and only the recipient can scan the blockchain to detect funds sent to them.
To spend funds from a stealth address, Alice computes the stealth private key:
k_stealth = k_spend + s (scalar addition)
Where:
k_spend is Alice’s spend private key (32-byte scalar)
s is the stealth scalar derived from the shared secret
She signs the transaction with k_stealth and submits it to the blockchain.
// From StealthAddress.ktfun scan( viewPrivateKey: ByteArray, spendPrivateKey: ByteArray, spendPubkey: ByteArray, ephemeralPubkey: ByteArray, announcedViewTag: Int, announcedStealthAddress: String,): ScanResult? { // 1. Compute ECDH shared secret val shared = x25519.getSharedSecret(viewPrivateKey, ephemeralPubkey) val viewTag = shared[0].toInt() and 0xFF // 2. Fast filter: check view tag if (viewTag != announcedViewTag) return null // 3. Derive stealth scalar val scalar = sha256(shared + "identipay-stealth-v1".toByteArray()) // 4. Compute stealth private key = k_spend + s val stealthPrivateKey = scalarAdd(spendPrivateKey, scalar) // 5. Verify the address matches val stealthPubkey = ed25519.getPublicKey(stealthPrivateKey) val stealthAddress = blake2b(0x00 || stealthPubkey) if (stealthAddress == announcedStealthAddress) { return ScanResult( stealthAddress = stealthAddress, stealthPrivateKey = stealthPrivateKey, stealthPubkey = stealthPubkey, ) } return null}
Key Security: The stealth private key is derived deterministically from the spend key and shared secret. It is stored encrypted in the wallet database, never transmitted over the network.
The view tag is a 1-byte value (0-255) derived from the first byte of the ECDH shared secret. It provides a fast filter for scanning:
Without view tags: Alice must compute full ECDH + scalar derivation for every announcement (~1000 hashes per announcement)
With view tags: Alice checks the view tag first (single byte comparison), only doing full derivation if it matches (256x fewer computations on average)
The shielded pool provides Layer 2 privacy by enabling untraceable fund mixing. Users deposit USDC into the pool and receive cryptographic note commitments. They can later withdraw to fresh addresses with zero-knowledge proofs.
Submit deposit transaction: Transfer USDC to pool, insert commitment into Merkle tree
Store note locally: Save (commitment, amount, ownerKey, salt, leafIndex) in encrypted database
// From PoolRepository.kt:74-155suspend fun deposit( amount: Long, stealthAddress: String, stealthPrivKey: ByteArray,): PoolResult { // 1. Generate random salt val saltBytes = ByteArray(31) SecureRandom().nextBytes(saltBytes) val salt = BigInteger(1, saltBytes).mod(PoseidonHash.FIELD_PRIME) // 2. Compute owner key (public key from stealth private key) val ownerPubkey = Ed25519Ops.publicKeyFromScalar(stealthPrivKey) val ownerKeyField = BigInteger(1, ownerPubkey).mod(PoseidonHash.FIELD_PRIME) // 3. Compute note commitment = Poseidon(amount, ownerKey, salt) val amountField = BigInteger.valueOf(amount) val noteCommitment = PoseidonHash.hash3(amountField, ownerKeyField, salt) // 4. Build deposit transaction via gas sponsorship val txDigest = executePoolDeposit( senderAddress = stealthAddress, senderPrivKey = stealthPrivKey, amount = amount, noteCommitment = noteCommitment, ) // 5. Get leaf index from deposit event (retry until indexed) var leafIndex: Long? = null for (attempt in 0 until 10) { leafIndex = fetchDepositLeafIndex(txDigest) if (leafIndex != null) break delay(1500L) } // 6. Compute nullifier = Poseidon(noteCommitment, ownerKeyField) val nullifier = PoseidonHash.hash2(noteCommitment, ownerKeyField) // 7. Store note locally val noteId = noteDao.insert( NoteEntity( noteCommitment = noteCommitment.toString(16), amount = amount, ownerKey = ownerPubkey.toHexString(), salt = salt.toString(16), leafIndex = leafIndex, depositTxDigest = txDigest, ) ) return PoolResult.Success(txDigest, noteId = noteId)}
Privacy preservation: The deposit transaction reveals the sender address and amount, but subsequent withdrawals are unlinkable because they use zero-knowledge proofs of Merkle tree membership.
“I know a note commitment in the Merkle tree with amount A and owner key K, and I am withdrawing W ≤ A to recipient R, with nullifier N and change commitment C.”
Public inputs:
merkleRoot: Current Merkle tree root
nullifier: Prevents double-spending
recipient: Destination address
withdrawAmount: Amount being withdrawn
changeCommitment: Commitment to remaining change (or 0)
The wallet automatically uses the pool when a payment requires more USDC than any single stealth address holds:
// From CommerceRepository.kt:146-219val amountMicros = parseAmountMicros(proposal.amount.value)val sources = stealthAddressDao.getWithBalance()var source = sources.firstOrNull { it.balanceUsdc >= amountMicros }// Pool-on-merge: deposit into shielded pool, then withdraw to a fresh addressif (source == null) { val totalBalance = sources.sumOf { it.balanceUsdc } if (totalBalance < amountMicros) { return CommerceResult.Error("Insufficient USDC balance") } // 1. Deposit all sources into the pool val depositedNoteIds = mutableListOf<Long>() for (src in sources) { if (src.balanceUsdc <= 0) continue val result = poolRepository.get().deposit( src.balanceUsdc, src.stealthAddress, privKey, ) depositedNoteIds.add((result as PoolResult.Success).noteId) stealthAddressDao.updateBalance(src.stealthAddress, 0) } // 2. Derive a fresh stealth address for withdrawal val mergeSpendPub = walletKeys.getSpendPublicKey() val mergeViewPub = walletKeys.getViewPublicKey() val mergeStealth = stealthAddress.derive(mergeSpendPub, mergeViewPub) val mergeScan = stealthAddress.scan(...) stealthAddressDao.insert(mergeStealth) // 3. Withdraw only the just-deposited notes to the fresh address val depositedNotes = poolRepository.get().getNotesByIds(depositedNoteIds) for (note in depositedNotes) { val result = poolRepository.get().withdraw( noteId = note.id, recipientAddress = mergeScan.stealthAddress, amount = note.amount, ) } source = stealthAddressDao.getByAddress(mergeScan.stealthAddress)}
1
Detect insufficient single-address balance
If no single stealth address has enough USDC, but the total balance is sufficient, trigger pool-on-merge.
2
Deposit all sources
Deposit from each stealth address into the pool, creating note commitments.
3
Withdraw to fresh address
Generate a new stealth address and withdraw the combined balance using ZK proofs.
4
Complete payment
Use the fresh stealth address to complete the merchant payment.
Privacy benefit: The payment is now unlinkable from the original stealth addresses. On-chain observers cannot determine which addresses funded the payment.
Scenario 1: Direct Payment (Stealth Addresses Only)
Alice receives $100 to stealth address SA1.Alice pays merchant $25 from SA1.On-chain view: TX1: Unknown → SA1 ($100) TX2: SA1 → Merchant ($25)Linkability: Merchant knows SA1 sent the payment, but SA1 is not linked to Alice's identity.
Alice receives $30 to SA1 and $40 to SA2.Alice needs to pay $50, which exceeds any single address.Pool-on-merge flow: TX1: SA1 → Pool ($30) TX2: SA2 → Pool ($40) TX3: Pool → SA3 ($70, ZK proof) TX4: SA3 → Merchant ($50)On-chain view: - Merchant sees payment from SA3 - SA3 is not linked to SA1 or SA2 (ZK proof breaks linkage) - Total privacy: Merchant cannot determine the original funding sources
Pool withdraw proof generation is the slowest operation (~15 seconds on mobile) due to the large circuit (~35K constraints). This is an acceptable tradeoff for the strong privacy guarantees.
Stealth private keys are derived deterministically from the spend key and shared secret. They are stored encrypted in the wallet database using Android Keystore / iOS Secure Enclave.Never:
Nullifiers are deterministic (Poseidon(commitment, ownerKey)) so the same note always produces the same nullifier.
Merkle Tree Synchronization
The wallet rebuilds the Merkle tree client-side by replaying all deposit events. This ensures the proof is generated against the correct root.If deposits occur while generating a withdrawal proof, the wallet retries until the computed root matches the on-chain root (max 10 retries with 2s delays).
Pool Deposit Amount Visibility
Pool deposits reveal the sender address and amount on-chain. For maximum privacy:
Use stealth addresses as deposit sources (sender is already pseudonymous)
Deposit round amounts (e.g., 50,100) to reduce fingerprinting
Withdraw to fresh stealth addresses
Future enhancement: Confidential transactions using Pedersen commitments could hide deposit amounts.
The anonymity set is the number of possible senders for a transaction:
Anonymity Set Size = Number of notes in the pool at withdrawal time
Example: If there are 10,000 notes in the pool when Alice withdraws, an observer has a 1/10,000 chance of guessing which note she spent.Growing the anonymity set:
More users → more pool deposits → larger anonymity set
Pool-on-merge auto-deposits → more notes per user
Periodic “dummy” deposits (future enhancement) → larger set even without real transactions