Specification · v1

Stealth Protocol v1

Stealth addresses for Solana. A receiver publishes one meta-address; every payment to it lands in a fresh one-time address that only the receiver can find and spend. No program, no pool, no custody. This page is the complete protocol: anyone can build a compatible wallet from it.

Notation

  • G is the ed25519 base point and L = 2252 + 27742317777372353535851937790883648493 is the group order.
  • enc(P) is the 32-byte ed25519 point encoding; le32(x) is a scalar as 32 bytes little-endian.
  • H512 = SHA-512, H256 = SHA-256, || is byte concatenation. Domain strings are ASCII bytes.
  • mod L means: read the 64-byte hash as a little-endian integer and reduce it modulo L. Any scalar equal to 0 is rejected.
  • Points read from untrusted input are decoded strictly (canonical encoding, on the curve) and rejected if small-order. Points inside a meta-address or view key must also be in the prime-order subgroup.

B1 · Receiver keys

The wallet signs a fixed message with Wallet Standard solana:signMessage. Ed25519 signatures are deterministic, so the same wallet always yields the same 64-byte signature, and so the same keys.

MSG_DERIVE = "Stealth Protocol v1\nDerive my stealth keys.\nThis signature never leaves this browser.\nOnly sign this on stealthprotocol.online"

sig = wallet.signMessage(utf8(MSG_DERIVE))          // 64 bytes
s   = H512("SP1/spend" || sig) mod L                // spend scalar
v   = H512("SP1/view"  || sig) mod L                // view scalar
S   = s·G ;  V = v·G

meta-address = "st:sol:" + base58( enc(S) || enc(V) || H256(H256(enc(S) || enc(V)))[0..4] )
  • First-time setup asks for the signature twice and requires byte-identical results. A wallet that signs non-deterministically is refused: payments to a meta-address that cannot be re-derived would be lost.
  • The signature is the receiver's master secret. It never leaves the browser and is never stored. Derived keys live in memory only; Lock or closing the tab drops them.
  • MSG_DERIVE is used for nothing else. Sign it only on stealthprotocol.online: any site that obtains this signature obtains the keys.

B2 · Sending

The sender parses the meta-address (prefix, checksum, both points valid and not small-order), then for every payment:

r   = H512(32 random bytes) mod L                   // fresh per payment, never reused
R   = r·G
Q   = 8·(r·V)                                       // cofactor cleared
h   = H512("SP1/tweak" || enc(Q)) mod L
tag = H256("SP1/tag" || enc(Q))[0]                  // 1-byte view tag
P   = S + h·G                                       // one-time address = base58(enc(P))

The payment is one transaction with two instructions, in either order:

  1. System transfer from the sender to P, at least 1,000,000 lamports (0.001 SOL, above the 890,880-lamport rent-exempt minimum), with ANNOUNCER appended to this instruction's account keys as read-only and non-signer. This is the Solana Pay "reference" pattern, so getSignaturesForAddress(ANNOUNCER) lists every announcement.
  2. SPL Memo v2 (MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr) with the text sp1:<base58(enc(R))>:<tag as 2 lowercase hex chars>.

ANNOUNCER = base58(H256("Stealth Protocol v1 announcer")) = 73ucoayVg2PJTuTMXZkoA71M56hHTbt7aetBzQRYeroR. It is a hash output used as a public key, so nobody holds a private key for it. It never signs and never holds funds.

Any Solana Pay wallet can pay without connecting to the site, through a transfer request:

solana:<P>?amount=<SOL>&reference=73ucoayVg2PJTuTMXZkoA71M56hHTbt7aetBzQRYeroR&memo=<urlencoded sp1:…>&label=Stealth%20Protocol

The send form generates a new r and P every time it opens or the amount changes.

B3 · Scanning

The receiver downloads every announcement { id, R, tag, to, lamports, sig, slot } in pages and checks each one locally, inside a Web Worker:

decode R; skip if invalid or small-order
Q  = 8·(v·R)                                        // equals 8·(r·V)
if H256("SP1/tag" || enc(Q))[0] ≠ tag: skip         // rejects 255/256 of non-matches cheaply
h  = H512("SP1/tweak" || enc(Q)) mod L
P' = S + h·G
match if enc(P') == to

Only a cursor (the last scanned announcement id) is kept in localStorage, keyed by H256(meta-address). When unclaimed payments exist, the cursor is kept just before the oldest of them so the next visit finds it again. Keys and matches are never stored. Balances of matches are fetched with getMultipleAccounts.

B4 · Claiming

p = (s + h) mod L        // assert p·G == P before doing anything

The claim transaction has P as fee payer and a single SystemProgram.transfer of balance − fee to the destination, where the fee comes from getFeeForMessage. Draining P to 0 closes the account. An optional priority fee adds ComputeBudget instructions; their cost is included in the fee and subtracted from the amount.

There is no seed for P, so it is signed with the raw scalar using deterministic EdDSA:

k  = H512("SP1/nonce" || le32(p) || message) mod L
Rn = k·G
e  = H512(enc(Rn) || enc(P) || message) mod L
Sn = (k + e·p) mod L
signature = enc(Rn) || le32(Sn)

This verifies under standard ed25519 because verification only needs P = p·G. Before sending, each claim is verified with @noble/curves and web3.js Transaction.verifySignatures(), then simulated, then sent and confirmed.

One transaction per one-time address by default. Merging several into one transaction links them together on-chain, and the site warns before doing it.

Spending without claiming

A one-time address can also pay directly, with no claim first. The transaction has the one-time address as fee payer and only signer, signed with p exactly as above:

  • Paying a stealth recipient from a private balance: the transfer and memo are the B2 payment, except the source is the one-time address P instead of a wallet. The visible sender is P, which has no link to the payer's wallet.
  • Paying any address: a plain system transfer of part of the balance.
  • Whatever is not sent stays at P as change, which must be 0 (the account closes) or at least the 890,880-lamport rent-exempt minimum. Payments are never combined from several one-time addresses, because that would link them.

Standard amounts

A sender can split a payment into the 1-2-5 series 10, 5, 2, 1, 0.5, 0.2, 0.1, 0.05, 0.02, 0.01 SOL (at most 8 parts), each part to its own one-time address with its own announcement. Every part is an ordinary B2 payment; the split only makes amounts less distinctive.

B5 · View key

view key = "stv:sol:" + base58( le32(v) || enc(S) || H256(H256(le32(v) || enc(S)))[0..4] )

A view key is enough to find every incoming payment and its amount, but it does not contain s, so it can never spend. The auditor page imports one for a read-only scan.

B6 · Registry

A wallet can link itself to its meta-address so senders can type a normal address or a .sol name. The link is an off-chain signMessage, free and with no transaction:

Stealth Protocol v1 registry
wallet: <base58 wallet>
meta: <st:sol:…>
issued: <ISO-8601 UTC, e.g. 2026-10-22T12:00:00.000Z>

Revocation uses the same shape with the line revoke in place of the meta: line. The server verifies the ed25519 signature against the wallet, requires issued within 15 minutes of its clock, and only accepts a message newer than the stored one. The site re-verifies the signature in the browser before paying a registered wallet, and anyone can download the full registry with signatures at /api/registry/export and verify every entry.

.sol names resolve on mainnet through the SNS name registry to the domain owner, and then through the registry above. Names held as NFTs are not followed; the site asks for a wallet address instead.

B7 · Test vectors

Fixed inputs: sig = the 64 bytes 0x01…0x40, r-seed = the 32 bytes 0xA0…0xBF (so r = H512(r-seed) mod L), message = "test". Scalars are le32 hex, points are enc() hex. Every value is asserted in the test suite.

sig (input)0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40
r-seed (input)a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf
message (input)test
s94a04f63d08dec23cf84e3baf366f11fa1672d02ac2157090f8a70f4e3d8f10b
vb8ce869a94f79b78b9b728299ab5bed5cea2e6776f4caca2c82b9202c9291209
S3ef21246053dcad44dbeba959dd77b223dc24f79810924d965ed711fd7f37598
V1c62c78d6165ba9fbc90f5c35fbe234b3059635af22ed9c3ab290d2fed45c3c8
meta-addressst:sol:9Edo47WMdLzaPe9uF5gwa6nXXweFtFajxWLB8WghMiGamRg1aJdmDdJZthDvWJSYyH8aPpFhUq6x8e1ewjiBU8SX4PDMX
view keystv:sol:RBKjixsZiCpgRAra5dgsbgrZQ84Hd5rV7UzmYwsFqvNy9zAF5q9g3XsP66dab97UawyPTjpmez9z9L1wk3D2iG3FqDaNN
re8f6612b36f7098c2060b74639b843293d8058fbddec973d41f31fb719956e05
R79c1465e70e8b2c93bce73ae23c5a8b9c1056f72f09242daa15d77840ad7d0b1
Q18e7d543a9398f4604de7dae44407b6f0d7a295b8a7d2062a872d444e3d4377b
hadd62c99bef88c7bf27f721803e49166f2bf6a63be6c75c2a27519ba63b8ef00
taga3
Pafeb3ea31a08fcdb1dd328de6dae0c016cbe84ff20bb4724a29e5ea9d6c1e8aa
P (base58)CqiQ8CgENHs7W2kEqxqDZGR1x5gytjeZ9Db684Xg7dYu
memosp1:9CHGMVPWuH4AHwg4ehGsP1Ci11mnc6bbjKVqzu6RxSeL:a3
p41777cfc8e86799fc10456d3f64a8386932798656a8ecccbb1ff89ae4791e10c
signature74f2590c580fe09d3fc67c2ed6a1e3bf3855871ca4619e376d5ade982aa1b19d989c2d16b00514963a8248c619c4ebe50f418059703373bc0700edac0cfc800d

B8 · Encrypted payment notes

A sender can attach a note that only the receiver, or someone holding their view key, can read. The key comes from the per-payment shared point Q of B2/B3, so no new keys are needed.

key   = H256("SP1/note-key" || enc(Q))
nonce = H256("SP1/note-nonce" || enc(Q))[0..12]    // Q is unique per payment; one note per payment
AEAD  = ChaCha20-Poly1305, AAD = "SP1/note" || enc(P)
note  = base64url(ciphertext || tag16)             // no padding; plaintext is UTF-8, at most 120 bytes

memo  = "sp1:" + base58(enc(R)) + ":" + tag + ":" + note

The fourth memo field is optional: memos without it are v1 memos and stay valid. The longest memo is 234 characters, well inside SPL Memo and transaction size limits, and it travels unchanged through the Solana Pay memo parameter. The indexer stores the note field as published (ciphertext). The inbox and the auditor decrypt it for matched payments and show "Note unreadable" when authentication fails. When a payment is split into standard amounts, only the first part carries the note.

Test vector: the B7 payment (same Q and P) with the note "gm":

notegm
keyf9e47a30e8846872e487c8ad8c78c91024b9927bd74d655a5971d2eb8101354a
nonce9ce4196e7f2767c9f000489c
ciphertextJktFxpeuivO1oshiHy-qJYhV
memosp1:9CHGMVPWuH4AHwg4ehGsP1Ci11mnc6bbjKVqzu6RxSeL:a3:JktFxpeuivO1oshiHy-qJYhV

B9 · Exchange withdrawal addresses

Exchanges cannot attach a memo, so these addresses are derived by the receiver alone:

h_i = H512("SP1/index" || le32(v) || le32(i)) mod L      // i = 0, 1, 2, …
P_i = S + h_i·G
p_i = (s + h_i) mod L                                    // assert p_i·G == P_i
  • Receive shows the next unused index: the first i not yet handed out on this device whose P_i has no signatures (checked through /rpc).
  • Labels and handed-out indices are stored only on the device, encrypted with AES-256-GCM under H256("SP1/local" || derivation signature), which exists only in memory.
  • The inbox checks P_0 … P_(highest handed out + 20) with batched getMultipleAccounts (at most 100 per call); any funded address extends the window by 20. Matches are claimed exactly as in B4 with p_i.
  • A view key holder can find every P_i (read-only). Only the spend key can claim.
  • Unlike stealth payments, withdrawals to P_i carry no announcement: an observer sees an ordinary transfer to an address with no history.

Test vectors from the B7 inputs:

h_0e643865f8eb233361fc14d1019b4798783804fc2f09cc18ed2839a8403fcf000
P_01a3aba11c1ac8a49096c1acbd333ceeb3c4124e67a691ad4ea6ace272452728e
P_0_base582mPYEMFZz73TGR8gp9gJ86wogHTwZayBJ45yLciYgnQV
p_07ae4d5c25e40205aee4531cb0c1b6ba724e87cc49cbe1898e10d0b79e7d4e20c
h_172c5fb49106bb034b8296ba1e5baaab9d412c49f99c3ac35b36d107e9682450c
P_168244e859253b93b8c85fbfcf59cc403790222bd82e4bb8089bff63d501c9fb2
P_1_base5881XWc3wrxaTyNAgrsmvevy5KEC7QpZidE2iWvXXCrUbT
p_119925550c6958a00b11157b9fa27bdc4757af1a145e5033fc2f780727a5b3708

B10 · Token payments (USDC, USDT, any SPL token)

A token payment is a B2 payment plus the tokens. The SOL transfer that carries ANNOUNCER is still the announcement, so scanning (B3) is unchanged; it also brings the SOL the receiver needs to claim.

1. createAssociatedTokenAccountIdempotent(payer = sender, owner = P, mint)
2. transferChecked(sender's token account → ATA(P, mint), amount, decimals)
3. SystemProgram.transfer(sender → P, TOKEN_GAS = 2,500,000 lamports) + ANNOUNCER (+ invoice reference, B11)
4. SPL Memo v2: "sp1:" + base58(enc(R)) + ":" + tag [+ ":" + note]
  • Why 0.0025 SOL travels with it. The one-time address must pay its own claim fee and, when the destination is a brand-new wallet, that wallet's token account rent (0.00148844 SOL on SPL Token and 0.00151384 on Token-2022 at today's rent). The sender also pays the one-time address's token account rent. The claim returns both to the receiver. Claims read the exact rent from the cluster when they are built, so P always ends at 0.
  • Indexing. The token, the raw amount and the token account come from the transaction's token balances: the account owned by P whose balance went up. The API returns them with each announcement.
  • Claiming. One transaction signed with p (B4): create the destination's associated token account if missing (paid by P), transferChecked the whole balance, close P's token account (rent to the destination), send P's remaining SOL to the destination. P ends at 0.
  • No SOL at P (an exchange sending USDC to a withdrawal address, B9): a connected wallet pays the fee and any new token account, and gets P's token account rent back. P still signs as the token owner. That wallet appears in the transaction, so it is linked to the payment.
  • Supported mints. SPL Token mints, and Token-2022 mints whose only extensions are close authority, metadata and group pointers. Transfer fees, transfer hooks, permanent delegates, non-transferable or default-frozen accounts, confidential transfers and interest-bearing or scaled amounts are refused: the amount sent must be the amount received, and only p may move it.
  • Solana Pay QR uses a transaction request: solana:https://stealthprotocol.online/api/pay/tx?net=…&to=<P>&memo=<sp1:…>&mint=…&amount=<raw>[&ref=…] (URL-encoded). GET answers the label and icon; POST {"account"} answers the unsigned transaction above with that account as fee payer. The page computes P and the memo in the payer's browser; the endpoint sees only what the transaction makes public and stores nothing. A plain spl-token transfer request is not used because wallets need not create P's token account.

B11 · Payment links and invoices

https://stealthprotocol.online/pay/<meta-address>?<params>     or     /@<handle>?<params>

token    "SOL" (default), a symbol (USDC, USDT, STEALTH) or a mint address
amount   decimal amount in that token; fixed when present
label    who asks to be paid, ≤ 60 characters (shown as written, not verified)
message  what it is for, ≤ 140 characters
invoice  invoice number, ≤ 40 characters
ref      invoice reference: 32 random bytes, base58
return   https URL the payer can go back to; "stealth_tx" (and "stealth_ref") are appended
net      "devnet" for test links
  • A link carries the meta-address or handle, never a one-time address: each payer's browser makes its own (B2), so a link can be paid any number of times and the payments stay unrelated.
  • Invoices add ref, which the payment carries as a read-only key right after ANNOUNCER. Anyone holding the link finds the payment with getSignaturesForAddress(ref). The receiver checks it with their keys: the transfer carries the reference, P opens with their keys (B3), and the token and amount are at least what was asked. The reference names nobody; it only ties that one payment to that one invoice. Never reuse it: a shared reference would tie every payment that carries it together.
  • The receiver's invoice list stays on their device, encrypted like B9 labels.
  • Website button: a plain link with inline styles pointing to a payment link without ref (a button serves many payers). No script, nothing loaded from this site until it is clicked.

@handles

A handle is a short name that resolves to a meta-address, so stealthprotocol.online/@name can be shared as a pay link. Payments to it are ordinary B2 payments to the handle's current meta-address. Claiming burns $STEALTH from the claimer's wallet.

Names

  • 3–15 characters, [a-z0-9_], lowercase, no leading or trailing underscore, no double underscore. Case-insensitive.
  • Reserved, never claimable: stealth, stealthprotocol, admin, support, help, official, team, dev, mod, solana, sol, phantom, solflare, backpack, jupiter, jup, pump, pumpfun, raydium, binance, coinbase, okx, bybit, kraken, bitget, kucoin, usdc, usdt, tether, circle, toly, anatoly, helius, magiceden, tensor, wallet, pay, send, receive, inbox, spec, explorer, auditor, api, rpc, handles, www.
  • First valid claim wins, ordered by slot, then by position in the block. No expiry.

Price (whole $STEALTH burned, configurable)

3 characters500,000 $STEALTH
4 characters200,000 $STEALTH
5 characters100,000 $STEALTH
6–15 characters50,000 $STEALTH

Claim transaction

HANDLES_REF = base58(H256("Stealth Protocol v1 handles"))
            = 5rgEYKg68AsB7nWhrsNRHqGGYmo9zrBcs4hQtJkgszW2

1. SPL Memo v2: "sp1-handle:<name>:<meta-address>"
2. burnChecked(price × 10^decimals) of the $STEALTH mint from the claimer's token account,
   with HANDLES_REF appended to this instruction's keys (read-only, non-signer).
   SPL Token or Token-2022, detected from the owner program of the mint.

The indexer reads getSignaturesForAddress(HANDLES_REF) every minute on each network. A claim is valid only if the transaction succeeded, the mint is the configured $STEALTH mint (a test mint on devnet), the burned amount is at least the price for the name's length, the memo parses, the meta-address is valid, the name is well-formed and not reserved, and nobody owns it yet. The burn authority becomes the handle's owner. Burned tokens are destroyed; nobody receives them.

Updates

The owner can point a handle to a new meta-address with a free off-chain signature. The newest valid update wins and the history stays public:

Stealth Protocol v1 handle update
handle: <name>
meta: <st:sol:…>
issued: <ISO-8601 UTC>

Privacy: the wallet that burns is publicly linked to the handle. Payments to the handle stay unlinkable, exactly like payments to the meta-address itself.

SDK

The site's own protocol code is also a TypeScript package with no dependency on @solana/web3.js: it builds legacy transactions itself and returns them in wire format, so any wallet API can sign them.

  • createInvoice and getInvoiceStatus: a link with a fresh reference, and its status checked with a view key only, so a server never holds the spend key.
  • preparePayment: a SOL or token payment (B2, B10) to a meta-address or @handle, unsigned, with the payer as fee payer.
  • scanPayments and prepareClaim: find payments with a view key (notes decrypted) and build the claim signed with p, or with a wallet paying the fee.
  • Lower level: one-time addresses, memos, notes, withdrawal addresses, instruction builders, compileLegacy and serializeTx.

Constants

ANNOUNCER73ucoayVg2PJTuTMXZkoA71M56hHTbt7aetBzQRYeroR
Announcer seed"Stealth Protocol v1 announcer"
Memo programMemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr (SPL Memo v2)
Memo prefixsp1: (payments), sp1-handle: (handle claims)
HANDLES_REF5rgEYKg68AsB7nWhrsNRHqGGYmo9zrBcs4hQtJkgszW2
Meta-address prefixst:sol:
View key prefixstv:sol:
Domain stringsSP1/spend, SP1/view, SP1/tweak, SP1/tag, SP1/nonce, SP1/note-key, SP1/note-nonce, SP1/note, SP1/index, SP1/local
Checksumfirst 4 bytes of H256(H256(payload))
Minimum payment1,000,000 lamports (0.001 SOL)
TOKEN_GAS2,500,000 lamports (0.0025 SOL) sent to P with every token payment (B10)

Threat model

What it hides

  • The link between the sender and the receiver's main wallet. The payment goes to a one-time address with no history. Without the view key, nobody can tell which meta-address it belongs to.
  • The receiver's total incoming. Each payment sits at its own address, so observers cannot add them up or tell that two payments went to the same person.

What it does not hide

  • The sender is visible. A connected wallet signs and pays the transfer. Paying from a private balance shows a one-time address instead, but that address is still linked, on-chain, to the payment that funded it: the chain of one-time addresses is public even when none of them names anyone.
  • Amounts are visible. Unusual amounts make payments easier to correlate. Standard amounts help, but parts paid together share their sender and their timing.
  • Claiming into a known wallet re-links. The claim is a public transfer from the one-time address to the destination. Claim into a fresh wallet, one payment per transaction.
  • Timing correlation. A claim right after a payment, or several claims together, can be matched by time.
  • RPC providers see IP addresses. Whoever serves your RPC requests sees your IP and which accounts you ask about. This site's /rpc proxy keeps no logs and forwards without your IP, but the request still passes through Cloudflare and the upstream provider.

Also worth knowing

  • Announcements are public by design: anyone can see that a stealth payment happened, to which one-time address, and for how much.
  • Scanning downloads every announcement, so the server cannot tell which ones are yours. It can see that your IP scanned.
  • Linking a wallet in the registry reveals that the wallet uses Stealth Protocol, not which payments are its.
  • A view key reveals every incoming payment to whoever holds it, including notes and exchange withdrawal addresses.
  • A handle links the claiming wallet to the name in public. Payments to the handle stay unlinkable.
  • Token payments show the token and the amount, like SOL payments. Paying a claim fee from a connected wallet (B10) links that wallet to the payment.
  • A payment link's label and message are whatever its author typed. They are shown as written, never verified.
  • Anyone can post announcements. Fake ones cost the poster a fee and make scanning slightly slower, but cannot make a payment appear in your inbox: a match requires enc(S + h·G) to equal the address that was actually paid.
  • Anything that can read this tab's memory (a malicious extension, a compromised device) can read your keys while unlocked.

Verify it yourself

This standalone script derives a one-time address with nothing but @noble/curves, @noble/hashes and bs58. Deriving P needs a secret: either the sender's r (with the meta-address) or the receiver's view key (with R from the memo). Run it on the test vectors above and you get the same P. Download verify.mjs.

// Stealth Protocol v1: derive a one-time address independently of the site.
//   npm i @noble/curves@2 @noble/hashes@2 bs58@6
//   Sender view:   node verify.mjs <st:sol:meta-address> <r as 32-byte little-endian hex>
//   Receiver view: node verify.mjs <stv:sol:view-key> <R as base58>
import { ed25519 } from '@noble/curves/ed25519.js';
import { sha256, sha512 } from '@noble/hashes/sha2.js';
import bs58 from 'bs58';

const { BASE: G, Fn } = ed25519.Point;
const utf8 = (s) => new TextEncoder().encode(s);
const cat = (...a) => Uint8Array.from(a.flatMap((x) => [...x]));
const le = (b) => b.reduceRight((n, x) => (n << 8n) | BigInt(x), 0n);
const hex = (b) => Buffer.from(b).toString('hex');
const point = (b) => ed25519.Point.fromBytes(b);

function decode(str, prefix) {
  const raw = bs58.decode(str.slice(prefix.length));
  const body = raw.slice(0, 64);
  if (raw.length !== 68 || hex(sha256(sha256(body)).slice(0, 4)) !== hex(raw.slice(64))) throw new Error('bad checksum');
  return [body.slice(0, 32), body.slice(32)];
}

function finish(S, sharedQ) {
  const Q = sharedQ.clearCofactor().toBytes(); // Q = 8·(r·V) = 8·(v·R)
  const h = le(sha512(cat(utf8('SP1/tweak'), Q))) % Fn.ORDER;
  const tag = sha256(cat(utf8('SP1/tag'), Q))[0];
  const P = point(S).add(G.multiply(h)); // P = S + h·G
  console.log('tag', tag.toString(16).padStart(2, '0'));
  console.log('P  ', bs58.encode(P.toBytes()));
}

const [a, b] = process.argv.slice(2);
if (a?.startsWith('st:sol:') && b) {
  const [S, V] = decode(a, 'st:sol:');
  const r = le(Buffer.from(b, 'hex'));
  console.log('R  ', bs58.encode(G.multiply(r).toBytes()));
  finish(S, point(V).multiply(r));
} else if (a?.startsWith('stv:sol:') && b) {
  const [v, S] = decode(a, 'stv:sol:');
  finish(S, point(bs58.decode(b)).multiply(le(v)));
} else {
  console.log('usage: node verify.mjs <st:sol:meta> <r-hex>  |  node verify.mjs <stv:sol:view-key> <R-base58>');
}

With the B7 vectors, both commands print P = CqiQ8CgENHs7W2kEqxqDZGR1x5gytjeZ9Db684Xg7dYu and tag = a3:

# sender view: meta-address and r
node verify.mjs st:sol:9Edo47WMdLzaPe9uF5gwa6nXXweFtFajxWLB8WghMiGamRg1aJdmDdJZthDvWJSYyH8aPpFhUq6x8e1ewjiBU8SX4PDMX e8f6612b36f7098c2060b74639b843293d8058fbddec973d41f31fb719956e05

# receiver view: view key and R from the memo
node verify.mjs stv:sol:RBKjixsZiCpgRAra5dgsbgrZQ84Hd5rV7UzmYwsFqvNy9zAF5q9g3XsP66dab97UawyPTjpmez9z9L1wk3D2iG3FqDaNN 9CHGMVPWuH4AHwg4ehGsP1Ci11mnc6bbjKVqzu6RxSeL

Public API

  • GET /api/announcements?net=mainnet|devnet&after=<id>&limit=<≤5000>: compact rows [id, R, tag, to, lamports, sig, slot, time, sender, note, mint, amount, decimals, token_account], ascending. The last four are null for SOL payments.
  • GET /api/announcements?net=…&order=desc&before=<id>: newest first.
  • GET /api/stats?net=…, GET /api/tx/<signature>?net=…
  • GET /api/registry/<wallet>, POST /api/registry with { msg, sig }, GET /api/registry/export
  • GET|POST /api/pay/tx?…: Solana Pay transaction request for token payments (B10).
  • GET /api/handles/<name>?net=…, GET /api/handles?net=…&recent=50 (or top=, owner=), POST /api/handles/update with { msg, sig }, GET /api/burns/stats?net=…