Real stealth addresses on Solana. ECDH-derived, view-key recoverable, with a Memo-program footprint.
Extracted from the production NoTrace wallet. Zero relayers, zero mixers, zero on-chain program — just a fresh one-time address per payment, derived in the payer's browser. The recipient scans the Memo program with a view-key and finds their payments locally — no relayer, no mixer.
Note
This is the same code that ships in the NoTrace web wallet. Browser, Node 18+,
Deno and Bun are all supported. No Buffer, no globals.
npm install @notrace/stealth-sdk
# or
pnpm add @notrace/stealth-sdk
# or
yarn add @notrace/stealth-sdkVerified on Bun 1.1.x, Deno 2.x, and Node 18+ — no runtime-specific shims.
@solana/web3.js is an optional peer dep — only the scanner needs it (and
even there, the scanner takes a small RpcLike interface, so you can wire in
your own client).
import { generateMetaKey, makePayLink } from "@notrace/stealth-sdk";
const meta = generateMetaKey();
// Persist `meta.seed` somewhere encrypted — that's your whole identity.
const link = makePayLink(meta.pub);
// → "https://notracesol.xyz/pay#m=4mNp9q8K…"
// Share `link` anywhere. Nobody learns who the recipient is.import { deriveStealthSender, encodeMemo, parsePayLink } from "@notrace/stealth-sdk";
const metaPub = parsePayLink(linkFromRecipient)!;
const { stealthPub, ephPub } = deriveStealthSender(metaPub);
// Build a Solana tx:
// 1. SystemProgram.transfer → stealthPub (base58)
// 2. Memo instruction with the body `encodeMemo(ephPub)` (e.g. "nt1:4mNp…")
// Sign + send with Phantom/web3.js. The recipient's scanner will find it.import { Connection } from "@solana/web3.js";
import { scanForPayments, metaFromSeed } from "@notrace/stealth-sdk";
const meta = metaFromSeed(savedSeed);
const rpc = new Connection("https://solana-rpc.publicnode.com", "confirmed");
const payments = await scanForPayments(meta, rpc as any, { limit: 200 });
for (const p of payments) {
console.log(p.lamports / 1e9, "SOL @", p.stealthPub, "from", p.source);
}import { signWithScalar, verify } from "@notrace/stealth-sdk";
// `payment.stealthScalar` from the scanner is the secret you need.
const message = transferTx.serializeMessage(); // web3.js
const sig = signWithScalar(payment.stealthScalar, transferTx.feePayer!.toBytes(), message);
transferTx.addSignature(transferTx.feePayer!, Buffer.from(sig));
// Optionally double-check:
console.assert(verify(sig, message, transferTx.feePayer!.toBytes()));
await rpc.sendRawTransaction(transferTx.serialize());NoTrace is an ERC-5564-style stealth-address scheme ported to ed25519:
SENDER RECIPIENT
────── ─────────
eph_scalar, eph_pub ← fresh keypair meta_scalar, meta_pub ← persistent
shared = eph_scalar × meta_pub shared = meta_scalar × eph_pub
tweak = SHA512(ver ‖ shared ‖ tweak = SHA512(ver ‖ shared ‖
eph_pub ‖ meta_pub) mod L eph_pub ‖ meta_pub) mod L
stealth_pub = meta_pub + tweak × G stealth_scalar = (meta_scalar + tweak) mod L
stealth_pub = stealth_scalar × G
The shared point is identical on both sides because eph_scalar × (meta_scalar × G) = meta_scalar × (eph_scalar × G). The sender knows stealth_pub (a valid
Solana address) but not stealth_scalar — they can't spend the funds they
just sent. Only the recipient, applying their meta_scalar to the
sender-published eph_pub, recovers the spend scalar.
The protocol is self-contained: no Solana program needed. The wire format is a
~48-character Memo (nt1:<base58_eph_pub>) attached to a plain SystemProgram
transfer.
| Export | Returns | Notes |
|---|---|---|
generateMetaKey() |
MetaKey |
Fresh meta-keypair. Persist .seed. |
metaFromSeed(seed) |
MetaKey |
Deterministic from a 32-byte seed. |
pubFromScalar(scalar) |
Uint8Array(32) |
scalar × G. |
deriveStealthSender(metaPub) |
{ stealthPub, ephPub } |
Call once per payment. |
recoverStealth(metaScalar, metaPub, ephPub) |
{ stealthScalar, stealthPub } |
Recipient-side. |
signWithScalar(scalar, pub, msg) |
Uint8Array(64) |
ed25519 sig from raw scalar. |
verify(sig, msg, pub) |
boolean |
Standard ed25519 verify. |
encodeMemo(ephPub) |
string |
nt1:<base58_eph_pub>. |
parseMemo(s) |
Uint8Array(32) | null |
Returns null for non-NoTrace memos. |
makePayLink(metaPub, origin?) |
string |
https://…/pay#m=<base58>. |
parsePayLink(url) |
Uint8Array(32) | null |
Accepts #m= or ?m= form. |
scanForPayments(meta, rpc, opts?) |
StealthPayment[] |
Polls the Memo program. |
checkSignature(sig, meta, rpc) |
StealthPayment | null |
Single-tx variant. |
bs58encode / bs58decode |
— | Zero-dep base58. |
Constants: MEMO_PREFIX ("nt1:"), MEMO_PROGRAM_ID, VERSION.
- The seed is the only secret. Lose
meta.seedand you lose all incoming funds. Back it up encrypted, the same way you'd treat any private key. - The signing path uses a hedged Schnorr nonce
(
r = SHA512(random_prefix ‖ pub ‖ msg)), so bad randomness can't leak the scalar through a collidingr. Each stealth scalar typically signs just one sweep tx, but the hedge costs nothing. - The
nt1:memo prefix is intentional — a future protocol revision can shipnt2:without breaking old wallets, which will simply skip those memos. - Stealth addresses are real ed25519 points; they're indistinguishable on-chain from any other Solana address.
MIT — see LICENSE.
Built by NoTrace. Issues, PRs, and audits welcome.