Ethernauta

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

Recipient meta-address0x02446b5e788df56597be27e9b6fd987c7807600b0bb0b935c692dd9943aa74032702016d07b12088f244df0e4ae285a335771f61ae087efc59308a10eac8b02698fb

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.

View on GitHub

What's happening

  1. Recipient setup. Two independent secp256k1 key pairs: (s, S) spending and (v, V) viewing. Meta-address = S ‖ V (66 bytes of compressed pubkeys).
  2. Sender derives. Pick ephemeral pP = p·G. Compute shared = 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:].
  3. Announce. Sender calls the canonical Announcer: announce(schemeId=1, stealthAddress, P, metadata=concat(view_tag, ...)). The Announcer emits an Announcement event indexed by schemeId, stealthAddress, caller.
  4. Recipient scans. eth_getLogs on 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 stealthAddress and compare. If equal, the deposit is yours.
  5. 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.