@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.

Shell
npm install @p15/stealth

Keys

Every key derives from one ed25519 signature over a fixed message. No seed phrases.

TypeScript
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);                  // P15Keys

deriveKeys(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:

FieldTypeMeaning
scanSecretUint8ArrayX25519 secret that detects incoming payments and decrypts memos. Shareable with auditors.
scanPublicUint8ArrayX25519 public half; part of the meta-address.
spendPublicUint8Arrayed25519 spend public; part of the meta-address.
spendScalarUint8Arrayed25519 spend scalar (clamped). Spend authority; never leaves the client.
elgamalSeedUint8ArraySeed for the CT ElGamal keypair (reserved).
aesSeedUint8ArraySeed for the decryptable-balance AeKey (reserved).
epochnumberThe 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.

TypeScript
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.

ExportPurpose
deriveStealthForSendSender: one-time StealthAccount from a meta-address.
deriveStealthForReceiveRecipient: detect + reconstruct a payment.
deriveStealthOwnerScalarForReceiveRecipient: 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.

TypeScript
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 | null

ScanOptions: 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.

TypeScript
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

TypeScript
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).