ERC-5564 — stealth addresses (Scheme 1, SECP256k1)
A privacy primitive: the recipient publishes a long-lived
meta-address (spending_pub ‖ viewing_pub). The sender
derives a one-shot stealth address from that meta-address +
a fresh ephemeral key, transfers funds there, and posts a
public announcement carrying the ephemeral pubkey + a view
tag. The recipient scans the announcer log, view-tag filters
out 255/256 of irrelevant entries, then rederives the
stealth address with their viewing private key. Only the
spending private key can move funds — viewing keys can
detect deposits but never spend.
Recipient
Sender — derive a stealth address
Recipient — check an announcement
All math runs in-browser. Nothing is broadcast. Privates above are demo data — replace with a real wallet integration before relying on this.
What's happening
- Recipient setup. Two independent secp256k1 key
pairs:
(s, S)spending and(v, V)viewing. Meta-address =S ‖ V(66 bytes of compressed pubkeys). - Sender derives. Pick ephemeral
p→P = p·G. Computeshared = p·V(compressed serialization). Hash:s_h = keccak256(shared). View tag =s_h[0]. Stealth pubkey =S + s_h·G. Stealth address =keccak256(stealth_pubkey_uncompressed[1:])[12:]. - Announce. Sender calls the canonical Announcer:
announce(schemeId=1, stealthAddress, P, metadata=concat(view_tag, ...)). The Announcer emits anAnnouncementevent indexed byschemeId,stealthAddress,caller. - Recipient scans.
eth_getLogson the Announcer. For each event:- Decode
view_tag = metadata[0]. Compute the recipient's view tag for that ephemeral pubkey. Skip on mismatch (~99.6% of irrelevant events skipped). - On match, rederive
stealthAddressand compare. If equal, the deposit is yours.
- Decode
- Spend. Recipient's stealth private key =
(s + s_h) mod n. Plug into an EIP-1559 signer like any other EOA.
The code
import "./demo.css"
import { type Bytes, BytesSchema } from "@ethernauta/core"
import {
check_stealth_address,
compute_view_tag,
derive_meta_address,
format_stealth_meta_address,
generate_stealth_address,
parse_stealth_meta_address,
type StealthMetaAddress,
} from "@ethernauta/erc/5564"
import { useMemo, useState } from "react"
import {
type InferOutput,
number,
object,
parse,
string,
} from "valibot"
import { Button } from "../../components/button"
function hex_to_bytes_local(hex: Bytes): Uint8Array {
const stripped = hex.toLowerCase().replace(/^0x/, "")
const out = new Uint8Array(stripped.length / 2)
for (let i = 0; i < out.length; i++) {
out[i] = Number.parseInt(
stripped.slice(i * 2, i * 2 + 2),
16,
)
}
return out
}
function random_32_bytes_hex(): Bytes {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
let hex = "0x"
for (const b of bytes) {
hex += b.toString(16).padStart(2, "0")
}
return parse(BytesSchema, hex)
}
const GeneratedSchema = object({
stealth_address: string(),
ephemeral_public_key: string(),
view_tag: number(),
})
type Generated = InferOutput<typeof GeneratedSchema>
export function Stealth5564Demo() {
const [spending_priv, set_spending_priv] = useState<string>(
() => random_32_bytes_hex(),
)
const [viewing_priv, set_viewing_priv] = useState<string>(
() => random_32_bytes_hex(),
)
const [meta_input, set_meta_input] = useState<string>("")
const [generated, set_generated] =
useState<Generated | null>(null)
const [scan_input, set_scan_input] = useState<string>("")
const [match_result, set_match_result] = useState<
string | null
>(null)
const [error, set_error] = useState<string | null>(null)
const meta = useMemo<StealthMetaAddress | null>(() => {
try {
return derive_meta_address({
spending_private_key: hex_to_bytes_local(
parse(BytesSchema, spending_priv),
),
viewing_private_key: hex_to_bytes_local(
parse(BytesSchema, viewing_priv),
),
})
} catch {
return null
}
}, [spending_priv, viewing_priv])
const meta_hex = useMemo(
() => (meta ? format_stealth_meta_address(meta) : ""),
[meta],
)
function generate() {
set_error(null)
set_generated(null)
try {
const parsed = meta_input
? parse_stealth_meta_address(
parse(BytesSchema, meta_input),
)
: meta
if (!parsed) throw new Error("no meta-address")
const result = generate_stealth_address({
meta: parsed,
})
set_generated({
stealth_address: result.stealth_address,
ephemeral_public_key: result.ephemeral_public_key,
view_tag: result.view_tag,
})
} catch (e) {
set_error(
e instanceof Error ? e.message : "Unknown error",
)
}
}
function scan() {
set_error(null)
set_match_result(null)
if (!meta) {
set_error("Set recipient keys first.")
return
}
try {
const ephemeral_hex = parse(BytesSchema, scan_input)
const viewing_bytes = hex_to_bytes_local(
parse(BytesSchema, viewing_priv),
)
const tag = compute_view_tag({
viewing_private_key: viewing_bytes,
ephemeral_public_key: ephemeral_hex,
})
const addr = check_stealth_address({
viewing_private_key: viewing_bytes,
spending_public_key: meta.spending_public_key,
ephemeral_public_key: ephemeral_hex,
})
set_match_result(`view_tag=${tag} → ${addr}`)
} catch (e) {
set_error(
e instanceof Error ? e.message : "Unknown error",
)
}
}
return (
<div className="stealth-5564-root">
<h3 className="stealth-5564-heading">Recipient</h3>
<Field
label="Spending private key"
value={spending_priv}
onChange={set_spending_priv}
/>
<Field
label="Viewing private key"
value={viewing_priv}
onChange={set_viewing_priv}
/>
<Button
variant="secondary"
onClick={() => {
set_spending_priv(random_32_bytes_hex())
set_viewing_priv(random_32_bytes_hex())
}}
>
Randomize recipient
</Button>
<div className="stealth-5564-meta-row">
<Row
label="Recipient meta-address"
value={meta_hex}
mono
/>
</div>
<h3 className="stealth-5564-heading">
Sender — derive a stealth address
</h3>
<Field
label="Recipient meta-address (paste, or use the one above)"
value={meta_input}
onChange={set_meta_input}
placeholder={meta_hex}
/>
<Button onClick={generate}>
Generate stealth address
</Button>
{generated && (
<div className="stealth-5564-generated">
<Row
label="Stealth address"
value={generated.stealth_address}
mono
/>
<Row
label="Ephemeral pubkey"
value={generated.ephemeral_public_key}
mono
/>
<Row
label="View tag"
value={`0x${generated.view_tag.toString(16).padStart(2, "0")} (${generated.view_tag})`}
mono
/>
</div>
)}
<h3 className="stealth-5564-heading is-spaced">
Recipient — check an announcement
</h3>
<Field
label="Ephemeral public key from announcement"
value={scan_input}
onChange={set_scan_input}
placeholder={
generated?.ephemeral_public_key ?? "0x…"
}
/>
<Button variant="secondary" onClick={scan}>
Recover stealth address
</Button>
{match_result && (
<p className="stealth-5564-match">{match_result}</p>
)}
{error && (
<p className="stealth-5564-error">{error}</p>
)}
<p className="stealth-5564-footnote">
All math runs in-browser. Nothing is broadcast.
Privates above are demo data — replace with a real
wallet integration before relying on this.
</p>
</div>
)
}
function Field({
label,
value,
onChange,
placeholder,
}: {
label: string
value: string
onChange: (next: string) => void
placeholder?: string
}) {
return (
<label className="stealth-5564-field">
<span className="stealth-5564-field-label">
{label}
</span>
<input
value={value}
placeholder={placeholder}
onChange={(e) =>
onChange(e.currentTarget.value.trim())
}
className="stealth-5564-field-input"
/>
</label>
)
}
function Row({
label,
value,
mono,
}: {
label: string
value: string
mono?: boolean
}) {
return (
<div className="stealth-5564-row">
<span className="stealth-5564-row-label">
{label}
</span>
<span
className={
mono
? "stealth-5564-row-value is-mono"
: "stealth-5564-row-value"
}
>
{value}
</span>
</div>
)
}
Announcer contract
ERC-5564 specifies a singleton Announcer with:
event Announcement(
uint256 indexed schemeId,
address indexed stealthAddress,
address indexed caller,
bytes ephemeralPubKey,
bytes metadata
);
function announce(
uint256 schemeId,
address stealthAddress,
bytes calldata ephemeralPubKey,
bytes calldata metadata
) external;This package exports announce(...) (Signable) and the
canonical event topic so dapps can build the
eth_getLogs filter against the announcer of their
choice.