protocol

Stealth Addresses

One-time addresses for private receipt

Overview#

Mini Veil uses a Dual-Key Stealth Address Protocol (DKSAP) based on Ed25519 and X25519. Each payment generates a unique, one-time stealth address that only the intended recipient can detect and control. No on-chain link exists between the sender's transaction and the recipient's wallet.

Stealth Address Lifecycle

Sender

Ephemeral Key

Random X25519 keypair (r, R)

Shared Secret

X25519(r, viewingPubKey)

Tweak

reduceScalar(sha256(secret))

Stealth Key

spendingPubKey + tweak*G

Recipient

Shared Secret

X25519(viewingPriv, R)

View Tag

sha256(secret)[0:2] quick filter

Decrypt Key

AES-256-GCM(secret, encKey)

Stealth Priv

(spendingScalar + tweak) % L

Key Structure#

Each user has two key pairs:

| Key Pair | Algorithm | Purpose | |---|---|---| | Spending key | Ed25519 | Signs claim transactions from stealth addresses | | Viewing key | X25519 | Enables shared secret derivation for scanning |

Both are derived deterministically from a master seed:

masterSeed  = SHA-256(walletSignature("Mini Veil master keys"))
spendingPriv = SHA-256("mini-veil-spending" || masterSeed)
viewingPriv  = SHA-256("mini-veil-viewing" || masterSeed)
spendingPub  = Ed25519.getPublicKey(spendingPriv)
viewingPub   = X25519.getPublicKey(viewingPriv)

Sender: Generating a Stealth Address#

1. Generate ephemeral X25519 keypair (r, R = r * G)
2. sharedSecret = X25519(r, viewingPubKey)
3. tweak = reduceScalar(SHA-256(sharedSecret))  // mod Ed25519 order L
4. stealthPubKey = spendingPubKey + (tweak * G)  // Ed25519 point addition
5. viewTag = u16(SHA-256(sharedSecret)[0:2])     // LE bytes
6. encryptedKey = AES-256-GCM(SHA-256(sharedSecret), r)

The sender publishes (R, viewTag, encryptedKey) in a StealthAnnouncement event.

Recipient: Scanning#

For each announcement (R, viewTag, encryptedKey):
  1. sharedSecret = X25519(viewingPrivKey, R)
  2. Quick filter: compare viewTag against SHA-256(sharedSecret)[0:2]
  3. If match: tweak = reduceScalar(SHA-256(sharedSecret))
  4. Decrypt r via AES-256-GCM(SHA-256(sharedSecret), encryptedKey)
  5. stealthPrivScalar = (spendingScalar + tweak) % L
  6. stealthPubKey = stealthPrivScalar * G
  7. Check on-chain balance of stealthPubKey
i

View Tag Optimization

The view tag (2 bytes) enables fast rejection of non-matching announcements. For each announcement, the recipient computes one X25519 shared secret and compares 2 bytes. If no match, the announcement can be skipped without further computation. With 2-byte view tags, ~99.8% of non-matching announcements are filtered immediately.

BN254 Field Constraint#

The stealth address must fit within the BN254 scalar field modulus:

BN254_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n

~62% of Ed25519 public keys exceed this modulus. The generateStealthAddress function retries with new ephemeral keys until the resulting stealth address is within the field:

retries = 0
do {
  ephemeralKey = generateRandomX25519Key()
  stealthAddress = deriveStealthAddress(spendingPubKey, viewingPubKey, ephemeralKey)
  retries++
} while (stealthAddress > BN254_FIELD && retries < 40)

40 retries give approximately 3×10⁻⁹ failure probability.

Claiming#

The recipient uses the stealth private key scalar to build a transaction:

claim_stealth(amount: u64)

Accounts:

  • stealth_address (Signer, mut) — the stealth address signing as fee payer
  • recipient (UncheckedAccount, mut) — the destination wallet
  • system_program

The stealth private key scalar is used to create a ScalarSigner:

scalarSigner = createScalarSigner(stealthPrivKeyScalar)
transaction.feePayer = stealthPublicKey
transaction.sign(scalarSigner)
connection.sendRawTransaction(transaction.serialize())

The claim drains all but the rent-exempt minimum and sweep fee (10,000 lamports) from the stealth address.

Metadata Address#

Recipients expose their stealth address capability via a MetaAddress:

interface MetaAddress {
  spendingPubKey: Uint8Array  // Ed25519 public key (32 bytes)
  viewingPubKey: Uint8Array   // X25519 public key (32 bytes)
}

This is stored on-chain in the KeyRegistry PDA and fetched by the sender when generating the stealth address.

Event Structure#

StealthAnnouncement {
  ephemeral_pubkey: [u8; 32],
  view_tag: u16,
  encrypted_ephemeral_key: [u8; 80],
  sender: Pubkey,
  timestamp: i64,
}

Discriminator: [197, 85, 83, 203, 142, 88, 5, 176] (SHA-256 of "event:StealthAnnouncement")