@p15/stealth
The base layer: key derivation, the public meta-address identity, stealth delivery, and the announce-and-scan discovery system. No internal dependencies. Everything else builds on it. Import from the package root.
npm install @p15/stealthKeys
Every key derives from one ed25519 signature over a fixed message. No seed phrases.
import { keyDerivationMessage, deriveKeys } from "@p15/stealth";
const message = keyDerivationMessage(walletAddress, epoch); // Uint8Array
const signature = await wallet.signMessage(message); // raw 64 bytes
const keys = deriveKeys(signature, epoch); // P15KeysderiveKeys(signature, epoch = 0) throws if the signature is not 64 bytes. The
epoch is also bound into the message, so different epochs cannot collide. The
returned P15Keys:
| Field | Type | Meaning |
|---|---|---|
scanSecret | Uint8Array | X25519 secret that detects incoming payments and decrypts memos. Shareable with auditors. |
scanPublic | Uint8Array | X25519 public half; part of the meta-address. |
spendPublic | Uint8Array | ed25519 spend public; part of the meta-address. |
spendScalar | Uint8Array | ed25519 spend scalar (clamped). Spend authority; never leaves the client. |
elgamalSeed | Uint8Array | Seed for the CT ElGamal keypair (reserved). |
aesSeed | Uint8Array | Seed for the decryptable-balance AeKey (reserved). |
epoch | number | The rotation epoch this set belongs to. |
Helpers: clampScalar, bytesToScalar, scalarToBytes for raw scalar work.
Meta-address
The public payment identity: a bech32m string encoding the scan and spend public keys. Share it once.
import { encodeMetaAddress, decodeMetaAddress, isMetaAddress } from "@p15/stealth";
const meta = encodeMetaAddress({ scanPublic: keys.scanPublic, spendPublic: keys.spendPublic });
isMetaAddress(meta); // true
const { scanPublic, spendPublic } = decodeMetaAddress(meta);Always validate an incoming address with isMetaAddress before sending. Two
payments to the same meta-address produce unrelated on-chain destinations.
Stealth derivation
The sender turns a meta-address into a one-time destination; the recipient
re-derives ownership. The owner key is R = spend_pub + t*G where t comes from
the ECDH shared secret. The sender can compute R but not the scalar behind it.
| Export | Purpose |
|---|---|
deriveStealthForSend | Sender: one-time StealthAccount from a meta-address. |
deriveStealthForReceive | Recipient: detect + reconstruct a payment. |
deriveStealthOwnerScalarForReceive | Recipient: the spendable owner scalar. |
matchesViewTag(scanSecret, ephemeralPublic, viewTag) | Cheap "is this mine" pre-filter. |
scalarTransactionSigner(scalarBytes) | A TransactionSigner from a raw scalar (the release key). |
For the note model, the equivalent note-aware derivations live in
@p15/blind-mode (deriveNoteForSend / deriveNoteForReceive),
which reuse the same R = spend_pub + t*G tweak.
Announce and scan
Every blind transfer pings one fixed anchor address with an encrypted memo. Recipients scan that one address.
import { P15_ANNOUNCE_ADDRESS, scanAnnouncements, scanAnnouncementsPage } from "@p15/stealth";
// All announcements addressed to this key (both directions):
const found = await scanAnnouncements(rpc, keys.scanSecret, { indexerUrl });
// One page, with a cursor for older history:
const page = await scanAnnouncementsPage(rpc, keys.scanSecret, {
before, limit: 50, indexerUrl,
});
// page.items: DiscoveredAnnouncement[], page.nextBefore: string | nullScanOptions: before, until, limit, indexerUrl. When indexerUrl is set
the scanner fetches pre-collected announcements in one request and decrypts
locally; otherwise it scans the anchor over RPC. The indexer only ever sees
ciphertext.
A DiscoveredAnnouncement carries signature, slot, blockTime, and a decoded
announcement with the amount, mint, ephemeralPublic, direction
(0 = incoming, 1 = the sender's self-receipt), optional note, and scheme.
import { encodeAnnouncement, decodeAnnouncement } from "@p15/stealth";
// Low-level memo codec, used by the planners; you rarely call these directly.Putting it together: a recipient's balance
import { scanAnnouncements } from "@p15/stealth";
import { deriveNoteForReceive, assetField } from "@p15/blind-mode";
const found = await scanAnnouncements(rpc, keys.scanSecret, { indexerUrl });
const notes = found.map((d) =>
deriveNoteForReceive(
d.announcement.amount,
assetField(d.announcement.mint),
keys.scanSecret,
keys.spendScalar,
d.announcement.ephemeralPublic,
),
);
// Keep only notes whose on-chain marker is still alive (see note-vault program).