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:1 Based 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:
Spend public key : Used to derive the stealth address.32 bytes, compressed Ed25519 point.
Spend private key : Used to spend from stealth addresses.32 bytes, secret scalar.
View public key : Used by sender to derive shared secret.32 bytes, Curve25519 point.
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:
stealth.service.ts:81
Step-by-step breakdown
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:
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 = 0 n ;
for ( let i = bytes . length - 1 ; i >= 0 ; i -- ) {
result = ( result << 8 n ) | BigInt ( bytes [ i ]);
}
// Reduce mod Ed25519 curve order
const L = 2 n ** 252 n + 27742317777372353535851937790883648493 n ;
return result % L ;
}
Cryptographic Primitives
ECDH Shared Secret
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
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
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 :
Scalar multiplication: sG = s * G (G is Ed25519 base point)
Point addition: K_stealth = K_spend + sG
Compression: Convert extended point to 32-byte compressed form
Sui Address Derivation
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.
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