@notrace/stealth-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/keys.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Meta-keypair generation and seed derivation.
3
+ *
4
+ * A NoTrace identity is a single ed25519 keypair (the *meta-key*). Its public
5
+ * half is shared with payers — that's enough for them to derive a one-time
6
+ * stealth address. Its private half (the *view+spend scalar*) is what lets the
7
+ * recipient detect and claim incoming payments.
8
+ *
9
+ * Note: this is a non-hierarchical key. If you want separate view-only and
10
+ * spend keys, derive them upstream from your own master secret and pass the
11
+ * spend scalar in only when you need to sign.
12
+ */
13
+
14
+ import { ed25519 } from "@noble/curves/ed25519";
15
+ import { sha512 } from "@noble/hashes/sha2";
16
+ import { randomBytes } from "@noble/hashes/utils";
17
+ import { bytesToNumberLE } from "./bytes.js";
18
+
19
+ const Point = ed25519.ExtendedPoint;
20
+ const L = ed25519.CURVE.n;
21
+
22
+ /** A meta-keypair. `seed` and `scalar` are *secret*; only `pub` is shareable. */
23
+ export interface MetaKey {
24
+ /** Original 32-byte random seed (kept for backup/export). */
25
+ seed: Uint8Array;
26
+ /** ed25519 scalar derived from the seed (in [0, L)). */
27
+ scalar: bigint;
28
+ /** 32-byte compressed ed25519 public point. Share this with payers. */
29
+ pub: Uint8Array;
30
+ }
31
+
32
+ /** Generate a fresh random meta-keypair using `crypto.getRandomValues`. */
33
+ export function generateMetaKey(): MetaKey {
34
+ return metaFromSeed(randomBytes(32));
35
+ }
36
+
37
+ /**
38
+ * Deterministically derive a meta-keypair from a 32-byte seed.
39
+ *
40
+ * Performs the standard ed25519 clamping (RFC 8032 §5.1.5) so the scalar lands
41
+ * in a valid range. Same `seed` → same `pub` forever.
42
+ */
43
+ export function metaFromSeed(seed: Uint8Array): MetaKey {
44
+ if (seed.length !== 32) {
45
+ throw new Error(`metaFromSeed: seed must be 32 bytes (got ${seed.length})`);
46
+ }
47
+ const h = sha512(seed);
48
+ const sb = new Uint8Array(h.slice(0, 32));
49
+ // ed25519 clamping
50
+ sb[0]! &= 248;
51
+ sb[31]! &= 127;
52
+ sb[31]! |= 64;
53
+ const scalar = bytesToNumberLE(sb) % L;
54
+ const pub = Point.BASE.multiply(scalar).toRawBytes();
55
+ // Defensive copy of `seed` so callers can't mutate our state.
56
+ return { seed: new Uint8Array(seed), scalar, pub };
57
+ }
58
+
59
+ /** Recompute the public point for a given scalar. */
60
+ export function pubFromScalar(scalar: bigint): Uint8Array {
61
+ return Point.BASE.multiply(scalar).toRawBytes();
62
+ }
package/src/memo.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Memo-program encoding for the ephemeral pub.
3
+ *
4
+ * Every NoTrace payment carries a memo of the form `nt1:<bs58_eph_pub>`. The
5
+ * recipient's scanner parses these out of memo-program instructions and feeds
6
+ * them to `recoverStealth` to test for matches.
7
+ *
8
+ * The `nt1:` prefix makes the protocol forward-compatible: a future scheme can
9
+ * use `nt2:` and old wallets will simply skip those memos.
10
+ */
11
+
12
+ import { bs58decode, bs58encode } from "./base58.js";
13
+
14
+ /** Current memo prefix. Bump when the on-chain wire format changes. */
15
+ export const MEMO_PREFIX = "nt1:";
16
+
17
+ /** Solana's official Memo Program address (v2). Useful for filtering RPC calls. */
18
+ export const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
19
+
20
+ /**
21
+ * Encode an ephemeral public key into a memo string for on-chain publication.
22
+ * Output is ~48 chars (`nt1:` + 44-char base58).
23
+ */
24
+ export function encodeMemo(ephPubBytes: Uint8Array): string {
25
+ if (ephPubBytes.length !== 32) {
26
+ throw new Error(`encodeMemo: eph_pub must be 32 bytes (got ${ephPubBytes.length})`);
27
+ }
28
+ return MEMO_PREFIX + bs58encode(ephPubBytes);
29
+ }
30
+
31
+ /**
32
+ * Parse a memo string back into the ephemeral pub. Returns `null` for any
33
+ * memo that doesn't start with the current prefix or fails to decode to 32
34
+ * bytes — so the scanner can pipe every memo through this without try/catch.
35
+ */
36
+ export function parseMemo(memoString: unknown): Uint8Array | null {
37
+ if (typeof memoString !== "string") return null;
38
+ if (!memoString.startsWith(MEMO_PREFIX)) return null;
39
+ const body = memoString.slice(MEMO_PREFIX.length).trim();
40
+ try {
41
+ const bytes = bs58decode(body);
42
+ if (bytes.length !== 32) return null;
43
+ return bytes;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
package/src/payLink.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * NoTrace pay-link helpers.
3
+ *
4
+ * A pay-link encodes a recipient's `meta_pub` in the URL fragment of a hosted
5
+ * `/pay` page (default: https://notracesol.xyz/pay). The recipient shares the
6
+ * link; the payer's browser derives a one-time address client-side and signs
7
+ * the transfer through Phantom.
8
+ *
9
+ * Format: `https://<origin>/pay#m=<bs58(meta_pub)>`
10
+ *
11
+ * Using the URL fragment (after `#`) means the meta-pub never reaches the
12
+ * NoTrace server — even the request log can't link payer to recipient.
13
+ */
14
+
15
+ import { bs58decode, bs58encode } from "./base58.js";
16
+
17
+ const DEFAULT_ORIGIN = "https://notracesol.xyz";
18
+
19
+ /** Build a `/pay` link for a given meta-pub. */
20
+ export function makePayLink(metaPubBytes: Uint8Array, origin: string = DEFAULT_ORIGIN): string {
21
+ if (metaPubBytes.length !== 32) {
22
+ throw new Error(`makePayLink: meta_pub must be 32 bytes (got ${metaPubBytes.length})`);
23
+ }
24
+ const cleanOrigin = origin.replace(/\/+$/, "");
25
+ return `${cleanOrigin}/pay#m=${bs58encode(metaPubBytes)}`;
26
+ }
27
+
28
+ /**
29
+ * Parse a NoTrace pay-link and return the recipient meta-pub.
30
+ * Returns `null` for any input that isn't a parseable NoTrace pay-link.
31
+ */
32
+ export function parsePayLink(url: string): Uint8Array | null {
33
+ let parsed: URL;
34
+ try {
35
+ parsed = new URL(url);
36
+ } catch {
37
+ return null;
38
+ }
39
+ // Accept both `#m=...` (fragment) and `?m=...` (query) for robustness.
40
+ const fragMatch = parsed.hash.match(/(?:^#|&)m=([1-9A-HJ-NP-Za-km-z]+)/);
41
+ const queryMatch = parsed.searchParams.get("m");
42
+ const encoded = fragMatch?.[1] ?? queryMatch;
43
+ if (!encoded) return null;
44
+ try {
45
+ const bytes = bs58decode(encoded);
46
+ return bytes.length === 32 ? bytes : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
package/src/scan.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Memo-program scanner — find incoming stealth payments addressed to you.
3
+ *
4
+ * Standalone (no `@solana/web3.js` dependency at type-level): the scanner takes
5
+ * a small `RpcLike` interface so it composes with any RPC client — a real
6
+ * `Connection` from web3.js, a mock for tests, or a thin HTTP wrapper.
7
+ *
8
+ * Algorithm per scanned signature:
9
+ *
10
+ * 1. Fetch the parsed tx.
11
+ * 2. Find a Memo-program instruction; extract the memo string.
12
+ * 3. `parseMemo(...)` → 32-byte `eph_pub` (skip if not an `nt1:` memo).
13
+ * 4. `recoverStealth(meta_scalar, meta_pub, eph_pub)` → expected `stealth_pub`.
14
+ * 5. Look for a SystemProgram `transfer` whose `destination` matches; if so,
15
+ * it's a payment to you.
16
+ */
17
+
18
+ import { bs58decode, bs58encode } from "./base58.js";
19
+ import type { MetaKey } from "./keys.js";
20
+ import { MEMO_PROGRAM_ID, parseMemo } from "./memo.js";
21
+ import { recoverStealth } from "./stealth.js";
22
+
23
+ /** Minimal shape we need from an RPC client. */
24
+ export interface RpcLike {
25
+ /**
26
+ * Return signatures touching `address`, newest first. Slot/blockTime are
27
+ * optional but recommended for ordering and display.
28
+ */
29
+ getSignaturesForAddress(
30
+ address: string,
31
+ opts?: { limit?: number; before?: string; until?: string }
32
+ ): Promise<Array<{ signature: string; slot?: number; blockTime?: number | null }>>;
33
+
34
+ /**
35
+ * Return a parsed transaction. The shape matches web3.js's
36
+ * `getParsedTransaction` for the fields the scanner actually reads.
37
+ */
38
+ getParsedTransaction(
39
+ signature: string,
40
+ opts?: { maxSupportedTransactionVersion?: number; commitment?: string }
41
+ ): Promise<ParsedTransactionLike | null>;
42
+ }
43
+
44
+ /** Minimal parsed-tx shape required by the scanner. */
45
+ export interface ParsedTransactionLike {
46
+ slot?: number;
47
+ blockTime?: number | null;
48
+ meta?: { err?: unknown } | null;
49
+ transaction: {
50
+ message: {
51
+ instructions: Array<ParsedInstructionLike>;
52
+ };
53
+ };
54
+ }
55
+
56
+ export interface ParsedInstructionLike {
57
+ programId?: { toString(): string } | string;
58
+ /** Memo program may surface the memo here as a string. */
59
+ parsed?:
60
+ | string
61
+ | {
62
+ type?: string;
63
+ info?: { destination?: string; source?: string; lamports?: number } | string;
64
+ };
65
+ /** Raw memo bytes (base58-encoded) if the program is not parsed. */
66
+ data?: string;
67
+ }
68
+
69
+ /** A successfully-matched stealth payment. */
70
+ export interface StealthPayment {
71
+ /** Transaction signature on Solana. */
72
+ signature: string;
73
+ /** Slot the tx landed in (if the RPC returned it). */
74
+ slot?: number;
75
+ /** Block timestamp in **ms** since epoch (or `null` if RPC didn't supply). */
76
+ blockTimeMs?: number | null;
77
+ /** Lamports received at the stealth address. */
78
+ lamports: number;
79
+ /** Source wallet (the payer). */
80
+ source: string;
81
+ /** Stealth address (base58). */
82
+ stealthPub: string;
83
+ /** Secret stealth scalar — needed to sign sweeps. Treat as private key. */
84
+ stealthScalar: bigint;
85
+ /** Ephemeral pub from the memo (base58). */
86
+ ephPub: string;
87
+ }
88
+
89
+ export interface ScanOptions {
90
+ /** How many signatures to pull from the memo program per batch. Default 100. */
91
+ limit?: number;
92
+ /** Concurrent `getParsedTransaction` calls. Default 5 (RPC-friendly). */
93
+ concurrency?: number;
94
+ /** Cursor for pagination — fetch sigs older than this signature. */
95
+ before?: string;
96
+ /** Already-scanned signatures to skip. */
97
+ alreadyScanned?: Set<string>;
98
+ }
99
+
100
+ /**
101
+ * Scan the memo program for new stealth payments addressed to `meta`.
102
+ *
103
+ * Returns *only the matches*. Updates `alreadyScanned` (if provided) in-place
104
+ * so subsequent calls can resume without re-checking.
105
+ */
106
+ export async function scanForPayments(
107
+ meta: Pick<MetaKey, "scalar" | "pub">,
108
+ rpc: RpcLike,
109
+ opts: ScanOptions = {}
110
+ ): Promise<StealthPayment[]> {
111
+ const limit = opts.limit ?? 100;
112
+ const concurrency = Math.max(1, opts.concurrency ?? 5);
113
+ const seen = opts.alreadyScanned ?? new Set<string>();
114
+
115
+ const sigs = await rpc.getSignaturesForAddress(MEMO_PROGRAM_ID, {
116
+ limit,
117
+ ...(opts.before ? { before: opts.before } : {}),
118
+ });
119
+
120
+ const fresh = sigs.filter((s) => !seen.has(s.signature));
121
+ const matches: StealthPayment[] = [];
122
+
123
+ for (let i = 0; i < fresh.length; i += concurrency) {
124
+ const batch = fresh.slice(i, i + concurrency);
125
+ const results = await Promise.allSettled(
126
+ batch.map((s) => checkSignature(s.signature, meta, rpc))
127
+ );
128
+ for (let j = 0; j < results.length; j++) {
129
+ const r = results[j]!;
130
+ const sig = batch[j]!.signature;
131
+ seen.add(sig);
132
+ if (r.status === "fulfilled" && r.value) matches.push(r.value);
133
+ }
134
+ }
135
+
136
+ return matches;
137
+ }
138
+
139
+ /**
140
+ * Check a single signature for a stealth payment to `meta`. Returns the
141
+ * payment if matched, `null` otherwise. Exposed so callers can wire their own
142
+ * scanning loop (e.g. with subscribe/webhook flows instead of polling).
143
+ */
144
+ export async function checkSignature(
145
+ signature: string,
146
+ meta: Pick<MetaKey, "scalar" | "pub">,
147
+ rpc: RpcLike
148
+ ): Promise<StealthPayment | null> {
149
+ let tx: ParsedTransactionLike | null;
150
+ try {
151
+ tx = await rpc.getParsedTransaction(signature, {
152
+ maxSupportedTransactionVersion: 0,
153
+ commitment: "confirmed",
154
+ });
155
+ } catch {
156
+ return null;
157
+ }
158
+ if (!tx || tx.meta?.err) return null;
159
+
160
+ const instrs = tx.transaction.message.instructions || [];
161
+
162
+ // 1. Find the memo string.
163
+ let memoString: string | null = null;
164
+ for (const ix of instrs) {
165
+ const pid = typeof ix.programId === "string" ? ix.programId : ix.programId?.toString();
166
+ if (pid !== MEMO_PROGRAM_ID) continue;
167
+
168
+ if (typeof ix.parsed === "string") {
169
+ memoString = ix.parsed;
170
+ } else if (ix.parsed && typeof ix.parsed === "object" && typeof ix.parsed.info === "string") {
171
+ memoString = ix.parsed.info;
172
+ } else if (typeof ix.data === "string") {
173
+ try {
174
+ memoString = new TextDecoder().decode(bs58decode(ix.data));
175
+ } catch {
176
+ /* ignore unparseable memo */
177
+ }
178
+ }
179
+ if (memoString) break;
180
+ }
181
+ if (!memoString) return null;
182
+
183
+ const ephPub = parseMemo(memoString);
184
+ if (!ephPub) return null;
185
+
186
+ // 2. Recover the expected stealth address.
187
+ const { stealthScalar, stealthPub } = recoverStealth(meta.scalar, meta.pub, ephPub);
188
+ const expectedAddr = bs58encode(stealthPub);
189
+
190
+ // 3. Find a transfer to that address.
191
+ for (const ix of instrs) {
192
+ const parsed = ix.parsed;
193
+ if (!parsed || typeof parsed !== "object") continue;
194
+ if (parsed.type !== "transfer") continue;
195
+ const info = parsed.info;
196
+ if (!info || typeof info !== "object") continue;
197
+ if (info.destination !== expectedAddr) continue;
198
+
199
+ return {
200
+ signature,
201
+ slot: tx.slot,
202
+ blockTimeMs: tx.blockTime ? tx.blockTime * 1000 : null,
203
+ lamports: typeof info.lamports === "number" ? info.lamports : 0,
204
+ source: info.source ?? "",
205
+ stealthPub: expectedAddr,
206
+ stealthScalar,
207
+ ephPub: bs58encode(ephPub),
208
+ };
209
+ }
210
+
211
+ return null;
212
+ }
package/src/sign.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * ed25519 signing with a raw scalar (not a seed).
3
+ *
4
+ * Standard ed25519 sign-from-seed first hashes the seed and uses the upper half
5
+ * as a deterministic nonce. Our stealth scalars are *derived*, not seeded — so
6
+ * we use a Schnorr-style construction directly:
7
+ *
8
+ * r = SHA512(prefix ‖ pub ‖ msg) mod L where prefix is 32 fresh bytes
9
+ * R = r × G
10
+ * k = SHA512(R ‖ pub ‖ msg) mod L
11
+ * S = (r + k × scalar) mod L
12
+ * sig = R ‖ S (64 bytes, verifiable as ed25519)
13
+ *
14
+ * The random `prefix` is a hedge against bad randomness leaking the scalar via
15
+ * a colliding `r`. In normal NoTrace use each stealth scalar signs at most one
16
+ * sweep, so even non-hedged would be safe — we hedge anyway.
17
+ */
18
+
19
+ import { ed25519 } from "@noble/curves/ed25519";
20
+ import { sha512 } from "@noble/hashes/sha2";
21
+ import { randomBytes } from "@noble/hashes/utils";
22
+ import { bytesToNumberLE, concatBytes, numberToBytesLE } from "./bytes.js";
23
+
24
+ const Point = ed25519.ExtendedPoint;
25
+ const L = ed25519.CURVE.n;
26
+
27
+ function modL(n: bigint): bigint {
28
+ let r = n % L;
29
+ if (r < 0n) r += L;
30
+ return r;
31
+ }
32
+
33
+ function hash512ModL(input: Uint8Array): bigint {
34
+ return modL(bytesToNumberLE(sha512(input)));
35
+ }
36
+
37
+ /**
38
+ * Produce a 64-byte ed25519 signature using a raw scalar.
39
+ *
40
+ * The resulting signature verifies against `pub = scalar × G` under the
41
+ * standard ed25519 verify algorithm (e.g. `ed25519.verify` in @noble/curves,
42
+ * or Solana's runtime).
43
+ */
44
+ export function signWithScalar(
45
+ scalar: bigint,
46
+ pubBytes: Uint8Array,
47
+ message: Uint8Array
48
+ ): Uint8Array {
49
+ const prefix = randomBytes(32);
50
+ const r = hash512ModL(concatBytes(prefix, pubBytes, message));
51
+ const R = Point.BASE.multiply(r).toRawBytes();
52
+ const k = hash512ModL(concatBytes(R, pubBytes, message));
53
+ const S = modL(r + k * scalar);
54
+ return concatBytes(R, numberToBytesLE(S, 32));
55
+ }
56
+
57
+ /** Re-export `ed25519.verify` for callers that don't want to import @noble directly. */
58
+ export function verify(
59
+ sig: Uint8Array,
60
+ message: Uint8Array,
61
+ pubBytes: Uint8Array
62
+ ): boolean {
63
+ return ed25519.verify(sig, message, pubBytes);
64
+ }
package/src/stealth.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Stealth-address derivation and recovery.
3
+ *
4
+ * The protocol is ECDH-on-ed25519 with a scalar-tweak — the same shape used by
5
+ * ERC-5564 and Umbra Cash, ported to Solana's native curve. Properties:
6
+ *
7
+ * - Sender derives a stealth address from the recipient's published `meta_pub`.
8
+ * - Sender CANNOT recover the stealth private key (would need `meta_scalar`).
9
+ * - Recipient scans memos and applies their `meta_scalar` to detect payments.
10
+ * - The stealth pub is a normal ed25519 point → a valid Solana address.
11
+ *
12
+ * The "version tag" included in the tweak makes the protocol forward-compatible:
13
+ * a future curve swap or hash change can ship a new tag without breaking
14
+ * existing wallets.
15
+ */
16
+
17
+ import { ed25519 } from "@noble/curves/ed25519";
18
+ import { sha512 } from "@noble/hashes/sha2";
19
+ import { randomBytes } from "@noble/hashes/utils";
20
+ import { bytesToNumberLE, concatBytes } from "./bytes.js";
21
+ import { metaFromSeed } from "./keys.js";
22
+
23
+ const Point = ed25519.ExtendedPoint;
24
+ const L = ed25519.CURVE.n;
25
+
26
+ const VERSION_TAG = new TextEncoder().encode("notrace.v1.stealth");
27
+
28
+ function modL(n: bigint): bigint {
29
+ let r = n % L;
30
+ if (r < 0n) r += L;
31
+ return r;
32
+ }
33
+
34
+ function hash512ModL(input: Uint8Array): bigint {
35
+ return modL(bytesToNumberLE(sha512(input)));
36
+ }
37
+
38
+ /** Result of a sender-side derivation. */
39
+ export interface SenderDerivation {
40
+ /** The one-time stealth address (32-byte ed25519 pub) to send funds to. */
41
+ stealthPub: Uint8Array;
42
+ /** The ephemeral pub the sender must publish on-chain (e.g. in a memo). */
43
+ ephPub: Uint8Array;
44
+ }
45
+
46
+ /**
47
+ * SENDER side: derive a one-time stealth address from the recipient's meta-pub.
48
+ *
49
+ * eph_scalar, eph_pub = fresh ed25519 keypair
50
+ * shared = eph_scalar × meta_pub (DH on ed25519)
51
+ * tweak = SHA512(ver ‖ shared ‖ eph_pub ‖ meta_pub) mod L
52
+ * stealth_pub = meta_pub + tweak × G (point addition)
53
+ *
54
+ * The sender publishes `eph_pub`; only the holder of `meta_scalar` can both
55
+ * recover `stealth_scalar` and produce signatures over it.
56
+ *
57
+ * Safe to call from a hostile environment — no state is persisted.
58
+ */
59
+ export function deriveStealthSender(metaPubBytes: Uint8Array): SenderDerivation {
60
+ if (metaPubBytes.length !== 32) {
61
+ throw new Error(`deriveStealthSender: meta_pub must be 32 bytes (got ${metaPubBytes.length})`);
62
+ }
63
+
64
+ // Fresh ephemeral keypair via the same clamping path as the meta-key.
65
+ const eph = metaFromSeed(randomBytes(32));
66
+
67
+ // DH on ed25519
68
+ const metaPt = Point.fromHex(metaPubBytes);
69
+ const sharedBytes = metaPt.multiply(eph.scalar).toRawBytes();
70
+
71
+ // Tweak. Include all four inputs so the tweak is bound to this exact pairing.
72
+ const tweak = hash512ModL(
73
+ concatBytes(VERSION_TAG, sharedBytes, eph.pub, metaPubBytes)
74
+ );
75
+
76
+ // stealth_pub = meta_pub + tweak * G
77
+ const stealthPt = metaPt.add(Point.BASE.multiply(tweak));
78
+ const stealthPub = stealthPt.toRawBytes();
79
+
80
+ return { stealthPub, ephPub: eph.pub };
81
+ }
82
+
83
+ /** Result of a recipient-side recovery — pub matches what the sender derived. */
84
+ export interface RecipientRecovery {
85
+ /** The recovered scalar — secret. Use this to sign sweeps. */
86
+ stealthScalar: bigint;
87
+ /** The recovered pub — compare against the on-chain destination. */
88
+ stealthPub: Uint8Array;
89
+ }
90
+
91
+ /**
92
+ * RECIPIENT side: given your meta-key and an ephemeral pub from a memo, recover
93
+ * the stealth keypair the sender derived.
94
+ *
95
+ * shared = meta_scalar × eph_pub (same point as sender's)
96
+ * tweak = SHA512(ver ‖ shared ‖ eph_pub ‖ meta_pub) mod L
97
+ * stealth_scalar = (meta_scalar + tweak) mod L
98
+ * stealth_pub = stealth_scalar × G
99
+ *
100
+ * Compare the returned `stealthPub` against the SOL/SPL `destination` field of
101
+ * the transaction containing the memo. A match means the payment is yours.
102
+ */
103
+ export function recoverStealth(
104
+ metaScalar: bigint,
105
+ metaPubBytes: Uint8Array,
106
+ ephPubBytes: Uint8Array
107
+ ): RecipientRecovery {
108
+ if (ephPubBytes.length !== 32) {
109
+ throw new Error(`recoverStealth: eph_pub must be 32 bytes (got ${ephPubBytes.length})`);
110
+ }
111
+ if (metaPubBytes.length !== 32) {
112
+ throw new Error(`recoverStealth: meta_pub must be 32 bytes (got ${metaPubBytes.length})`);
113
+ }
114
+
115
+ const ephPt = Point.fromHex(ephPubBytes);
116
+ const sharedBytes = ephPt.multiply(metaScalar).toRawBytes();
117
+
118
+ const tweak = hash512ModL(
119
+ concatBytes(VERSION_TAG, sharedBytes, ephPubBytes, metaPubBytes)
120
+ );
121
+ const stealthScalar = modL(metaScalar + tweak);
122
+ const stealthPub = Point.BASE.multiply(stealthScalar).toRawBytes();
123
+
124
+ return { stealthScalar, stealthPub };
125
+ }