@utxopia/sdk 0.1.0-alpha.2 → 0.1.0-alpha.3

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.
Files changed (142) hide show
  1. package/README.md +214 -108
  2. package/{packages/sdk/dist → dist}/client.d.ts +25 -1
  3. package/{packages/sdk/dist → dist}/client.js +36 -1
  4. package/{packages/sdk/dist → dist}/crypto-ed25519.d.ts +14 -0
  5. package/{packages/sdk/dist → dist}/crypto-ed25519.js +14 -0
  6. package/{packages/sdk/dist → dist}/index.d.ts +3 -3
  7. package/{packages/sdk/dist → dist}/index.js +3 -3
  8. package/{packages/sdk/dist → dist}/instructions.d.ts +28 -11
  9. package/{packages/sdk/dist → dist}/instructions.js +45 -15
  10. package/{packages/sdk/dist → dist}/stealth.d.ts +104 -9
  11. package/{packages/sdk/dist → dist}/stealth.js +146 -14
  12. package/{packages/sdk/dist → dist}/taproot.d.ts +39 -2
  13. package/{packages/sdk/dist → dist}/taproot.js +54 -2
  14. package/package.json +86 -63
  15. package/src/announcement-client.ts +457 -0
  16. package/src/auditor-ciphertext.ts +181 -0
  17. package/src/auditor.ts +409 -0
  18. package/src/bitcoin/ika.ts +103 -0
  19. package/src/bitcoin/index.ts +5 -0
  20. package/src/bound-params.ts +322 -0
  21. package/src/chadbuffer.ts +603 -0
  22. package/src/circomlibjs.d.ts +51 -0
  23. package/src/claim-link.ts +53 -0
  24. package/src/client.ts +638 -0
  25. package/src/commitment-tree.ts +736 -0
  26. package/src/config.ts +772 -0
  27. package/src/core/esplora.ts +332 -0
  28. package/src/core/mempool.ts +159 -0
  29. package/src/crypto-babyjub.ts +385 -0
  30. package/src/crypto-ed25519.ts +297 -0
  31. package/src/crypto.ts +199 -0
  32. package/src/event-client.ts +231 -0
  33. package/src/events.ts +384 -0
  34. package/src/explorer.ts +300 -0
  35. package/src/index.ts +902 -0
  36. package/src/instructions.ts +2820 -0
  37. package/src/keys.ts +1228 -0
  38. package/src/logger.ts +41 -0
  39. package/src/magicblock.ts +278 -0
  40. package/src/merkle.ts +197 -0
  41. package/src/note.ts +754 -0
  42. package/src/pda.ts +516 -0
  43. package/src/pool-state.ts +176 -0
  44. package/src/poseidon.ts +175 -0
  45. package/src/prover/index.ts +19 -0
  46. package/src/prover/mobile.ts +303 -0
  47. package/src/prover/web.ts +771 -0
  48. package/src/psbt.ts +333 -0
  49. package/src/selective-disclosure.ts +284 -0
  50. package/src/sender-memo.ts +343 -0
  51. package/src/snarkjs.d.ts +19 -0
  52. package/src/sns-resolver.ts +333 -0
  53. package/src/solana/connection.ts +189 -0
  54. package/src/solana/priority-fee.ts +201 -0
  55. package/src/spend-doc.ts +163 -0
  56. package/src/stealth.ts +1477 -0
  57. package/src/taproot.ts +707 -0
  58. package/src/token-registry.ts +207 -0
  59. package/src/utils/encoding.ts +33 -0
  60. package/src/vk-registry.ts +295 -0
  61. package/LICENSE +0 -21
  62. package/packages/btc-client/src/esplora-client.ts +0 -153
  63. package/packages/btc-client/src/index.ts +0 -3
  64. package/packages/btc-client/src/op-return.ts +0 -93
  65. package/packages/btc-client/src/types.ts +0 -112
  66. package/packages/sdk/README.md +0 -277
  67. /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
  68. /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
  69. /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
  70. /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
  71. /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
  72. /package/{packages/sdk/dist → dist}/auditor.js +0 -0
  73. /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
  74. /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
  75. /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
  76. /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
  77. /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
  78. /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
  79. /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
  80. /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
  81. /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
  82. /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
  83. /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
  84. /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
  85. /package/{packages/sdk/dist → dist}/config.d.ts +0 -0
  86. /package/{packages/sdk/dist → dist}/config.js +0 -0
  87. /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
  88. /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
  89. /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
  90. /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
  91. /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
  92. /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
  93. /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
  94. /package/{packages/sdk/dist → dist}/crypto.js +0 -0
  95. /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
  96. /package/{packages/sdk/dist → dist}/event-client.js +0 -0
  97. /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
  98. /package/{packages/sdk/dist → dist}/events.js +0 -0
  99. /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
  100. /package/{packages/sdk/dist → dist}/explorer.js +0 -0
  101. /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
  102. /package/{packages/sdk/dist → dist}/keys.js +0 -0
  103. /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
  104. /package/{packages/sdk/dist → dist}/logger.js +0 -0
  105. /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
  106. /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
  107. /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
  108. /package/{packages/sdk/dist → dist}/merkle.js +0 -0
  109. /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
  110. /package/{packages/sdk/dist → dist}/note.js +0 -0
  111. /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
  112. /package/{packages/sdk/dist → dist}/pda.js +0 -0
  113. /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
  114. /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
  115. /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
  116. /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
  117. /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
  118. /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
  119. /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
  120. /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
  121. /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
  122. /package/{packages/sdk/dist → dist}/prover/web.js +0 -0
  123. /package/{packages/sdk/dist → dist}/psbt.d.ts +0 -0
  124. /package/{packages/sdk/dist → dist}/psbt.js +0 -0
  125. /package/{packages/sdk/dist → dist}/selective-disclosure.d.ts +0 -0
  126. /package/{packages/sdk/dist → dist}/selective-disclosure.js +0 -0
  127. /package/{packages/sdk/dist → dist}/sender-memo.d.ts +0 -0
  128. /package/{packages/sdk/dist → dist}/sender-memo.js +0 -0
  129. /package/{packages/sdk/dist → dist}/sns-resolver.d.ts +0 -0
  130. /package/{packages/sdk/dist → dist}/sns-resolver.js +0 -0
  131. /package/{packages/sdk/dist → dist}/solana/connection.d.ts +0 -0
  132. /package/{packages/sdk/dist → dist}/solana/connection.js +0 -0
  133. /package/{packages/sdk/dist → dist}/solana/priority-fee.d.ts +0 -0
  134. /package/{packages/sdk/dist → dist}/solana/priority-fee.js +0 -0
  135. /package/{packages/sdk/dist → dist}/spend-doc.d.ts +0 -0
  136. /package/{packages/sdk/dist → dist}/spend-doc.js +0 -0
  137. /package/{packages/sdk/dist → dist}/token-registry.d.ts +0 -0
  138. /package/{packages/sdk/dist → dist}/token-registry.js +0 -0
  139. /package/{packages/sdk/dist → dist}/utils/encoding.d.ts +0 -0
  140. /package/{packages/sdk/dist → dist}/utils/encoding.js +0 -0
  141. /package/{packages/sdk/dist → dist}/vk-registry.d.ts +0 -0
  142. /package/{packages/sdk/dist → dist}/vk-registry.js +0 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Sender memo channel (Phase 2 — regulator-grade tamper detection)
3
+ *
4
+ * Outputs of a `transact` call are encrypted to the *recipient's* viewing
5
+ * key, so the sender can't recover their own outgoing history from chain
6
+ * data alone. Sender memos close that gap: an opt-in second event per
7
+ * output, encrypted with a derived **outgoing viewing key** (`ovk`) so the
8
+ * sender (or an auditor holding the right key material) can later
9
+ * reconstruct what was sent.
10
+ *
11
+ * ## Cipher: XChaCha20-Poly1305 AEAD
12
+ *
13
+ * Chosen over plain XOR for **regulator-grade tamper detection**: the
14
+ * Poly1305 tag fails crisply on any bit flip in the ciphertext, the nonce,
15
+ * the key, or the AAD. With 24-byte random nonces collisions are
16
+ * astronomically unlikely, so callers don't have to coordinate uniqueness.
17
+ * Same family as Railgun / Zcash Sapling note encryption.
18
+ *
19
+ * `ovk = SHA-256(viewingPrivKey || "utxopia.ovk.v1")` — Sapling-style
20
+ * outgoing viewing key. Currently a one-way derivation from
21
+ * `viewingPrivKey`; a v2 refactor will derive both `ivk` and `ovk` from a
22
+ * master `mvk` so the two can be granted independently for true
23
+ * incoming/outgoing privilege separation.
24
+ *
25
+ * ## Wire format (matches `emit_sender_memo` in `events.rs`)
26
+ *
27
+ * nonce :: 24 bytes (random, fresh per memo — XChaCha extended)
28
+ * ciphertext_and_tag :: 56 bytes (encrypted tokenId(32) || amount(8) + Poly1305 tag(16))
29
+ * commitment :: 32 bytes (plaintext — join key, also AAD)
30
+ * leaf_index_le :: 4 bytes (plaintext — same)
31
+ * ─────────────────────────────────
32
+ * total :: 116 bytes
33
+ *
34
+ * The AAD `commitment || leafIndex_LE` binds the seal to the memo's tree
35
+ * leaf. An attacker who lifts a memo onto a different output → AAD
36
+ * mismatch → tag fails → decryption returns null. Crisp move-the-memo
37
+ * protection, unlike the probabilistic version we had with XOR.
38
+ */
39
+
40
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
41
+ import { sha256 } from "@noble/hashes/sha2.js";
42
+
43
+ /** Bytes of the XChaCha20 24-byte nonce. */
44
+ export const SENDER_MEMO_NONCE_BYTES = 24;
45
+ /** Bytes of the encrypted ciphertext + Poly1305 tag (token(32) + amount(8) + tag(16)). */
46
+ export const SENDER_MEMO_CIPHERTEXT_BYTES = 56;
47
+ /** Plaintext token-id bytes inside the ciphertext. */
48
+ export const SENDER_MEMO_TOKEN_BYTES = 32;
49
+ /** Plaintext amount bytes inside the ciphertext. */
50
+ export const SENDER_MEMO_AMOUNT_BYTES = 8;
51
+ /** Bytes of the Poly1305 tag. */
52
+ export const SENDER_MEMO_TAG_BYTES = 16;
53
+ /** Bytes of the commitment field (plaintext, AAD). */
54
+ export const SENDER_MEMO_COMMITMENT_BYTES = 32;
55
+ /** Bytes of the leaf-index field (plaintext LE u32, AAD). */
56
+ export const SENDER_MEMO_LEAF_INDEX_BYTES = 4;
57
+ /** Total bytes of a packed sender memo. */
58
+ export const SENDER_MEMO_PACKED_BYTES =
59
+ SENDER_MEMO_NONCE_BYTES +
60
+ SENDER_MEMO_CIPHERTEXT_BYTES +
61
+ SENDER_MEMO_COMMITMENT_BYTES +
62
+ SENDER_MEMO_LEAF_INDEX_BYTES;
63
+
64
+ /** Domain separator for ovk derivation. */
65
+ const OVK_DOMAIN = new TextEncoder().encode("utxopia.ovk.v1");
66
+
67
+ /** Plaintext memo. */
68
+ export interface SenderMemoPlain {
69
+ tokenId: bigint;
70
+ amount: bigint;
71
+ }
72
+
73
+ /** Encrypted memo with associated chain context. */
74
+ export interface SenderMemoCiphertext {
75
+ /** 24-byte XChaCha20 nonce. */
76
+ nonce: Uint8Array;
77
+ /** 56-byte ChaCha20-encrypted (tokenId(32) || amount(8)) + Poly1305 tag(16). */
78
+ ciphertextWithTag: Uint8Array;
79
+ /** 32-byte commitment of the output this memo covers (plaintext + AAD). */
80
+ commitment: Uint8Array;
81
+ /** Leaf index of the covered output (plaintext + AAD). */
82
+ leafIndex: number;
83
+ }
84
+
85
+ /**
86
+ * Derive the outgoing viewing key (`ovk`) from a viewing private key.
87
+ *
88
+ * ovk = SHA-256(viewingPrivKey || "utxopia.ovk.v1")
89
+ *
90
+ * Currently derivable from `viewingPrivKey`, so possession of the viewing
91
+ * key implies possession of `ovk`. A future v2 refactor will derive `ivk`
92
+ * and `ovk` independently from a master `mvk`, allowing true incoming-only
93
+ * vs outgoing-only audit delegation.
94
+ */
95
+ export function deriveOutgoingViewingKey(viewingPrivKey: Uint8Array): Uint8Array {
96
+ if (viewingPrivKey.length !== 32) {
97
+ throw new Error(`viewingPrivKey must be 32 bytes; got ${viewingPrivKey.length}`);
98
+ }
99
+ const buf = new Uint8Array(viewingPrivKey.length + OVK_DOMAIN.length);
100
+ buf.set(viewingPrivKey, 0);
101
+ buf.set(OVK_DOMAIN, viewingPrivKey.length);
102
+ return sha256(buf);
103
+ }
104
+
105
+ /**
106
+ * Generate a fresh 24-byte XChaCha20 nonce. Exposed so callers can
107
+ * pre-allocate nonces if they need deterministic ordering across outputs.
108
+ */
109
+ export function generateSenderMemoNonce(): Uint8Array {
110
+ const nonce = new Uint8Array(SENDER_MEMO_NONCE_BYTES);
111
+ crypto.getRandomValues(nonce);
112
+ return nonce;
113
+ }
114
+
115
+ /**
116
+ * Encrypt a sender memo. The returned ciphertext is meant to be embedded
117
+ * in the program's instruction data alongside the recipient announcement,
118
+ * then emitted on-chain via `emit_sender_memo`.
119
+ */
120
+ export function encryptSenderMemo(
121
+ viewingPrivKey: Uint8Array,
122
+ plain: SenderMemoPlain,
123
+ ctx: { commitment: Uint8Array; leafIndex: number },
124
+ nonce: Uint8Array = generateSenderMemoNonce(),
125
+ ): SenderMemoCiphertext {
126
+ if (nonce.length !== SENDER_MEMO_NONCE_BYTES) {
127
+ throw new Error(`nonce must be ${SENDER_MEMO_NONCE_BYTES} bytes`);
128
+ }
129
+ if (ctx.commitment.length !== SENDER_MEMO_COMMITMENT_BYTES) {
130
+ throw new Error(`commitment must be ${SENDER_MEMO_COMMITMENT_BYTES} bytes`);
131
+ }
132
+
133
+ const ovk = deriveOutgoingViewingKey(viewingPrivKey);
134
+ const aad = buildAAD(ctx.commitment, ctx.leafIndex);
135
+ const plaintext = packPlaintext(plain);
136
+
137
+ const aead = xchacha20poly1305(ovk, nonce, aad);
138
+ const ciphertextWithTag = aead.encrypt(plaintext);
139
+
140
+ if (ciphertextWithTag.length !== SENDER_MEMO_CIPHERTEXT_BYTES) {
141
+ throw new Error(
142
+ `internal error: AEAD produced ${ciphertextWithTag.length} bytes, expected ${SENDER_MEMO_CIPHERTEXT_BYTES}`,
143
+ );
144
+ }
145
+
146
+ return {
147
+ nonce: new Uint8Array(nonce),
148
+ ciphertextWithTag,
149
+ commitment: new Uint8Array(ctx.commitment),
150
+ leafIndex: ctx.leafIndex,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Decrypt a sender memo. Returns `null` when the Poly1305 tag fails
156
+ * (wrong key, tampered ciphertext, swapped AAD) or the ciphertext is
157
+ * structurally malformed. Callers should treat both as "not for me / corrupt"
158
+ * rather than fatal — this is the crisp tamper-detection signal AEAD provides.
159
+ */
160
+ export function decryptSenderMemo(
161
+ viewingPrivKey: Uint8Array,
162
+ memo: SenderMemoCiphertext,
163
+ ): SenderMemoPlain | null {
164
+ if (
165
+ memo.nonce.length !== SENDER_MEMO_NONCE_BYTES ||
166
+ memo.ciphertextWithTag.length !== SENDER_MEMO_CIPHERTEXT_BYTES ||
167
+ memo.commitment.length !== SENDER_MEMO_COMMITMENT_BYTES
168
+ ) {
169
+ return null;
170
+ }
171
+
172
+ const ovk = deriveOutgoingViewingKey(viewingPrivKey);
173
+ const aad = buildAAD(memo.commitment, memo.leafIndex);
174
+
175
+ let plaintext: Uint8Array;
176
+ try {
177
+ const aead = xchacha20poly1305(ovk, memo.nonce, aad);
178
+ plaintext = aead.decrypt(memo.ciphertextWithTag);
179
+ } catch {
180
+ return null; // tag failure → wrong key, corrupted ciphertext, or AAD mismatch
181
+ }
182
+ return unpackPlaintext(plaintext);
183
+ }
184
+
185
+ /**
186
+ * Serialize a memo as its 116-byte on-chain payload.
187
+ *
188
+ * Layout: nonce(24) ‖ ciphertextWithTag(56) ‖ commitment(32) ‖ leafIndex(4)
189
+ */
190
+ export function packSenderMemo(memo: SenderMemoCiphertext): Uint8Array {
191
+ const out = new Uint8Array(SENDER_MEMO_PACKED_BYTES);
192
+ let off = 0;
193
+ out.set(memo.nonce, off);
194
+ off += SENDER_MEMO_NONCE_BYTES;
195
+ out.set(memo.ciphertextWithTag, off);
196
+ off += SENDER_MEMO_CIPHERTEXT_BYTES;
197
+ out.set(memo.commitment, off);
198
+ off += SENDER_MEMO_COMMITMENT_BYTES;
199
+ new DataView(out.buffer, out.byteOffset + off, SENDER_MEMO_LEAF_INDEX_BYTES).setUint32(
200
+ 0,
201
+ memo.leafIndex,
202
+ true,
203
+ );
204
+ return out;
205
+ }
206
+
207
+ /**
208
+ * Pack a memo into the 80-byte instruction-data layout that `transact` reads:
209
+ * `nonce(24) || ciphertext_and_tag(56)`. `commitment` and `leafIndex` are
210
+ * filled in by the program from the public commitments + tree insertion
211
+ * result, so they're omitted from the instruction-data form.
212
+ */
213
+ export function packSenderMemoForInstruction(memo: SenderMemoCiphertext): Uint8Array {
214
+ const out = new Uint8Array(SENDER_MEMO_NONCE_BYTES + SENDER_MEMO_CIPHERTEXT_BYTES);
215
+ out.set(memo.nonce, 0);
216
+ out.set(memo.ciphertextWithTag, SENDER_MEMO_NONCE_BYTES);
217
+ return out;
218
+ }
219
+
220
+ /**
221
+ * Per-output input for {@link buildSenderMemosForTransact}.
222
+ *
223
+ * - `tokenId` / `amount` are the plaintext payload that lands in the memo.
224
+ * - `commitment` is the 32-byte output commitment for this leaf — must match
225
+ * exactly what the program inserts (it's AAD).
226
+ * - `leafIndex` is the predicted on-chain leaf index for this output. Get it
227
+ * by reading the commitment tree's `next_leaf_index` before signing and
228
+ * incrementing for each output in order. If the tx races with another
229
+ * `transact` that inserts first, decryption will fail (the AAD won't
230
+ * match) — callers should be prepared to skip the failed memo on read
231
+ * rather than treat it as fatal.
232
+ */
233
+ export interface SenderMemoOutput {
234
+ tokenId: bigint;
235
+ amount: bigint;
236
+ commitment: Uint8Array;
237
+ leafIndex: number;
238
+ }
239
+
240
+ /**
241
+ * Build the per-output 80-byte sender-memo slices for a `transact` call.
242
+ *
243
+ * Returns one `Uint8Array` per output, each containing
244
+ * `nonce(24) || ciphertext_and_tag(56)`. Pass the result directly to
245
+ * {@link buildTransactInstructionData} as `senderMemos`.
246
+ *
247
+ * The program emits the commitment + leaf index back as part of
248
+ * `emit_sender_memo`, so they're omitted from the instruction-data form —
249
+ * but they remain bound via AAD, so a relayer can't lift a memo onto a
250
+ * different output.
251
+ */
252
+ export function buildSenderMemosForTransact(
253
+ viewingPrivKey: Uint8Array,
254
+ outputs: ReadonlyArray<SenderMemoOutput>,
255
+ ): Uint8Array[] {
256
+ const out: Uint8Array[] = new Array(outputs.length);
257
+ for (let i = 0; i < outputs.length; i++) {
258
+ const o = outputs[i];
259
+ if (o.commitment.length !== SENDER_MEMO_COMMITMENT_BYTES) {
260
+ throw new Error(
261
+ `output ${i} commitment must be ${SENDER_MEMO_COMMITMENT_BYTES} bytes; got ${o.commitment.length}`,
262
+ );
263
+ }
264
+ if (!Number.isInteger(o.leafIndex) || o.leafIndex < 0 || o.leafIndex > 0xffffffff) {
265
+ throw new RangeError(`output ${i} leafIndex must be a u32; got ${o.leafIndex}`);
266
+ }
267
+ const memo = encryptSenderMemo(
268
+ viewingPrivKey,
269
+ { tokenId: o.tokenId, amount: o.amount },
270
+ { commitment: o.commitment, leafIndex: o.leafIndex },
271
+ );
272
+ out[i] = packSenderMemoForInstruction(memo);
273
+ }
274
+ return out;
275
+ }
276
+
277
+ /** Deserialize a 116-byte packed memo. */
278
+ export function unpackSenderMemo(bytes: Uint8Array): SenderMemoCiphertext {
279
+ if (bytes.length !== SENDER_MEMO_PACKED_BYTES) {
280
+ throw new Error(
281
+ `packed sender memo must be ${SENDER_MEMO_PACKED_BYTES} bytes; got ${bytes.length}`,
282
+ );
283
+ }
284
+ let off = 0;
285
+ const nonce = bytes.slice(off, off + SENDER_MEMO_NONCE_BYTES);
286
+ off += SENDER_MEMO_NONCE_BYTES;
287
+ const ciphertextWithTag = bytes.slice(off, off + SENDER_MEMO_CIPHERTEXT_BYTES);
288
+ off += SENDER_MEMO_CIPHERTEXT_BYTES;
289
+ const commitment = bytes.slice(off, off + SENDER_MEMO_COMMITMENT_BYTES);
290
+ off += SENDER_MEMO_COMMITMENT_BYTES;
291
+ const leafIndex = new DataView(
292
+ bytes.buffer,
293
+ bytes.byteOffset + off,
294
+ SENDER_MEMO_LEAF_INDEX_BYTES,
295
+ ).getUint32(0, true);
296
+ return { nonce, ciphertextWithTag, commitment, leafIndex };
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Internal helpers
301
+ // ---------------------------------------------------------------------------
302
+
303
+ function buildAAD(commitment: Uint8Array, leafIndex: number): Uint8Array {
304
+ const aad = new Uint8Array(
305
+ SENDER_MEMO_COMMITMENT_BYTES + SENDER_MEMO_LEAF_INDEX_BYTES,
306
+ );
307
+ aad.set(commitment, 0);
308
+ new DataView(
309
+ aad.buffer,
310
+ aad.byteOffset + SENDER_MEMO_COMMITMENT_BYTES,
311
+ SENDER_MEMO_LEAF_INDEX_BYTES,
312
+ ).setUint32(0, leafIndex, true);
313
+ return aad;
314
+ }
315
+
316
+ function packPlaintext(plain: SenderMemoPlain): Uint8Array {
317
+ const out = new Uint8Array(SENDER_MEMO_TOKEN_BYTES + SENDER_MEMO_AMOUNT_BYTES);
318
+ // tokenId big-endian in first 32 bytes
319
+ let t = plain.tokenId;
320
+ for (let i = SENDER_MEMO_TOKEN_BYTES - 1; i >= 0; i--) {
321
+ out[i] = Number(t & 0xffn);
322
+ t >>= 8n;
323
+ }
324
+ if (t !== 0n) throw new RangeError("tokenId exceeds 256 bits");
325
+ // amount little-endian u64 in last 8 bytes
326
+ let a = plain.amount;
327
+ for (let i = 0; i < SENDER_MEMO_AMOUNT_BYTES; i++) {
328
+ out[SENDER_MEMO_TOKEN_BYTES + i] = Number(a & 0xffn);
329
+ a >>= 8n;
330
+ }
331
+ if (a !== 0n) throw new RangeError("amount exceeds 64 bits");
332
+ return out;
333
+ }
334
+
335
+ function unpackPlaintext(bytes: Uint8Array): SenderMemoPlain {
336
+ let tokenId = 0n;
337
+ for (let i = 0; i < SENDER_MEMO_TOKEN_BYTES; i++) tokenId = (tokenId << 8n) | BigInt(bytes[i]);
338
+ let amount = 0n;
339
+ for (let i = SENDER_MEMO_AMOUNT_BYTES - 1; i >= 0; i--) {
340
+ amount = (amount << 8n) | BigInt(bytes[SENDER_MEMO_TOKEN_BYTES + i]);
341
+ }
342
+ return { tokenId, amount };
343
+ }
@@ -0,0 +1,19 @@
1
+ declare module "snarkjs" {
2
+ const groth16: {
3
+ fullProve(
4
+ input: Record<string, any>,
5
+ wasmFile: string | Uint8Array,
6
+ zkeyFile: string | Uint8Array
7
+ ): Promise<{ proof: any; publicSignals: string[] }>;
8
+ verify(
9
+ vkey: any,
10
+ publicSignals: string[],
11
+ proof: any
12
+ ): Promise<boolean>;
13
+ exportSolidityCallData(
14
+ proof: any,
15
+ publicSignals: string[]
16
+ ): Promise<string>;
17
+ };
18
+ export { groth16 };
19
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * SNS Subdomain Resolver for Stealth Addresses
3
+ *
4
+ * Resolves `<name>.utxopia.sol` (or configured parent domain) to stealth
5
+ * address keys stored in the SNS name record data field.
6
+ *
7
+ * On-chain data layout (after 96-byte SNS header):
8
+ * version(1) + viewingPubKey(32) + mpk(32) = 65 bytes
9
+ *
10
+ * - viewingPubKey: Ed25519 public key (for X25519 ECDH)
11
+ * - mpk: Master Public Key = Poseidon(spendingPub.x, spendingPub.y, nullifyingKey)
12
+ *
13
+ * These two keys are all a sender needs to create a stealth deposit.
14
+ * spendingPubKey is NOT stored — senders never use it.
15
+ *
16
+ * @module sns-resolver
17
+ */
18
+
19
+ import { getConfig } from "./config";
20
+ import type { ConnectionAdapter } from "./stealth";
21
+ import { sha256Hash } from "./crypto";
22
+
23
+ // ========== Constants ==========
24
+
25
+ /** SNS name record header size (parent:32 + owner:32 + class:32) */
26
+ const SNS_HEADER_SIZE = 96;
27
+
28
+ /** Stealth data size: version(1) + viewingPubKey(32) + mpk(32) = 65 bytes */
29
+ export const SNS_STEALTH_DATA_SIZE = 65;
30
+
31
+ /** SNS hash prefix used for PDA derivation */
32
+ const HASH_PREFIX = "SPL Name Service";
33
+
34
+ // ========== Types ==========
35
+
36
+ export interface SnsStealthAddress {
37
+ /** The subdomain name (e.g., "alice") */
38
+ name: string;
39
+
40
+ /** Full domain (e.g., "alice.utxopia.sol") */
41
+ fullDomain: string;
42
+
43
+ /** Ed25519 viewing public key (32 bytes) — for X25519 ECDH */
44
+ viewingPubKey: Uint8Array;
45
+
46
+ /** Master public key: Poseidon(spendingPub.x, spendingPub.y, nullifyingKey) (32 bytes) — for NPK derivation */
47
+ mpk: Uint8Array;
48
+
49
+ /** Data version read from the record */
50
+ version: number;
51
+
52
+ /**
53
+ * Compliance flag bits the recipient has self-published. Defaults to 0
54
+ * when the SNS record only carries the base 65-byte stealth payload.
55
+ * Check via {@link SnsComplianceFlags} / {@link isAuditorDisclosable}.
56
+ */
57
+ complianceFlags: number;
58
+
59
+ /**
60
+ * Optional 32-byte Solana pubkey of the recipient's designated auditor.
61
+ * Present only when the record carries the v2 compliance bytes
62
+ * (flag + 32-byte pubkey, 33 bytes total after the 65-byte stealth payload).
63
+ * Senders treat this as a hint about who the recipient discloses to —
64
+ * the actual DelegatedViewKey delivery is still out-of-band.
65
+ */
66
+ auditorPubkey?: Uint8Array;
67
+ }
68
+
69
+ /**
70
+ * Bit-flags a recipient can opt into on their SNS subdomain. The byte sits
71
+ * at offset 65 of the stealth payload (i.e. byte 161 of the on-chain
72
+ * account, after the 96-byte SNS header). Absent → all bits 0.
73
+ */
74
+ export const SnsComplianceFlags = {
75
+ /**
76
+ * Recipient is "auditor-disclosable by default" — they've signalled to
77
+ * senders that they're OK receiving outgoing audit memos, and likely
78
+ * already share a `DelegatedViewKey` with a designated auditor
79
+ * out-of-band.
80
+ */
81
+ AUDITOR_DISCLOSABLE: 1 << 0,
82
+ } as const;
83
+
84
+ /** Returns true if the recipient has set the AUDITOR_DISCLOSABLE bit. */
85
+ export function isAuditorDisclosable(addr: SnsStealthAddress): boolean {
86
+ return (addr.complianceFlags & SnsComplianceFlags.AUDITOR_DISCLOSABLE) !== 0;
87
+ }
88
+
89
+ /**
90
+ * Total bytes of the stealth payload's compliance extension when both
91
+ * pieces are present:
92
+ * [byte 65] complianceFlags (u8)
93
+ * [bytes 66..97] auditorPubkey (32-byte Solana pubkey)
94
+ *
95
+ * Records may carry only the base 65-byte payload, the payload plus
96
+ * `complianceFlags`, or the payload plus `complianceFlags` and `auditorPubkey`.
97
+ */
98
+ export const SNS_COMPLIANCE_AUDITOR_OFFSET = 66;
99
+ export const SNS_COMPLIANCE_AUDITOR_BYTES = 32;
100
+
101
+ // ========== PDA Derivation ==========
102
+
103
+ /**
104
+ * Hash a name for SNS PDA derivation (SHA256 of HASH_PREFIX + name)
105
+ */
106
+ function hashSnsName(name: string): Uint8Array {
107
+ const input = HASH_PREFIX + name;
108
+ return sha256Hash(new TextEncoder().encode(input));
109
+ }
110
+
111
+ // ========== PDA Derivation (internal) ==========
112
+
113
+ /**
114
+ * Derive the SNS key for a parent domain (e.g., "utxopia.sol")
115
+ */
116
+ export async function deriveParentDomainKey(parentDomain: string): Promise<string> {
117
+ const { address, getProgramDerivedAddress, getAddressEncoder } = await import("@solana/kit");
118
+ const config = getConfig();
119
+
120
+ if (!config.snsRootDomain) {
121
+ throw new Error("SNS root domain not configured for this network");
122
+ }
123
+
124
+ const hashedParent = hashSnsName(parentDomain);
125
+ const encoder = getAddressEncoder();
126
+
127
+ const [pda] = await getProgramDerivedAddress({
128
+ seeds: [
129
+ hashedParent,
130
+ new Uint8Array(32), // no class
131
+ new Uint8Array(encoder.encode(address(config.snsRootDomain))),
132
+ ],
133
+ programAddress: address(config.snsNameServiceProgramId),
134
+ });
135
+
136
+ return pda;
137
+ }
138
+
139
+ /**
140
+ * Derive the SNS key for a subdomain (e.g., "alice" under "utxopia.sol")
141
+ *
142
+ * Seeds: [hash("SPL Name Service" + "\0" + name), zeros(32), parentKey]
143
+ */
144
+ async function deriveSubdomainKey(
145
+ subdomain: string,
146
+ parentKey: string,
147
+ ): Promise<string> {
148
+ const { address, getProgramDerivedAddress, getAddressEncoder } = await import("@solana/kit");
149
+ const config = getConfig();
150
+
151
+ const hashedSub = hashSnsName("\0" + subdomain);
152
+ const encoder = getAddressEncoder();
153
+
154
+ const [pda] = await getProgramDerivedAddress({
155
+ seeds: [
156
+ hashedSub,
157
+ new Uint8Array(32), // no class
158
+ new Uint8Array(encoder.encode(address(parentKey))),
159
+ ],
160
+ programAddress: address(config.snsNameServiceProgramId),
161
+ });
162
+
163
+ return pda;
164
+ }
165
+
166
+ // ========== Parsing ==========
167
+
168
+ /**
169
+ * Parse stealth address data from an SNS name record.
170
+ *
171
+ * Supports the current format:
172
+ * - Base (65 bytes, version 2): version(1) + viewingPubKey(32) + mpk(32)
173
+ *
174
+ * Optional trailing byte (offset 65 of the stealth payload, byte 161 of the
175
+ * account) carries `complianceFlags: u8` — see {@link SnsComplianceFlags}.
176
+ * When absent, the parsed result has `complianceFlags = 0`.
177
+ *
178
+ * @param accountData - Raw account data (including 96-byte header)
179
+ * @returns Parsed stealth keys + compliance flags, or null if invalid
180
+ */
181
+ export function parseSnsStealthData(
182
+ accountData: Uint8Array,
183
+ ):
184
+ | {
185
+ viewingPubKey: Uint8Array;
186
+ mpk: Uint8Array;
187
+ version: number;
188
+ complianceFlags: number;
189
+ auditorPubkey?: Uint8Array;
190
+ }
191
+ | null {
192
+ // Need at least header + 65 bytes of stealth data
193
+ if (accountData.length < SNS_HEADER_SIZE + SNS_STEALTH_DATA_SIZE) {
194
+ return null;
195
+ }
196
+
197
+ const data = accountData.slice(SNS_HEADER_SIZE);
198
+ const version = data[0];
199
+ const allZero = (buf: Uint8Array) => buf.every((b) => b === 0);
200
+
201
+ if (version !== 2) {
202
+ return null;
203
+ }
204
+
205
+ const viewingPubKey = data.slice(1, 33);
206
+ const mpk = data.slice(33, 65);
207
+
208
+ if (allZero(viewingPubKey) || allZero(mpk)) {
209
+ return null;
210
+ }
211
+
212
+ const complianceFlags =
213
+ data.length > SNS_STEALTH_DATA_SIZE ? data[SNS_STEALTH_DATA_SIZE] : 0;
214
+
215
+ let auditorPubkey: Uint8Array | undefined;
216
+ if (
217
+ data.length >= SNS_COMPLIANCE_AUDITOR_OFFSET + SNS_COMPLIANCE_AUDITOR_BYTES
218
+ ) {
219
+ const buf = data.slice(
220
+ SNS_COMPLIANCE_AUDITOR_OFFSET,
221
+ SNS_COMPLIANCE_AUDITOR_OFFSET + SNS_COMPLIANCE_AUDITOR_BYTES,
222
+ );
223
+ // All-zero pubkey means "no auditor set" — distinguishes from the
224
+ // case where a recipient flips the flag bit but skips the pubkey.
225
+ if (!allZero(buf)) {
226
+ auditorPubkey = new Uint8Array(buf);
227
+ }
228
+ }
229
+
230
+ return {
231
+ viewingPubKey: new Uint8Array(viewingPubKey),
232
+ mpk: new Uint8Array(mpk),
233
+ version,
234
+ complianceFlags,
235
+ auditorPubkey,
236
+ };
237
+ }
238
+
239
+ // ========== Resolution ==========
240
+
241
+ /**
242
+ * Resolve a name to a stealth address via SNS subdomain.
243
+ *
244
+ * Accepts multiple formats:
245
+ * - "alice" → resolves alice.<parentDomain>.sol
246
+ * - "alice.utxopia" → resolves alice.utxopia.sol
247
+ * - "alice.utxopia.sol" → resolves alice.utxopia.sol
248
+ *
249
+ * @param connection - RPC connection adapter
250
+ * @param name - Name to resolve
251
+ * @returns Stealth address or null if not found / no stealth data
252
+ */
253
+ export async function resolveSnsName(
254
+ connection: ConnectionAdapter,
255
+ name: string,
256
+ ): Promise<SnsStealthAddress | null> {
257
+ const config = getConfig();
258
+
259
+ if (!config.snsNameServiceProgramId || !config.snsParentDomain) {
260
+ return null; // SNS not configured
261
+ }
262
+
263
+ // Normalize: strip .sol and parent domain suffix
264
+ const parentDomain = config.snsParentDomain;
265
+ let subdomain = name.trim().toLowerCase();
266
+ if (subdomain.endsWith(".sol")) {
267
+ subdomain = subdomain.slice(0, -4);
268
+ }
269
+ if (subdomain.endsWith("." + parentDomain)) {
270
+ subdomain = subdomain.slice(0, -(parentDomain.length + 1));
271
+ }
272
+
273
+ if (!subdomain || subdomain.includes(".")) {
274
+ return null; // Invalid: either empty or has extra dots
275
+ }
276
+
277
+ try {
278
+ // Derive parent domain key
279
+ const parentKey = await deriveParentDomainKey(parentDomain);
280
+
281
+ // Derive subdomain key
282
+ const subKey = await deriveSubdomainKey(subdomain, parentKey);
283
+
284
+ // Fetch account
285
+ const accountInfo = await connection.getAccountInfo(subKey as any);
286
+ if (!accountInfo) {
287
+ return null;
288
+ }
289
+
290
+ // Parse stealth data
291
+ const parsed = parseSnsStealthData(new Uint8Array(accountInfo.data));
292
+ if (!parsed) {
293
+ return null;
294
+ }
295
+
296
+ const fullDomain = `${subdomain}.${parentDomain}.sol`;
297
+
298
+ return {
299
+ name: subdomain,
300
+ fullDomain,
301
+ viewingPubKey: parsed.viewingPubKey,
302
+ mpk: parsed.mpk,
303
+ version: parsed.version,
304
+ complianceFlags: parsed.complianceFlags,
305
+ auditorPubkey: parsed.auditorPubkey,
306
+ };
307
+ } catch (err) {
308
+ console.error(`Failed to resolve SNS name "${name}":`, err);
309
+ return null;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Resolve a stealth name via SNS subdomain.
315
+ *
316
+ * This is the unified resolver that the frontend should use.
317
+ * All names are resolved as SNS subdomains under the configured parent domain.
318
+ */
319
+ export async function resolveStealthName(
320
+ connection: ConnectionAdapter,
321
+ name: string,
322
+ ): Promise<SnsStealthAddress | null> {
323
+ return resolveSnsName(connection, name);
324
+ }
325
+
326
+ /**
327
+ * Type guard to check if a resolved address is from SNS
328
+ */
329
+ export function isSnsStealthAddress(
330
+ addr: SnsStealthAddress | unknown,
331
+ ): addr is SnsStealthAddress {
332
+ return typeof addr === "object" && addr !== null && "fullDomain" in addr;
333
+ }