Preskoči na glavni sadržaj

Using Stealth Addresses

Stealth address
privacy
cryptography
rust
wasm
Intermediate
Ori Pomerantz
30. studenoga 2025.
16 minuta čitanja

You're Bill. For reasons we won't go into, you want to donate to the "Alice for Queen of the World" campaign and have Alice know you donated so she'll reward you if she wins. Unfortunately, her victory is not guaranteed. There is a competing campaign, "Carol for Empress of the Solar System". If Carol wins, and she finds out you donated to Alice, you'll be in trouble. So you can't just transfer 200 ETH from your account to Alice's.

ERC-5564opens in a new tab has the solution. This ERC explains how to use stealth addressesopens in a new tab for anonymous transfer.

Warning: The cryptography behind stealth addresses is, as far as we know, sound. However, there are potential side-channel attacks. Below, you will see what you can do to reduce this risk.

How stealth addresses work

This article will attempt to explain stealth addresses in two ways. The first is how to use them. This part is sufficient to understand the rest of the article. Then, there is an explanation of the mathematics behind it. If you are interested in cryptography, read this part as well.

The simple version (how to use stealth addresses)

Alice creates two private keys and publishes the corresponding public keys (which can be combined into a single double-length meta-address). Bill also creates a private key and publishes the corresponding public key.

Using one party's public key and the other's private key, you can derive a shared secret known only to Alice and Bill (it can't be derived from the public keys alone). Using this shared secret, Bill obtains the stealth address and can send assets to it.

Alice also gets the address from the shared secret, but because she knows the private keys to the public keys she published, she can also get the private key that lets her withdraw from that address.

The mathematics (why stealth addresses work like this)

Standard stealth addresses use elliptic-curve cryptography (ECC)opens in a new tab to get better performance with fewer key bits, while still keeping the same level of security. But for the most part we can ignore that and pretend we are using regular arithmetic.

There is a number everybody knows, G. You can multiply by G. But because of the nature of ECC, it is practically impossible to divide by G. The way public key cryptography generally works in Ethereum is that you can use a private key, Ppriv, to sign transactions that are then verified by a public key, Ppub = GPpriv.

Alice creates two private keys, Kpriv and Vpriv. Kpriv will be used to spend money out of the stealth address, and Vpriv to view the addresses that belong to Alice. Alice then publishes the public keys: Kpub = GKpriv and Vpub = GVpriv

Bill creates a third private key, Rpriv, and publishes Rpub = GRpriv to a central registry (Bill could also have sent it to Alice, but we assume Carol is listening).

Bill calculates RprivVpub = GRprivVpriv, which he expects Alice also to know (explained below). This value is called S, the shared secret. This gives Bill a public key, Ppub = Kpub+G*hash(S). From this public key, he can calculate an address and send whatever resources he wants to it. In the future, if Alice wins, Bill can tell her Rpriv to prove the resources came from him.

Alice calculates RpubVpriv = GRprivVpriv. This gives her the same shared secret, S. Because she knows the private key, Kpriv, she can calculate Ppriv = Kpriv+hash(S). This key lets her access assets in the address that results from Ppub = GPpriv = GKpriv+G*hash(S) = Kpub+G*hash(S).

We have a separate viewing key to allow Alice to subcontract to Dave's World Domination Campaign Services. Alice is willing to let Dave know the public addresses and inform her when more money is available, but she doesn't want him spending her campaign money.

Because viewing and spending use separate keys, Alice can give Dave Vpriv. Then Dave can calculate S = RpubVpriv = GRprivVpriv and that way get the public keys (Ppub = Kpub+G*hash(S)). But without Kpriv Dave cannot get the private key.

To summarize, these are the values known by the different participants.

AlicePublishedBillDave
GGGG
Kpriv---
Vpriv--Vpriv
Kpub = GKprivKpubKpubKpub
Vpub = GVprivVpubVpubVpub
--Rpriv-
RpubRpubRpub = GRprivRpub
S = RpubVpriv = GRprivVpriv-S = RprivVpub = GRprivVprivS = RpubVpriv = GRprivVpriv
Ppub = Kpub+G*hash(S)-Ppub = Kpub+G*hash(S)Ppub = Kpub+G*hash(S)
Address=f(Ppub)-Address=f(Ppub)Address=f(Ppub)
Ppriv = Kpriv+hash(S)---

When stealth addresses go wrong

There are no secrets on the blockchain. While stealth addresses can provide you with privacy, that privacy is susceptible to traffic analysis. To pick a trivial example, imagine that Bill funds an address and immediately sends a transaction to publish an Rpub value. Without Alice's Vpriv, we can't be sure that this is a stealth address, but that is the way to bet. Then, we see another transaction that transfers all the ETH from that address to Alice's campaign fund address. We may not be able to prove it, but it's likely that Bill just donated to Alice's campaign. Carol would certainly think so.

It is easy for Bill to separate the publication of Rpub from the funding of the stealth address (do them at different times, from different addresses). However, that is insufficient. The pattern Carol looks for is that Bill funds an address, and then Alice's campaign fund withdraws from it.

One solution is for Alice's campaign not to withdraw the money directly, but use it to pay a third party. If Alice's campaign sends 10 ETH to Dave's World Domination Campaign Services, Carol only knows that Bill donated to one of Dave's customers. If Dave has enough customers, Carol would not be able to know if Bill donated to Alice who competes with her, or to Adam, Albert, or Abigail that Carol doesn't care about. Alice can include a hashed value with the payment, and then provide Dave the preimage, to prove that it was her donation. Alternatively, as noted above, if Alice gives Dave her Vpriv, he already knows who the payment came from.

The main problem with this solution is that it requires Alice to care about secrecy when that secrecy benefits Bill. Alice may want to maintain her reputation so Bill's friend Bob will also donate to her. But it's also possible that she wouldn't mind exposing Bill, because then he'll be afraid of what will happen if Carol wins. Bill might end up providing Alice even more support.

Using multiple stealth layers

Instead of relying on Alice to preserve Bill's privacy, Bill can do it himself. He can generate multiple meta-addresses for fictional people, Bob and Bella. Bill then sends ETH to Bob, and "Bob" (who is actually Bill) sends it to Bella. "Bella" (also Bill) sends it to Alice.

Carol can still do traffic analysis and see the Bill-to-Bob-to-Bella-to-Alice pipeline. However, if "Bob" and "Bella" also use ETH for other purposes, it won't appear that Bill transferred anything to Alice, even if Alice immediately withdraws from the stealth address to her known campaign address.

Writing a stealth-address application

This article explains a stealth-address application available on GitHubopens in a new tab.

Tools

There is a typescript stealth address libraryopens in a new tab we could use. However, cryptographic operations can be CPU-intensive. I prefer to implement them in a compiled language, such as Rustopens in a new tab, and use WASMopens in a new tab to run the code in the browser.

We are going to use Viteopens in a new tab and Reactopens in a new tab. These are industry-standard tools; if you are not familiar with them, you can use this tutorial. To use Vite, we need Node.

See stealth addresses in action

  1. Install the necessary tools: Rustopens in a new tab and Nodeopens in a new tab.

  2. Clone the GitHub repository.

    1git clone https://github.com/qbzzt/251022-stealth-addresses.git
    2cd 251022-stealth-addresses
  3. Install prerequisites and compile the Rust code.

    1cd src/rust-wasm
    2rustup target add wasm32-unknown-unknown
    3cargo install wasm-pack
    4wasm-pack build --target web
  4. Start the web server.

    1cd ../..
    2npm install
    3npm run dev
  5. Browse to the applicationopens in a new tab. This application page has two frames: one for Alice's user interface and the other for Bill's. The two frames do not communicate; they are only on the same page for convenience.

  6. As Alice, click Generate a Stealth Meta-Address. This will display the new stealth address and the corresponding private keys. Copy the stealth meta-address to the clipboard.

  7. As Bill, paste the new stealth meta-address and click Generate an address. This gives you the address to fund for Alice.

  8. Copy the address and Bill's public key and paste them in the "Private key for address generated by Bill" area of Alice's user interface. Once those fields are filled in, you will see the private key to access assets at that address.

  9. You can use an online calculatoropens in a new tab to ensure the private key corresponds to the address.

How the program works

The WASM component

The source code that compiles into WASM is written in Rustopens in a new tab. You can see it in src/rust_wasm/src/lib.rsopens in a new tab. This code is primarily an interface between the JavaScript code and the eth-stealth-addresses libraryopens in a new tab.

Cargo.toml

Cargo.tomlopens in a new tab in Rust is analogous to package.jsonopens in a new tab in JavaScript. It contains package information, dependency declarations, etc.

1[package]
2name = "rust-wasm"
3version = "0.1.0"
4edition = "2024"
5
6[dependencies]
7eth-stealth-addresses = "0.1.0"
8hex = "0.4.3"
9wasm-bindgen = "0.2.104"
10getrandom = { version = "0.2", features = ["js"] }
Prikaži sve

The getrandomopens in a new tab package needs to generate random values. That cannot be done by pure algorithmic means; it requires access to a physical process as a source of entropy. This definition specifies that we'll get that entropy by asking the browser we're running in.

1console_error_panic_hook = "0.1.7"

This libraryopens in a new tab gives us more meaningful error messages when the WASM code panics and cannot continue.

1[lib]
2crate-type = ["cdylib", "rlib"]

The output type required to produce WASM code.

lib.rs

This is the actual Rust code.

1use wasm_bindgen::prelude::*;

The definitions to create a WASM package out of Rust. They are documented hereopens in a new tab.

1use eth_stealth_addresses::{
2 generate_stealth_meta_address,
3 generate_stealth_address,
4 compute_stealth_key
5};

The functions we need from the eth-stealth-addresses libraryopens in a new tab.

1use hex::{decode,encode};

Rust typically uses byte arraysopens in a new tab ([u8; <size>]) for values. But in JavaScript, we typically use hexadecimal strings. The hex libraryopens in a new tab translates for us from one representation to the other.

1#[wasm_bindgen]

Generate WASM bindings to be able to call this function from JavaScript.

1pub fn wasm_generate_stealth_meta_address() -> String {

The easiest way to return an object with multiple fields is to return a JSON string.

1 let (address, spend_private_key, view_private_key) =
2 generate_stealth_meta_address();

The generate_stealth_meta_addressopens in a new tab returns three fields:

  • The meta-address (Kpub and Vpub)
  • The viewing private key (Vpriv)
  • The spending private key (Kpriv)

The tupleopens in a new tab syntax lets us separate those values again.

1 format!("{{\"address\":\"{}\",\"view_private_key\":\"{}\",\"spend_private_key\":\"{}\"}}",
2 encode(address),
3 encode(view_private_key),
4 encode(spend_private_key)
5 )
6}

Use the format!opens in a new tab macro to generate the JSON-encoded string. Use hex::encodeopens in a new tab to change the arrays to hex strings.

1fn str_to_array<const N: usize>(s: &str) -> Option<[u8; N]> {

This function turns a hex string (provided by JavaScript) into a byte array. We use it to parse values provided by the JavaScript code. This function is complicated because of how Rust handles arrays and vectors.

The <const N: usize> expression is called a genericopens in a new tab. N is a parameter that controls the length of the returned array. The function is actually called str_to_array::<n>, where n is the array length.

The return value is Option<[u8; N]>, which means the returned array is optionalopens in a new tab. This is a typical pattern in Rust for functions that may fail.

For example, if we call str_to_array::10("bad060a7"), the function is supposed to return a ten-value array, but the input is only four bytes. The function needs to fail, and it does so by returning None. The return value for str_to_array::4("bad060a7") would be Some<[0xba, 0xd0, 0x60, 0xa7]>.

1 // decode returns Result<Vec<u8>, _>
2 let vec = decode(s).ok()?;

The hex::decodeopens in a new tab function return a Result<Vec<u8>, FromHexError>. The Resultopens in a new tab type can contain either a successfult result (Ok(value)) or an error (Err(error)).

The .ok() method turns the Result into an Option, whose value is either the Ok() value if successful or None if not. Finally, the question mark operatoropens in a new tab aborts the current functions and returns a None if the Option is empty. Otherwise, it unwraps the value and returns that (in this case, to assign a value to vec).

This looks like a strangely convoluted method to handle errors, but Result and Option ensure that all errors are handled, one way or another.

1 if vec.len() != N { return None; }

If the number of bytes is incorrect, that's a failure, and we return None.

1 // try_into consumes vec and attempts to make [u8; N]
2 let array: [u8; N] = vec.try_into().ok()?;

Rust has two array types. Arraysopens in a new tab have a fixed size. Vectorsopens in a new tab can grow and shrink. hex::decode returns a vector, but the eth_stealth_addresses library wants to receive arrays. .try_into()opens in a new tab converts a value into another type, for example, a vector into an array.

1 Some(array)
2}

Rust does not require you to use the returnopens in a new tab keyword when returning a value at the end of a function.

1#[wasm_bindgen]
2pub fn wasm_generate_stealth_address(stealth_address: &str) -> Option<String> {

This function receives a public meta-address, which includes both Vpub and Kpub. It returns the stealth address, the public key to publish (Rpub), and a one-byte scan value that speeds up the identification of which published addresses may belong to Alice.

The scan value is part of the shared secret (S = GRprivVpriv). This value is available to Alice, and checking it is much faster than checking whether f(Kpub+G*hash(S)) equals the published address.

1 let (address, r_pub, scan) =
2 generate_stealth_address(&str_to_array::<66>(stealth_address)?);

We use the library's generate_stealth_addressopens in a new tab.

1 format!("{{\"address\":\"{}\",\"rPub\":\"{}\",\"scan\":\"{}\"}}",
2 encode(address),
3 encode(r_pub),
4 encode(&[scan])
5 ).into()
6}

Prepare the JSON-encoded output string.

1#[wasm_bindgen]
2pub fn wasm_compute_stealth_key(
3 address: &str,
4 bill_pub_key: &str,
5 view_private_key: &str,
6 spend_private_key: &str
7) -> Option<String> {
8 .
9 .
10 .
11}
Prikaži sve

This function uses the library's compute_stealth_keyopens in a new tab to calculate the private key to withdraw from the address (Rpriv). This calculation requires these values:

  • The address (Address=f(Ppub))
  • The public key generated by Bill (Rpub)
  • The view private key (Vpriv)
  • The spend private key (Kpriv)
1#[wasm_bindgen(start)]

#[wasm_bindgen(start)]opens in a new tab specifies that the function is executed when the WASM code is initialized.

1pub fn main() {
2 console_error_panic_hook::set_once();
3}

This code specifies that panic output be sent to the JavaScript console. To see it in action, use the application and give Bill an invalid meta-address (just change one hexadecimal digit). You will see this error in the JavaScript console:

1rust_wasm.js:236 panicked at /home/ori/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/subtle-2.6.1/src/lib.rs:701:9:
2assertion `left == right` failed
3 left: 0
4 right: 1

Followed by a stack trace. Then give Bill the valid meta-address, and give Alice either an invalid address or an invalid public key. You will see this error:

1rust_wasm.js:236 panicked at /home/ori/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/eth-stealth-addresses-0.1.0/src/lib.rs:78:9:
2keys do not generate stealth address

Again, followed by a stack trace.

The User interface

The user interface is written using Reactopens in a new tab and served by Viteopens in a new tab. You can learn about them using this tutorial. There is no need for WAGMIopens in a new tab here because we do not interact directly with a blockchain or a wallet.

The only non-obvious part of the user interface is WASM connectivity. Here is how it works.

vite.config.js

This fine contains the Vite configurationopens in a new tab.

1import { defineConfig } from 'vite'
2import react from '@vitejs/plugin-react'
3import wasm from "vite-plugin-wasm";
4
5// https://vite.dev/config/
6export default defineConfig({
7 plugins: [react(), wasm()],
8})

We need two Vite plugins: reactopens in a new tab and wasmopens in a new tab.

App.jsx

This file is the main component of the application. It is a container that includes two components: Alice and Bill, the user interfaces for those users. The relevant part for WASM is the initialization code.

1import init from './rust-wasm/pkg/rust_wasm.js'

When we use wasm-packopens in a new tab, it creaates two files we use here: a wasm file with the actual code (here, src/rust-wasm/pkg/rust_wasm_bg.wasm) and a JavaScript file with the definitions to use it (here, src/rust_wasm/pkg/rust_wasm.js). The default export of that JavaScript file is code that needs to run to initiate WASM.

1function App() {
2 .
3 .
4 .
5 useEffect(() => {
6 const loadWasm = async () => {
7 try {
8 await init();
9 setWasmReady(true)
10 } catch (err) {
11 console.error('Error loading wasm:', err)
12 alert("Wasm error: " + err)
13 }
14 }
15
16 loadWasm()
17 }, []
18 )
Prikaži sve

The useEffect hookopens in a new tab lets you specify a function that gets executed when state variables change. Here, the list of state variables is empty ([]), so this function is executed only once when the page loads.

The effect function has to return immediately. To use asynchronous code, such as the WASM init (which has to load the .wasm file and therefore takes time) we define an internal asyncopens in a new tab function and run it without an await.

Bill.jsx

This is the user interface for Bill. It has a single action, creating an address based on the stealth meta-address provided by Alice.

1import { wasm_generate_stealth_address } from './rust-wasm/pkg/rust_wasm.js'

In addition to the default export, the JavaScript code generated by wasm-pack exports a function for every function in the WASM code.

1 <button onClick={() => {
2 setPublicAddress(JSON.parse(wasm_generate_stealth_address(stealthMetaAddress)))
3 }}>

To call WASM functions, we just call the function exported by the JavaScript file created by wasm-pack.

Alice.jsx

The code in Alice.jsx is analogous, except that Alice has two actions:

  • Generate a meta-address
  • Get the private key for an address published by Bill

Conclusion

Stealth addresses are not panacea; they have to be used correctly. But when used correctly, they can enable privacy on a public blockchain.

See here for more of my workopens in a new tab.

Page last update: 14. studenoga 2025.

Je li ovaj vodič bio koristan?