Install
npm install . ESM, Node 20.19+, browsers and React Native (with a crypto.getRandomValues polyfill). Depends only on @noble/curves, @noble/hashes, @noble/ciphers and bs58; no @solana/web3.js needed.
The npm package stealthprotocol is being published; the code below is its API. ESM, Node 20.19+, browsers and React Native (with a crypto.getRandomValues polyfill). It depends only on @noble/curves, @noble/hashes, @noble/ciphers and bs58; no @solana/web3.js needed.
Everything it does is specified in the spec with test vectors, so a wallet in another language can implement the same thing.
1 · Send to stealth recipients
Recognise the recipient as the user types, then build the payment. Pay links carry the amount, the token, a note and an invoice reference: prefill them.
import { detectRecipient, preparePayment, sendTransaction } from 'stealthprotocol';
const d = detectRecipient(input); // 'meta' | 'handle' | 'link' | 'none'
if (d.kind !== 'none') {
const p = await preparePayment({
rpc, net: 'mainnet',
from: account.address,
to: d.to, // st:sol:… or @name
amount: d.kind === 'link' ? d.params.amount ?? userAmount : userAmount,
token: d.kind === 'link' ? d.params.token : selectedToken, // 'SOL', 'USDC', 'USDT' or a mint
note: d.kind === 'link' ? d.params.message : userNote,
reference: d.kind === 'link' ? d.params.ref : undefined,
});
const signed = await signWithYourKeys(p.transaction); // unsigned legacy transaction, your account pays the fee
await sendTransaction(rpc, signed);
}
Plain wallet addresses and .sol names are not stealth recipients by themselves; resolveRecipient handles meta-addresses and handles. Token payments carry 0.0025 SOL for the receiver's claim plus the one-time address's token account rent: show the total before the user approves.
2 · Receive, scan and claim
The account signs a fixed message once; the stealth keys come from that signature, so the meta-address is the same here and on stealthprotocol.online. Sync on open and every minute or so while the wallet is in use.
import { StealthAccount } from 'stealthprotocol';
const acct = await StealthAccount.fromSigner((msg) => signMessageWithYourKeys(msg), { rpc, net: 'mainnet' });
acct.metaAddress; // show it, with a QR, as "Private address"
const fresh = await acct.sync(); // new payments since the last sync, notes decrypted
const balances = await acct.balances(); // SOL and token amounts still at each one-time address
const { transaction } = await acct.claim(balances[0].match, freshDestination);
await sendTransaction(rpc, transaction);
saveEncrypted(acct.exportState()); // public data only; StealthAccount.restore(signature, state, opts) later
For exchanges that cannot add a memo, give the user acct.withdrawalAddress(i): a fresh address per withdrawal that only these keys can spend. When a token payment arrives without SOL, claim takes a feePayer: that account pays the fee and becomes linked to the payment, so say so.
3 · Let dApps ask for the meta-address
Expose this Wallet Standard feature and dApps can show or use the user's meta-address without asking for a signature. Only the public meta-address leaves the wallet, never a view key or spend key.
features: {
'stealth:getMetaAddress': {
version: '1.0.0',
getMetaAddress: async ({ account }) => ({ metaAddress: accountFor(account).metaAddress }),
},
}
stealthprotocol.online/request already uses it: with a wallet that has it, the user's meta-address fills in with no prompt.
4 · Receipts
After sending, let the user keep a proof of payment. It shows that the transaction paid that meta-address and nothing more, and anyone can check it at /receipt.
import { receiptUrl } from 'stealthprotocol';
const url = receiptUrl('https://stealthprotocol.online', {
v: 1, net: 'mainnet', to: p.metaAddress,
parts: [{ sig: signature, r: p.receiptScalar }],
});
UX checklist
- Label stealth sends as "Private payment". Say that the sender and the amount are public; the receiver is not.
- Claim into a fresh account by default, one payment per claim. Warn when several payments would land in the same account, or in the user's main one.
- Show token payments with their token amount, and the SOL that came with them as "for the claim".
- Show handles and pay-link labels as typed by whoever made them, never as verified names.
- Offer the receipt right after a payment: it cannot be made later.
Security
- The derivation signature is the secret: keep it with the wallet's other secrets, never log it, never send it anywhere.
- A view key (
acct.viewKey) can see every incoming payment and never move funds. Share it only on purpose, for an accountant or the auditor. - The SDK talks to your RPC and, for scanning and handles, to the public Stealth Protocol API. Scanning downloads every announcement, so the API cannot tell which ones are the user's.
- Check that your signer is deterministic:
fromSignersigns twice and refuses one that is not, because keys must be re-derivable.