Skip to main content

Overview

Stealth addresses enable private payments where the recipient’s public key is visible, but each payment generates a unique one-time address that only the recipient can detect and spend from.
Implementation: /home/daytona/workspace/source/backend/src/services/stealth.service.ts:1Based on whitepaper section 4.5

Protocol Design

The stealth address protocol uses a combination of:
  • X25519: ECDH shared secret derivation (Curve25519)
  • Ed25519: Public key derivation and Sui address computation
  • SHA-256: Stealth scalar derivation
  • BLAKE2b: Sui address hashing

Key Pairs

Each user has two key pairs:
K_spend
Ed25519 public key
Spend public key: Used to derive the stealth address.32 bytes, compressed Ed25519 point.
k_spend
Ed25519 private key
Spend private key: Used to spend from stealth addresses.32 bytes, secret scalar.
K_view
X25519 public key
View public key: Used by sender to derive shared secret.32 bytes, Curve25519 point.
k_view
X25519 private key
View private key: Used by recipient to scan for incoming payments.32 bytes, secret scalar.
Key management: The spend key should be kept in cold storage. Only the view key needs to be online for scanning.

Derivation Algorithm

Sender Side

When sending a payment, the sender derives a unique stealth address:
export function deriveStealthAddress(
  spendPubkey: Uint8Array,      // Recipient's K_spend (Ed25519)
  viewPubkey: Uint8Array,        // Recipient's K_view (X25519)
  ephemeralPrivateKey?: Uint8Array  // Optional: provide for deterministic derivation
): StealthOutput {
  // 1. Generate ephemeral X25519 keypair
  const ephPriv = ephemeralPrivateKey ?? x25519.utils.randomPrivateKey();
  const ephPub = x25519.getPublicKey(ephPriv);

  // 2. ECDH shared secret
  const shared = ecdhSharedSecret(ephPriv, viewPubkey);

  // 3. View tag (first byte of shared secret)
  const viewTag = extractViewTag(shared);

  // 4. Stealth scalar: s = SHA-256(shared || "identipay-stealth-v1")
  const scalar = deriveStealthScalar(shared);

  // 5. Stealth pubkey: K_stealth = K_spend + s*G
  const stealthPubkey = computeStealthPubkey(spendPubkey, scalar);

  // 6. Sui address: BLAKE2b-256(0x00 || K_stealth)
  const stealthAddress = pubkeyToSuiAddress(stealthPubkey);

  return {
    ephemeralPubkey: ephPub,
    stealthAddress,
    viewTag,
    stealthPubkey
  };
}

Announcement

The sender publishes the stealth address parameters on-chain:
interface StealthAnnouncement {
  ephemeralPubkey: Uint8Array;  // R (32 bytes)
  viewTag: number;               // 0-255
  stealthAddress: string;        // 0x... (66 chars)
}
This is emitted as an event in the announcements contract (see announcements.move:1).

Scanning Algorithm

Recipient Side

The recipient scans announcements to find payments addressed to them:
stealth.service.ts:117
export function scanAnnouncement(
  viewPrivateKey: Uint8Array,    // k_view
  spendPubkey: Uint8Array,       // K_spend
  ephemeralPubkey: Uint8Array,   // R (from announcement)
  announcedViewTag: number,      // From announcement
  announcedStealthAddress: string // From announcement
): boolean {
  // 1. ECDH shared secret: shared = k_view * R
  const shared = ecdhSharedSecret(viewPrivateKey, ephemeralPubkey);
  const viewTag = extractViewTag(shared);

  // 2. Fast filter: check view tag first (256x speedup)
  if (viewTag !== announcedViewTag) return false;

  // 3. Full derivation to confirm
  const scalar = deriveStealthScalar(shared);
  const stealthPubkey = computeStealthPubkey(spendPubkey, scalar);
  const stealthAddress = pubkeyToSuiAddress(stealthPubkey);

  // 4. Check if derived address matches announcement
  return stealthAddress === announcedStealthAddress;
}
Performance optimization:
  • View tag check (line 128): Filters out 255/256 of non-matching announcements with just 1 byte comparison
  • Only 1/256 announcements require full ECDH + hash computation
  • For 10,000 announcements/day, recipient checks ~40 full derivations instead of 10,000

Spending from Stealth Address

To spend from a detected stealth address, the recipient computes the stealth private key:
// Given: k_spend, k_view, R (ephemeral pubkey from announcement)

// 1. Recompute shared secret
const shared = x25519.getSharedSecret(k_view, R);

// 2. Derive scalar s
const s = sha256(concatBytes(shared, DOMAIN_SEPARATOR));

// 3. Compute stealth private key: k_stealth = k_spend + s (mod L)
//    where L is the Ed25519 curve order
const k_stealth = (k_spend + bytesToBigInt(s)) % CURVE_ORDER;

// 4. Sign transactions with k_stealth
const signature = ed25519.sign(message, k_stealth);
Implementation in stealth.service.ts:139:
function bytesToBigInt(bytes: Uint8Array): bigint {
  let result = 0n;
  for (let i = bytes.length - 1; i >= 0; i--) {
    result = (result << 8n) | BigInt(bytes[i]);
  }
  // Reduce mod Ed25519 curve order
  const L = 2n ** 252n + 27742317777372353535851937790883648493n;
  return result % L;
}

Cryptographic Primitives

ECDH Shared Secret

stealth.service.ts:29
export function ecdhSharedSecret(
  privateKey: Uint8Array,
  publicKey: Uint8Array,
): Uint8Array {
  return x25519.getSharedSecret(privateKey, publicKey);
}
Security:
  • Uses Curve25519 (X25519)
  • ~128-bit security level
  • Constant-time implementation (side-channel resistant)

Stealth Scalar Derivation

stealth.service.ts:40
export function deriveStealthScalar(sharedSecret: Uint8Array): Uint8Array {
  return sha256(concatBytes(sharedSecret, DOMAIN_SEPARATOR));
}
Domain separation:
  • DOMAIN_SEPARATOR = "identipay-stealth-v1" (line 7)
  • Prevents cross-protocol attacks
  • Different applications cannot reuse the same stealth addresses

Point Addition

stealth.service.ts:48
export function computeStealthPubkey(
  spendPubkey: Uint8Array,
  scalar: Uint8Array,
): Uint8Array {
  const sG = ed25519.ExtendedPoint.BASE.multiply(
    bytesToBigInt(scalar),
  );
  const kSpend = ed25519.ExtendedPoint.fromHex(spendPubkey);
  const kStealth = kSpend.add(sG);
  return kStealth.toRawBytes();
}
Operations:
  1. Scalar multiplication: sG = s * G (G is Ed25519 base point)
  2. Point addition: K_stealth = K_spend + sG
  3. Compression: Convert extended point to 32-byte compressed form

Sui Address Derivation

stealth.service.ts:63
export function pubkeyToSuiAddress(pubkey: Uint8Array): string {
  const flagged = concatBytes(new Uint8Array([0x00]), pubkey);
  const hash = blake2b(flagged, { dkLen: 32 });
  return "0x" + bytesToHex(hash);
}
Format:
  • Flag byte 0x00 indicates Ed25519 signature scheme
  • BLAKE2b-256 hash of 0x00 || pubkey
  • Result: 32-byte address (64 hex chars + 0x prefix)
This matches Sui’s standard address derivation (see Sui docs).

Security Analysis

Unlinkability

Theorem: Given stealth addresses A1, A2, ..., An for the same recipient, an observer cannot determine they belong to the same user (assuming discrete log hardness). Proof sketch:
  • Each Ai = K_spend + si*G where si is derived from a unique ephemeral key ri
  • Without knowing K_spend, the addresses appear as random Ed25519 points
  • Linking requires solving discrete log: A2 - A1 = (s2 - s1)*G

Deniability

The sender can prove they sent to a stealth address by revealing the ephemeral private key r:
// Sender provides: r, K_spend, K_view
// Verifier computes:
const R = x25519.getPublicKey(r);
const shared = x25519.getSharedSecret(r, K_view);
const s = deriveStealthScalar(shared);
const A_derived = computeStealthPubkey(K_spend, s);

// Check: A_derived == A_announced
This is useful for refunds or dispute resolution.

Forward Secrecy

No forward secrecy: If k_view is compromised, an attacker can scan all past announcements and link stealth addresses.
Mitigation: Rotate k_view periodically (e.g., every 6 months). Old view keys can be archived in cold storage.

View Tag Leakage

The view tag (first byte of shared secret) leaks ~8 bits of information:
  • An observer can cluster announcements with the same view tag
  • ~1/256 of announcements will share a view tag by chance
  • This creates small anonymity sets (~256 stealth addresses per tag)
Impact: Minor. The primary unlinkability comes from the Ed25519 point addition, not the view tag.

Performance

Derivation (Sender)

Operation                  | Time (M1 MacBook)
---------------------------+-------------------
ECDH (x25519)              | ~0.02 ms
SHA-256                    | ~0.01 ms
Ed25519 scalar mult        | ~0.05 ms
Ed25519 point add          | ~0.01 ms
BLAKE2b                    | ~0.01 ms
---------------------------+-------------------
Total per address          | ~0.1 ms

Scanning (Recipient)

With view tag optimization:
Scenario                   | Time per 10K announcements
---------------------------+----------------------------
Without view tag           | ~1000 ms (0.1 ms × 10K)
With view tag (1/256 hit)  | ~40 ms (0.1 ms × 40)
---------------------------+----------------------------
Speedup                    | 25x
Recommendation: Run scanning in a background worker (browser) or cron job (server).

Integration Example

Sender: Create Payment

import { deriveStealthAddress } from './stealth.service';
import { Transaction } from '@mysten/sui';

// Fetch recipient's public keys (from registry or QR code)
const { spendPubkey, viewPubkey } = await fetchRecipientKeys(recipientId);

// Derive stealth address
const { ephemeralPubkey, stealthAddress, viewTag } = deriveStealthAddress(
  spendPubkey,
  viewPubkey
);

// Send payment to stealth address
const tx = new Transaction();
const [coin] = tx.splitCoins(tx.gas, [amount]);
tx.transferObjects([coin], stealthAddress);

// Announce stealth payment
tx.moveCall({
  target: `${PACKAGE_ID}::announcements::announce_stealth_payment`,
  arguments: [
    tx.object(ANNOUNCEMENTS_REGISTRY),
    tx.pure('vector<u8>', ephemeralPubkey),
    tx.pure('u8', viewTag),
    tx.pure('address', stealthAddress)
  ]
});

await signAndExecuteTransaction({ transaction: tx });

Recipient: Scan for Payments

import { scanAnnouncement } from './stealth.service';

// Fetch announcements from on-chain events
const announcements = await queryAnnouncementEvents(fromBlock, toBlock);

// Scan for incoming payments
for (const announcement of announcements) {
  const isForMe = scanAnnouncement(
    viewPrivateKey,
    spendPubkey,
    announcement.ephemeralPubkey,
    announcement.viewTag,
    announcement.stealthAddress
  );
  
  if (isForMe) {
    console.log('Received payment to:', announcement.stealthAddress);
    
    // Derive spending key
    const spendingKey = deriveStealthSpendingKey(
      spendPrivateKey,
      viewPrivateKey,
      announcement.ephemeralPubkey
    );
    
    // Store in wallet
    await wallet.addStealthAddress({
      address: announcement.stealthAddress,
      spendingKey,
      ephemeralPubkey: announcement.ephemeralPubkey
    });
  }
}

Testing

import { describe, it, expect } from 'vitest';
import { deriveStealthAddress, scanAnnouncement } from './stealth.service';
import { x25519, ed25519 } from '@noble/curves/ed25519';

describe('Stealth addresses', () => {
  it('should derive and scan correctly', () => {
    // Generate recipient keypairs
    const k_view = x25519.utils.randomPrivateKey();
    const K_view = x25519.getPublicKey(k_view);
    
    const k_spend = ed25519.utils.randomPrivateKey();
    const K_spend = ed25519.getPublicKey(k_spend);
    
    // Sender derives stealth address
    const { ephemeralPubkey, stealthAddress, viewTag } = deriveStealthAddress(
      K_spend,
      K_view
    );
    
    // Recipient scans announcement
    const detected = scanAnnouncement(
      k_view,
      K_spend,
      ephemeralPubkey,
      viewTag,
      stealthAddress
    );
    
    expect(detected).toBe(true);
  });
  
  it('should not detect payments for other recipients', () => {
    // ... similar test with different recipient keys ...
    expect(detected).toBe(false);
  });
});

Next Steps

Announcements Contract

On-chain stealth payment announcement system

Poseidon Hashing

ZK-friendly hash function for commitments

Build docs developers (and LLMs) love