@x1id/resolve 0.23.0 → 0.44.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.
Files changed (49) hide show
  1. package/README.md +97 -0
  2. package/dist/accounts.d.ts +6 -0
  3. package/dist/accounts.js +6 -1
  4. package/dist/agent.d.ts +184 -8
  5. package/dist/agent.js +351 -3
  6. package/dist/agentPolicy.d.ts +238 -0
  7. package/dist/agentPolicy.js +364 -0
  8. package/dist/attestation.d.ts +25 -0
  9. package/dist/attestation.js +25 -0
  10. package/dist/checkout.d.ts +216 -0
  11. package/dist/checkout.js +327 -0
  12. package/dist/credentials.d.ts +466 -0
  13. package/dist/credentials.js +669 -0
  14. package/dist/gate.d.ts +94 -4
  15. package/dist/gate.js +206 -7
  16. package/dist/gateClient.d.ts +12 -0
  17. package/dist/giftLink.d.ts +272 -0
  18. package/dist/giftLink.js +452 -0
  19. package/dist/index.d.ts +13 -0
  20. package/dist/index.js +32 -0
  21. package/dist/loginButton.d.ts +69 -0
  22. package/dist/loginButton.js +207 -0
  23. package/dist/logo.d.ts +1 -0
  24. package/dist/logo.js +8 -0
  25. package/dist/messaging.d.ts +439 -0
  26. package/dist/messaging.js +979 -0
  27. package/dist/nsGate.d.ts +98 -0
  28. package/dist/nsGate.js +172 -0
  29. package/dist/oidc.d.ts +272 -0
  30. package/dist/oidc.js +641 -0
  31. package/dist/pay.d.ts +513 -0
  32. package/dist/pay.js +756 -0
  33. package/dist/payment.d.ts +9 -2
  34. package/dist/payment.js +9 -2
  35. package/dist/recovery.d.ts +186 -0
  36. package/dist/recovery.js +276 -0
  37. package/dist/recoveryNft.d.ts +183 -0
  38. package/dist/recoveryNft.js +291 -0
  39. package/dist/shield.d.ts +373 -0
  40. package/dist/shield.js +695 -0
  41. package/dist/subscription.d.ts +439 -0
  42. package/dist/subscription.js +642 -0
  43. package/dist/textRecords.d.ts +52 -6
  44. package/dist/textRecords.js +80 -8
  45. package/dist/types.d.ts +3 -0
  46. package/dist/x402Receipt.d.ts +246 -0
  47. package/dist/x402Receipt.js +374 -0
  48. package/package.json +18 -1
  49. package/schema/agent-manifest.json +91 -8
@@ -0,0 +1,979 @@
1
+ /**
2
+ * X1ID Messaging — end-to-end encrypted messages between @names
3
+ * (docs/specs/x1id-messaging.md, OpenProject #8873: Msg 1 + Msg 2).
4
+ *
5
+ * What this module gives an integrator:
6
+ *
7
+ * - **Keys from a wallet signature.** {@link messagingKeyDerivationMessage} is
8
+ * the fixed, domain-separated text a wallet signs; {@link deriveMessagingKeyPair}
9
+ * turns that signature into an X25519 keypair (HKDF-SHA256). The user never
10
+ * manages a separate secret: the same wallet signing the same message gives
11
+ * the same key on every device. Same pattern as Shield's key derivation
12
+ * (app/src/lib/shield/keys.ts), including the "sign twice, both must match"
13
+ * determinism check ({@link signaturesMatch}).
14
+ * - **The on-chain key record.** The public key is published by the name's
15
+ * owner as the text record {@link MSG_KEY_TEXT_KEY} (`x1.msg.key`), value
16
+ * `x1msg1;k=<x25519 pubkey, base64url>;w=<inbox wallet>;i=<key index>` —
17
+ * {@link encodeMessagingKeyRecord} / {@link parseMessagingKeyRecord}.
18
+ * Rotation = derive with the next index and overwrite the record.
19
+ * - **The trust rule** ({@link judgeMessagingKey}, pure) and the chain read
20
+ * that applies it ({@link resolveMessagingKey}): a key is honoured only if
21
+ * the record was written AFTER the current holder's ownership epoch and its
22
+ * inbox wallet is the current authority, or a record delegate granted after
23
+ * that epoch. A key left behind by a previous owner is never used.
24
+ * - **Envelopes.** {@link sealMessage} encrypts (X25519 + HKDF-SHA256 +
25
+ * XChaCha20-Poly1305, fresh ephemeral key per recipient, padded to a length
26
+ * bucket) for ONE X1 network ({@link MessagingNetwork}: the network byte is
27
+ * in the authenticated header and the signed digest, so a message can't be
28
+ * replayed across mainnet/testnet); the sender's WALLET signs the envelope digest
29
+ * ({@link envelopeSigningMessage}); {@link attachSignature} completes it;
30
+ * {@link parseEnvelope} / {@link verifyEnvelopeSignature} check it without
31
+ * decrypting (what the relay does); {@link openMessage} verifies and decrypts.
32
+ * - **Optional on-chain proof of sending**, paid by the SENDER:
33
+ * {@link buildMessageProofMemoIx} — an SPL Memo `x1msg1:<digest hex>` signed
34
+ * by the sender's wallet.
35
+ *
36
+ * # Dependencies — why this module is a separate entry point
37
+ *
38
+ * The rest of `@x1id/resolve` is dependency-free. Messaging needs X25519,
39
+ * XChaCha20-Poly1305, HKDF and strict Ed25519 verification, and hand-rolling
40
+ * any of those is not acceptable. It uses the audited, pure-TypeScript @noble
41
+ * libraries (`@noble/curves`, `@noble/ciphers`, `@noble/hashes`; @solana/web3.js
42
+ * itself depends on curves + hashes, and the X1ID app already ships curves).
43
+ * It is published as the `@x1id/resolve/messaging` subpath and is NOT
44
+ * re-exported from the package root, so importing the resolver never loads
45
+ * them.
46
+ *
47
+ * Nothing here talks to the relay or signs anything: wallet signatures come in
48
+ * as bytes, and the relay is plain HTTP (spec §6).
49
+ */
50
+ import { x25519, ed25519 } from "@noble/curves/ed25519";
51
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha";
52
+ import { hkdf } from "@noble/hashes/hkdf";
53
+ import { sha256 } from "@noble/hashes/sha2";
54
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
55
+ import { makeAccountReader, nftHolder, parseHandleAccount } from "./accounts.js";
56
+ import { decodeRecordDelegate, RECORD_DELEGATE_SEED } from "./delegate.js";
57
+ import { decodeTextRecord, hashTextKey, TEXT_RECORD_SEED, TEXT_VALUE_MAX_LEN } from "./textRecords.js";
58
+ import { ResolveError } from "./types.js";
59
+ // ---------------------------------------------------------------------------
60
+ // Constants
61
+ // ---------------------------------------------------------------------------
62
+ /** The text-record key a name publishes its messaging key under. */
63
+ export const MSG_KEY_TEXT_KEY = "x1.msg.key";
64
+ /** Version tag that starts every v1 key-record value. */
65
+ export const MSG_KEY_RECORD_VERSION = "x1msg1";
66
+ /** Highest key index accepted (rotation counter). */
67
+ export const MSG_MAX_KEY_INDEX = 65535;
68
+ /** The header's network byte (offset 4) per network. 0 (the pre-network
69
+ * "reserved flags" value) and anything else are refused. */
70
+ export const MSG_NETWORK_BYTE = Object.freeze({ mainnet: 1, testnet: 2 });
71
+ /** Is `v` a {@link MessagingNetwork}? */
72
+ export function isMessagingNetwork(v) {
73
+ return v === "mainnet" || v === "testnet";
74
+ }
75
+ function networkFromByte(b) {
76
+ return b === MSG_NETWORK_BYTE.mainnet ? "mainnet" : b === MSG_NETWORK_BYTE.testnet ? "testnet" : null;
77
+ }
78
+ /** Envelope magic + version: ASCII "X1M" then 0x01. */
79
+ export const MSG_ENVELOPE_MAGIC = Uint8Array.from([0x58, 0x31, 0x4d, 0x01]);
80
+ /** Plaintext is padded up to the smallest of these sizes that fits, so the
81
+ * ciphertext reveals only which bucket the message falls in. */
82
+ export const MSG_PAD_BUCKETS = Object.freeze([256, 1024, 4096, 16384]);
83
+ /** Padded-plaintext framing: u32 length + 1 content-type byte. */
84
+ const PAD_OVERHEAD = 5;
85
+ /** Longest message text, UTF-8 bytes. */
86
+ export const MSG_MAX_TEXT_BYTES = 16384 - PAD_OVERHEAD;
87
+ /** Content type byte: UTF-8 plain text (the only one in v1). */
88
+ export const MSG_CONTENT_TEXT = 1;
89
+ /** Fixed header: magic(4) network(1) sender(32) recipient(32) createdAt(8) id(16) stanzaCount(1). */
90
+ const FIXED_HEADER_LEN = 4 + 1 + 32 + 32 + 8 + 16 + 1; // 94
91
+ /** One recipient stanza: key(32) ephemeral(32) wrapped content key(32 + 16 tag). */
92
+ const STANZA_LEN = 32 + 32 + 48;
93
+ const NONCE_LEN = 24;
94
+ const TAG_LEN = 16;
95
+ const SIG_LEN = 64;
96
+ /** Trailer kind byte: the wallet signed {@link envelopeSigningMessage}. */
97
+ const SIG_KIND_MESSAGE = 0;
98
+ /** Trailer kind byte: the wallet signed {@link envelopeSigningTransaction}. */
99
+ const SIG_KIND_TX = 1;
100
+ /** Largest signed-transaction message a transaction-kind trailer may carry. */
101
+ const MAX_TX_MESSAGE = 600;
102
+ /** Largest valid envelope, bytes (two stanzas, largest bucket, tx trailer). */
103
+ export const MSG_MAX_ENVELOPE_BYTES = FIXED_HEADER_LEN + 2 * STANZA_LEN + NONCE_LEN + 4 + 16384 + TAG_LEN + 1 + SIG_LEN + 2 + MAX_TX_MESSAGE;
104
+ /** SPL Memo v2 — the program the optional proof of sending rides on. */
105
+ export const MSG_MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
106
+ /** Prefix of the proof-of-sending memo: `x1msg1:<64 hex digest>`. */
107
+ export const MSG_PROOF_MEMO_PREFIX = "x1msg1:";
108
+ const TE = new TextEncoder();
109
+ const HKDF_SALT_KEY = TE.encode("x1id-msg/v1/key-derivation");
110
+ const HKDF_INFO_WRAP = TE.encode("x1id-msg/v1/wrap");
111
+ /** A typed failure from this module. `code` is stable; `message` is for people. */
112
+ export class MessagingError extends Error {
113
+ code;
114
+ constructor(code, message) {
115
+ super(message);
116
+ this.code = code;
117
+ this.name = "MessagingError";
118
+ }
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // Small helpers
122
+ // ---------------------------------------------------------------------------
123
+ function toBytes32(v, what) {
124
+ if (typeof v === "string") {
125
+ const b = decodeBase58_32(v);
126
+ if (!b)
127
+ throw new MessagingError("bad-input", `${what} is not a valid base58 address`);
128
+ return b;
129
+ }
130
+ if (v.length !== 32)
131
+ throw new MessagingError("bad-input", `${what} must be exactly 32 bytes`);
132
+ return v;
133
+ }
134
+ function concatBytes(...parts) {
135
+ let n = 0;
136
+ for (const p of parts)
137
+ n += p.length;
138
+ const out = new Uint8Array(n);
139
+ let o = 0;
140
+ for (const p of parts) {
141
+ out.set(p, o);
142
+ o += p.length;
143
+ }
144
+ return out;
145
+ }
146
+ function bytesEqual(a, b) {
147
+ if (a.length !== b.length)
148
+ return false;
149
+ let d = 0;
150
+ for (let i = 0; i < a.length; i++)
151
+ d |= a[i] ^ b[i];
152
+ return d === 0;
153
+ }
154
+ function isAllZero(b) {
155
+ let d = 0;
156
+ for (let i = 0; i < b.length; i++)
157
+ d |= b[i];
158
+ return d === 0;
159
+ }
160
+ /** Lowercase hex. */
161
+ export function toHex(b) {
162
+ let s = "";
163
+ for (let i = 0; i < b.length; i++)
164
+ s += b[i].toString(16).padStart(2, "0");
165
+ return s;
166
+ }
167
+ function fromHex(s) {
168
+ if (!/^(?:[0-9a-f]{2})*$/.test(s))
169
+ return null;
170
+ const out = new Uint8Array(s.length / 2);
171
+ for (let i = 0; i < out.length; i++)
172
+ out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
173
+ return out;
174
+ }
175
+ const B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
176
+ /** RFC 4648 §5 base64url, no padding. */
177
+ export function base64urlEncode(b) {
178
+ let s = "";
179
+ let i = 0;
180
+ for (; i + 2 < b.length; i += 3) {
181
+ const n = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2];
182
+ s += B64URL[n >> 18] + B64URL[(n >> 12) & 63] + B64URL[(n >> 6) & 63] + B64URL[n & 63];
183
+ }
184
+ const rest = b.length - i;
185
+ if (rest === 1) {
186
+ const n = b[i] << 16;
187
+ s += B64URL[n >> 18] + B64URL[(n >> 12) & 63];
188
+ }
189
+ else if (rest === 2) {
190
+ const n = (b[i] << 16) | (b[i + 1] << 8);
191
+ s += B64URL[n >> 18] + B64URL[(n >> 12) & 63] + B64URL[(n >> 6) & 63];
192
+ }
193
+ return s;
194
+ }
195
+ /** Strict base64url decode (no padding, canonical trailing bits), or null. */
196
+ export function base64urlDecode(s) {
197
+ if (!/^[A-Za-z0-9_-]*$/.test(s) || s.length % 4 === 1)
198
+ return null;
199
+ const out = new Uint8Array(Math.floor((s.length * 3) / 4));
200
+ let o = 0;
201
+ let acc = 0;
202
+ let bits = 0;
203
+ for (let i = 0; i < s.length; i++) {
204
+ acc = (acc << 6) | B64URL.indexOf(s[i]);
205
+ bits += 6;
206
+ if (bits >= 8) {
207
+ bits -= 8;
208
+ out[o++] = (acc >> bits) & 0xff;
209
+ }
210
+ }
211
+ // Non-canonical encodings (stray low bits) are refused, so every byte
212
+ // string has exactly one accepted spelling.
213
+ if (bits > 0 && (acc & ((1 << bits) - 1)) !== 0)
214
+ return null;
215
+ return out;
216
+ }
217
+ const defaultRandom = (n) => {
218
+ const b = new Uint8Array(n);
219
+ globalThis.crypto.getRandomValues(b);
220
+ return b;
221
+ };
222
+ // ---------------------------------------------------------------------------
223
+ // Keys from a wallet signature
224
+ // ---------------------------------------------------------------------------
225
+ function checkIndex(index) {
226
+ if (!Number.isInteger(index) || index < 0 || index > MSG_MAX_KEY_INDEX) {
227
+ throw new MessagingError("bad-input", `key index must be an integer 0..=${MSG_MAX_KEY_INDEX}`);
228
+ }
229
+ }
230
+ /**
231
+ * The exact text a wallet signs to derive its messaging key. Fixed and
232
+ * domain-separated (it names X1ID Messaging, the wallet and the key index, so
233
+ * a signature made for anything else — a sign-in, another app, another index —
234
+ * never yields this key). Plain printable ASCII + newlines so every wallet
235
+ * shows it as text.
236
+ *
237
+ * The signature IS the secret: anyone who obtains it can derive the key and
238
+ * read the wallet's messages. The text says so, and apps must never send or
239
+ * store it (the X1ID app keeps it in memory only, like Shield).
240
+ */
241
+ export function messagingKeyDerivationMessage(wallet, index = 0) {
242
+ checkIndex(index);
243
+ const w = encodeBase58(toBytes32(wallet, "wallet"));
244
+ return TE.encode("X1ID Messaging key v1\n\n" +
245
+ "Signing this creates your private X1ID messaging key. Anyone who has this " +
246
+ "signature can read your messages, so only sign it in an app you trust with them.\n\n" +
247
+ `Wallet: ${w}\n` +
248
+ `Key index: ${index}`);
249
+ }
250
+ /**
251
+ * Derive the X25519 keypair from the wallet's 64-byte signature over
252
+ * {@link messagingKeyDerivationMessage}`(wallet, index)`:
253
+ *
254
+ * secret = HKDF-SHA256(ikm = signature, salt = "x1id-msg/v1/key-derivation",
255
+ * info = "x25519|<wallet>|<index>", L = 32)
256
+ *
257
+ * (X25519 clamps the scalar itself.) Only deterministic wallets give a stable
258
+ * key — have the wallet sign twice on set-up and require
259
+ * {@link signaturesMatch}, exactly as Shield does.
260
+ */
261
+ export function deriveMessagingKeyPair(signature, wallet, index = 0) {
262
+ checkIndex(index);
263
+ if (!(signature instanceof Uint8Array) || signature.length !== SIG_LEN) {
264
+ throw new MessagingError("bad-input", "signature must be 64 bytes");
265
+ }
266
+ if (isAllZero(signature))
267
+ throw new MessagingError("bad-input", "signature is all zero");
268
+ const w = encodeBase58(toBytes32(wallet, "wallet"));
269
+ const secretKey = hkdf(sha256, signature, HKDF_SALT_KEY, TE.encode(`x25519|${w}|${index}`), 32);
270
+ return { wallet: w, index, publicKey: x25519.getPublicKey(secretKey), secretKey };
271
+ }
272
+ /** Constant-time equality of two signatures — the determinism check. */
273
+ export function signaturesMatch(a, b) {
274
+ return a.length === SIG_LEN && bytesEqual(a, b);
275
+ }
276
+ /** The record value, ASCII: `x1msg1;k=<base64url pubkey>;w=<wallet>;i=<index>`. */
277
+ export function encodeMessagingKeyRecord(r) {
278
+ if (!(r.publicKey instanceof Uint8Array) || r.publicKey.length !== 32) {
279
+ throw new MessagingError("bad-input", "publicKey must be 32 bytes");
280
+ }
281
+ if (isAllZero(r.publicKey))
282
+ throw new MessagingError("bad-input", "publicKey is all zero");
283
+ checkIndex(r.index);
284
+ const w = encodeBase58(toBytes32(r.wallet, "wallet"));
285
+ const s = `${MSG_KEY_RECORD_VERSION};k=${base64urlEncode(r.publicKey)};w=${w};i=${r.index}`;
286
+ if (s.length > TEXT_VALUE_MAX_LEN)
287
+ throw new MessagingError("bad-input", "record value too long");
288
+ return s;
289
+ }
290
+ /** {@link encodeMessagingKeyRecord} as the raw bytes a text-record ix takes. */
291
+ export function encodeMessagingKeyRecordBytes(r) {
292
+ return TE.encode(encodeMessagingKeyRecord(r));
293
+ }
294
+ const RECORD_RE = /^x1msg1;k=([A-Za-z0-9_-]{43});w=([1-9A-HJ-NP-Za-km-z]{32,44});i=(0|[1-9][0-9]{0,4})$/;
295
+ /**
296
+ * Parse a record value strictly: exactly the v1 shape, canonical base64url, a
297
+ * real 32-byte wallet, a non-zero key. Anything else (another version, extra
298
+ * fields, padding, whitespace) is null — a reader must then treat the name as
299
+ * having no usable key.
300
+ */
301
+ export function parseMessagingKeyRecord(value) {
302
+ let s;
303
+ if (typeof value === "string")
304
+ s = value;
305
+ else {
306
+ try {
307
+ s = new TextDecoder("utf-8", { fatal: true }).decode(value);
308
+ }
309
+ catch {
310
+ return null;
311
+ }
312
+ }
313
+ const m = RECORD_RE.exec(s);
314
+ if (!m)
315
+ return null;
316
+ const publicKey = base64urlDecode(m[1]);
317
+ if (!publicKey || publicKey.length !== 32 || isAllZero(publicKey))
318
+ return null;
319
+ const walletBytes = decodeBase58_32(m[2]);
320
+ if (!walletBytes)
321
+ return null;
322
+ const wallet = encodeBase58(walletBytes);
323
+ if (wallet !== m[2])
324
+ return null; // canonical spelling only
325
+ const index = Number(m[3]);
326
+ if (index > MSG_MAX_KEY_INDEX)
327
+ return null;
328
+ return { version: 1, publicKey, wallet, index };
329
+ }
330
+ /**
331
+ * The ownership epoch a messaging key must post-date:
332
+ * `max(registered_at, holder_epoch)` — the last native register/sale/transfer
333
+ * or native pNFT sale (docs/record-trust.md, attestation.ts).
334
+ */
335
+ export function messagingOwnershipEpoch(h) {
336
+ return h.registeredAt > h.holderEpoch ? h.registeredAt : h.holderEpoch;
337
+ }
338
+ /**
339
+ * THE trust rule (spec §3). A name's messaging key is honoured only when ALL of:
340
+ *
341
+ * 1. the record exists and parses as a v1 key;
342
+ * 2. `updated_at >= max(registered_at, holder_epoch)` — it was written in the
343
+ * CURRENT holder's ownership epoch — and `updated_at >= records_cleared_at`;
344
+ * 3. its inbox wallet `w` is the name's current authority (owner / NFT holder),
345
+ * OR the current record delegate whose `delegated_at >= max(registered_at,
346
+ * holder_epoch)` (the holder authorised that delegate after their epoch).
347
+ *
348
+ * Rule 3 also catches what the epochs cannot: a bearer-NFT trade on an
349
+ * outside marketplace bumps no epoch, but the old holder's key names the old
350
+ * holder's wallet, which is no longer the authority, so it stops counting.
351
+ * Pure; no I/O.
352
+ */
353
+ export function judgeMessagingKey(input) {
354
+ if (!input.record)
355
+ return { ok: false, reason: "no-record" };
356
+ const key = parseMessagingKeyRecord(input.record.value);
357
+ if (!key)
358
+ return { ok: false, reason: "malformed" };
359
+ const epoch = messagingOwnershipEpoch(input.handle);
360
+ if (input.record.updatedAt < epoch || input.record.updatedAt < input.handle.recordsClearedAt) {
361
+ return { ok: false, reason: "stale", key };
362
+ }
363
+ if (key.wallet === input.authority)
364
+ return { ok: true, key, basis: "authority" };
365
+ if (input.delegate && input.delegate.delegate === key.wallet) {
366
+ if (input.delegate.delegatedAt >= epoch)
367
+ return { ok: true, key, basis: "delegate" };
368
+ return { ok: false, reason: "delegate-stale", key };
369
+ }
370
+ return { ok: false, reason: "wallet-not-authorized", key };
371
+ }
372
+ function b58(v, what) {
373
+ return encodeBase58(toBytes32(v, what));
374
+ }
375
+ /**
376
+ * Read `@name`'s messaging key from chain and judge it with
377
+ * {@link judgeMessagingKey}. Bare `@handles` only (like Shield). Throws a
378
+ * `ResolveError` for an invalid or unregistered name; a name without a usable
379
+ * key is a normal result (`verdict.ok === false`), not an error.
380
+ */
381
+ export async function resolveMessagingKey(name, config) {
382
+ const reader = makeAccountReader(config.rpcUrl, config.fetchImpl);
383
+ const programBytes = toBytes32(config.handleProgramId, "handleProgramId");
384
+ const programB58 = encodeBase58(programBytes);
385
+ const canonical = config.wasm.normalizeHandle(name.trim().replace(/^@/, ""));
386
+ if (!canonical)
387
+ throw new ResolveError("invalid-handle", `"${name}" is not a valid handle`, name);
388
+ const derived = config.wasm.deriveHandleAccount(canonical, programBytes);
389
+ if (!derived)
390
+ throw new ResolveError("invalid-handle", `"${name}" is not a valid handle`, name);
391
+ const handleAccount = encodeBase58(derived);
392
+ const info = await reader.accountInfo(handleAccount);
393
+ if (!info || info.owner !== programB58)
394
+ throw new ResolveError("not-found", `@${canonical} is not registered`, name);
395
+ const handle = parseHandleAccount(info.data);
396
+ if (!handle)
397
+ throw new ResolveError("rpc-error", `@${canonical} returned a malformed account`, name);
398
+ const authority = handle.nftMint === null
399
+ ? encodeBase58(handle.owner)
400
+ : (await nftHolder(reader, config.wasm, canonical, handle.nftMint, name)).address;
401
+ const recordAccount = await config.find([TE.encode(TEXT_RECORD_SEED), derived, await hashTextKey(MSG_KEY_TEXT_KEY)], programB58);
402
+ const delegateAccount = await config.find([TE.encode(RECORD_DELEGATE_SEED), derived], programB58);
403
+ const [recInfo, delInfo] = await Promise.all([reader.accountInfo(recordAccount), reader.accountInfo(delegateAccount)]);
404
+ // An account under any other program is not ours — treat as absent.
405
+ const recOk = recInfo && recInfo.owner === programB58 ? recInfo : null;
406
+ const rec = recOk ? decodeTextRecord(recOk.data, recordAccount, handle.registeredAt, handle.recordsClearedAt, handle.holderEpoch) : null;
407
+ const del = delInfo && delInfo.owner === programB58 ? decodeRecordDelegate(delInfo.data) : null;
408
+ const record = rec && rec.handle === handleAccount && rec.key === MSG_KEY_TEXT_KEY
409
+ ? { value: rec.value, updatedAt: rec.updatedAt }
410
+ : null;
411
+ const verdict = recOk && !record
412
+ ? { ok: false, reason: "malformed" }
413
+ : judgeMessagingKey({
414
+ record,
415
+ handle,
416
+ authority,
417
+ delegate: del && del.handle === handleAccount ? del : null,
418
+ });
419
+ return { name: canonical, handleAccount, authority, recordAccount, recordExists: recOk !== null, verdict };
420
+ }
421
+ // ---------------------------------------------------------------------------
422
+ // Padding
423
+ // ---------------------------------------------------------------------------
424
+ /** The padded size for `textBytes` bytes of text. */
425
+ export function paddedLength(textBytes) {
426
+ const need = textBytes + PAD_OVERHEAD;
427
+ for (const b of MSG_PAD_BUCKETS)
428
+ if (need <= b)
429
+ return b;
430
+ throw new MessagingError("too-long", `message is too long (max ${MSG_MAX_TEXT_BYTES} bytes)`);
431
+ }
432
+ function pad(text) {
433
+ const body = TE.encode(text);
434
+ const out = new Uint8Array(paddedLength(body.length));
435
+ new DataView(out.buffer).setUint32(0, body.length + 1, true);
436
+ out[4] = MSG_CONTENT_TEXT;
437
+ out.set(body, 5);
438
+ return out;
439
+ }
440
+ function unpad(p) {
441
+ if (!MSG_PAD_BUCKETS.includes(p.length))
442
+ throw new MessagingError("malformed", "bad padded length");
443
+ const n = new DataView(p.buffer, p.byteOffset, p.byteLength).getUint32(0, true);
444
+ if (n < 1 || 4 + n > p.length)
445
+ throw new MessagingError("malformed", "bad content length");
446
+ if (p[4] !== MSG_CONTENT_TEXT)
447
+ throw new MessagingError("malformed", "unknown content type");
448
+ if (!isAllZero(p.subarray(4 + n)))
449
+ throw new MessagingError("malformed", "non-zero padding");
450
+ try {
451
+ return new TextDecoder("utf-8", { fatal: true }).decode(p.subarray(5, 4 + n));
452
+ }
453
+ catch {
454
+ throw new MessagingError("malformed", "message text is not UTF-8");
455
+ }
456
+ }
457
+ function wrapKeys(shared, ephemeral, key) {
458
+ if (isAllZero(shared))
459
+ throw new MessagingError("bad-input", "invalid (low-order) public key");
460
+ const okm = hkdf(sha256, shared, concatBytes(ephemeral, key), HKDF_INFO_WRAP, 32 + NONCE_LEN);
461
+ return { k: okm.subarray(0, 32), n: okm.subarray(32) };
462
+ }
463
+ function x25519Shared(secret, pub) {
464
+ try {
465
+ return x25519.getSharedSecret(secret, pub);
466
+ }
467
+ catch {
468
+ throw new MessagingError("bad-input", "invalid public key");
469
+ }
470
+ }
471
+ /**
472
+ * The text the sender's wallet signs for an envelope: the X1 network and the
473
+ * envelope's sha256 fingerprint under a fixed, domain-separated heading
474
+ * (printable ASCII). The digest already covers the network byte; naming it in
475
+ * the text also shows the user which network they are sending on.
476
+ */
477
+ export function envelopeSigningMessage(digest, network) {
478
+ if (digest.length !== 32)
479
+ throw new MessagingError("bad-input", "digest must be 32 bytes");
480
+ if (!isMessagingNetwork(network))
481
+ throw new MessagingError("bad-input", "network must be mainnet or testnet");
482
+ return TE.encode(`X1ID Messaging: send a message\n\nNetwork: X1 ${network}\nFingerprint: ${toHex(digest)}`);
483
+ }
484
+ /**
485
+ * Encrypt `text` to the recipient (and optionally the sender's own key).
486
+ *
487
+ * Per message: a random 32-byte content key encrypts the padded text with
488
+ * XChaCha20-Poly1305 (random 24-byte nonce; AAD = every header byte before
489
+ * the ciphertext, so network, sender, recipient, time, id and stanzas are all
490
+ * authenticated). Per stanza: a FRESH ephemeral X25519 key, ECDH with the
491
+ * stanza's key, HKDF-SHA256 (salt = ephemeral ‖ key) → wrap key + nonce, and
492
+ * the content key sealed under them (AAD = the fixed header ‖ the stanza key).
493
+ *
494
+ * Returns the unsigned envelope: have the sender's wallet sign
495
+ * `signingMessage`, then {@link attachSignature}.
496
+ */
497
+ export function sealMessage(p) {
498
+ const rand = p.random ?? defaultRandom;
499
+ if (!isMessagingNetwork(p.network))
500
+ throw new MessagingError("bad-input", "network must be mainnet or testnet");
501
+ const sender = toBytes32(p.sender, "sender");
502
+ const recipient = toBytes32(p.recipient, "recipient");
503
+ const keys = [p.recipientKey];
504
+ if (p.senderKey !== undefined)
505
+ keys.push(p.senderKey);
506
+ for (const k of keys) {
507
+ if (!(k instanceof Uint8Array) || k.length !== 32 || isAllZero(k)) {
508
+ throw new MessagingError("bad-input", "keys must be 32-byte X25519 public keys");
509
+ }
510
+ }
511
+ if (keys.length === 2 && bytesEqual(keys[0], keys[1]))
512
+ keys.pop(); // messaging yourself
513
+ const createdAtMs = p.createdAtMs ?? Date.now();
514
+ if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0)
515
+ throw new MessagingError("bad-input", "bad createdAtMs");
516
+ const fixed = new Uint8Array(FIXED_HEADER_LEN);
517
+ fixed.set(MSG_ENVELOPE_MAGIC, 0);
518
+ fixed[4] = MSG_NETWORK_BYTE[p.network];
519
+ fixed.set(sender, 5);
520
+ fixed.set(recipient, 37);
521
+ new DataView(fixed.buffer).setBigUint64(69, BigInt(createdAtMs), true);
522
+ const id = rand(16);
523
+ fixed.set(id, 77);
524
+ fixed[93] = keys.length;
525
+ const cek = rand(32);
526
+ const stanzas = [];
527
+ for (const key of keys) {
528
+ const ephSecret = rand(32);
529
+ const eph = x25519.getPublicKey(ephSecret);
530
+ const w = wrapKeys(x25519Shared(ephSecret, key), eph, key);
531
+ const wrapped = xchacha20poly1305(w.k, w.n, concatBytes(fixed, key)).encrypt(cek);
532
+ stanzas.push(concatBytes(key, eph, wrapped));
533
+ ephSecret.fill(0);
534
+ }
535
+ const nonce = rand(NONCE_LEN);
536
+ const aad = concatBytes(fixed, ...stanzas, nonce);
537
+ const ct = xchacha20poly1305(cek, nonce, aad).encrypt(pad(p.text));
538
+ cek.fill(0);
539
+ const len = new Uint8Array(4);
540
+ new DataView(len.buffer).setUint32(0, ct.length, true);
541
+ const body = concatBytes(aad, len, ct);
542
+ const digest = sha256(body);
543
+ return { body, digest, signingMessage: envelopeSigningMessage(digest, p.network), messageId: toHex(id) };
544
+ }
545
+ function finishEnvelope(env) {
546
+ const parsed = parseEnvelope(env);
547
+ if (!parsed || !verifyEnvelopeSignature(parsed)) {
548
+ throw new MessagingError("bad-signature", "the wallet's signature does not verify for the sender");
549
+ }
550
+ return env;
551
+ }
552
+ /**
553
+ * Complete an envelope with the sender wallet's `signMessage` signature over
554
+ * `signingMessage` (raw, or SIMD-0048-wrapped — both verify). Checks it
555
+ * before returning, so a wallet that signed the wrong thing (or whose message
556
+ * signatures don't verify on X1) fails HERE, not at the recipient — fall back
557
+ * to {@link envelopeSigningTransaction} + {@link attachTransactionSignature}.
558
+ */
559
+ export function attachSignature(unsigned, signature) {
560
+ if (!(signature instanceof Uint8Array) || signature.length !== SIG_LEN) {
561
+ throw new MessagingError("bad-input", "signature must be 64 bytes");
562
+ }
563
+ return finishEnvelope(concatBytes(unsigned.body, Uint8Array.of(SIG_KIND_MESSAGE), signature));
564
+ }
565
+ /**
566
+ * Complete an envelope with a TRANSACTION signature: for wallets that can sign
567
+ * transactions but not (verifiable) messages — the same reason X1ID sign-in
568
+ * has `/v1/auth/verify-tx`. `signedTx` is the wire transaction the wallet
569
+ * returned from signing {@link envelopeSigningTransaction} (never broadcast).
570
+ * Only the message bytes and the fee payer's signature are kept.
571
+ */
572
+ export function attachTransactionSignature(unsigned, signedTx) {
573
+ const split = splitSignedTransaction(signedTx);
574
+ if (!split)
575
+ throw new MessagingError("bad-input", "not a signed transaction");
576
+ if (split.message.length > MAX_TX_MESSAGE)
577
+ throw new MessagingError("bad-input", "signed transaction too large");
578
+ const len = new Uint8Array(2);
579
+ new DataView(len.buffer).setUint16(0, split.message.length, true);
580
+ return finishEnvelope(concatBytes(unsigned.body, Uint8Array.of(SIG_KIND_TX), split.signature, len, split.message));
581
+ }
582
+ /** Structural parse — the relay's check. Null for anything malformed. */
583
+ export function parseEnvelope(env) {
584
+ if (!(env instanceof Uint8Array) || env.length < FIXED_HEADER_LEN + STANZA_LEN + NONCE_LEN + 4 + TAG_LEN + 1 + SIG_LEN) {
585
+ return null;
586
+ }
587
+ if (env.length > MSG_MAX_ENVELOPE_BYTES)
588
+ return null;
589
+ for (let i = 0; i < 4; i++)
590
+ if (env[i] !== MSG_ENVELOPE_MAGIC[i])
591
+ return null;
592
+ const network = networkFromByte(env[4]);
593
+ if (!network)
594
+ return null;
595
+ const dv = new DataView(env.buffer, env.byteOffset, env.byteLength);
596
+ const createdAt = dv.getBigUint64(69, true);
597
+ if (createdAt > BigInt(Number.MAX_SAFE_INTEGER))
598
+ return null;
599
+ const count = env[93];
600
+ if (count < 1 || count > 2)
601
+ return null;
602
+ let o = FIXED_HEADER_LEN;
603
+ const stanzas = [];
604
+ for (let i = 0; i < count; i++) {
605
+ if (o + STANZA_LEN > env.length)
606
+ return null;
607
+ stanzas.push({
608
+ key: env.slice(o, o + 32),
609
+ ephemeral: env.slice(o + 32, o + 64),
610
+ wrapped: env.slice(o + 64, o + STANZA_LEN),
611
+ });
612
+ o += STANZA_LEN;
613
+ }
614
+ if (count === 2 && bytesEqual(stanzas[0].key, stanzas[1].key))
615
+ return null;
616
+ o += NONCE_LEN;
617
+ if (o + 4 > env.length)
618
+ return null;
619
+ const ctLen = dv.getUint32(o, true);
620
+ o += 4;
621
+ if (!MSG_PAD_BUCKETS.includes(ctLen - TAG_LEN))
622
+ return null;
623
+ const bodyEnd = o + ctLen;
624
+ if (bodyEnd + 1 + SIG_LEN > env.length)
625
+ return null;
626
+ const kind = env[bodyEnd];
627
+ const signature = env.slice(bodyEnd + 1, bodyEnd + 1 + SIG_LEN);
628
+ let signedTxMessage = null;
629
+ if (kind === SIG_KIND_MESSAGE) {
630
+ if (bodyEnd + 1 + SIG_LEN !== env.length)
631
+ return null;
632
+ }
633
+ else if (kind === SIG_KIND_TX) {
634
+ const at = bodyEnd + 1 + SIG_LEN;
635
+ if (at + 2 > env.length)
636
+ return null;
637
+ const n = dv.getUint16(at, true);
638
+ if (n < 1 || n > MAX_TX_MESSAGE || at + 2 + n !== env.length)
639
+ return null;
640
+ signedTxMessage = env.slice(at + 2);
641
+ }
642
+ else {
643
+ return null;
644
+ }
645
+ const body = env.slice(0, bodyEnd);
646
+ return {
647
+ network,
648
+ sender: encodeBase58(env.slice(5, 37)),
649
+ recipient: encodeBase58(env.slice(37, 69)),
650
+ createdAtMs: Number(createdAt),
651
+ messageId: toHex(env.slice(77, 93)),
652
+ stanzas,
653
+ body,
654
+ digest: sha256(body),
655
+ signatureKind: kind === SIG_KIND_TX ? "transaction" : "message",
656
+ signature,
657
+ signedTxMessage,
658
+ size: env.length,
659
+ };
660
+ }
661
+ // ---------------------------------------------------------------------------
662
+ // Transaction-signature mode (wallets whose message signatures don't verify)
663
+ // ---------------------------------------------------------------------------
664
+ const COMPUTE_BUDGET_PROGRAM_ID = "ComputeBudget111111111111111111111111111111";
665
+ /** The memo a transaction-mode sender signs: `x1msg1-sig:<digest hex>`. */
666
+ export function envelopeSigningMemo(digest) {
667
+ if (digest.length !== 32)
668
+ throw new MessagingError("bad-input", "digest must be 32 bytes");
669
+ return `x1msg1-sig:${toHex(digest)}`;
670
+ }
671
+ function compactU16(n) {
672
+ const out = [];
673
+ let v = n;
674
+ for (;;) {
675
+ const b = v & 0x7f;
676
+ v >>= 7;
677
+ if (v === 0) {
678
+ out.push(b);
679
+ return out;
680
+ }
681
+ out.push(b | 0x80);
682
+ }
683
+ }
684
+ function readCompactU16(b, at) {
685
+ let v = 0;
686
+ for (let i = 0; i < 3; i++) {
687
+ if (at + i >= b.length)
688
+ return null;
689
+ const x = b[at + i];
690
+ v |= (x & 0x7f) << (7 * i);
691
+ if ((x & 0x80) === 0)
692
+ return [v, at + i + 1];
693
+ }
694
+ return null;
695
+ }
696
+ /**
697
+ * The unsigned wire transaction a transaction-mode sender's wallet signs (and
698
+ * must NOT broadcast): a legacy message, fee payer = sender, ONE SPL Memo
699
+ * instruction carrying {@link envelopeSigningMemo}. Its "recent blockhash" is
700
+ * the envelope digest itself — never a real blockhash, so the signed artifact
701
+ * can never land on chain and cost the sender a fee, whoever holds it.
702
+ */
703
+ export function envelopeSigningTransaction(sender, digest) {
704
+ const payer = toBytes32(sender, "sender");
705
+ if (digest.length !== 32)
706
+ throw new MessagingError("bad-input", "digest must be 32 bytes");
707
+ const memoProgram = toBytes32(MSG_MEMO_PROGRAM_ID, "memo program");
708
+ const data = TE.encode(envelopeSigningMemo(digest));
709
+ const msg = Uint8Array.from([
710
+ 1, 0, 1, // 1 signer, 0 read-only signed, 1 read-only unsigned (the memo program)
711
+ ...compactU16(2), ...payer, ...memoProgram,
712
+ ...digest,
713
+ ...compactU16(1), 1, ...compactU16(0), ...compactU16(data.length), ...data,
714
+ ]);
715
+ return concatBytes(Uint8Array.from(compactU16(1)), new Uint8Array(SIG_LEN), msg);
716
+ }
717
+ /** Split a signed wire transaction into its message and the FIRST (fee
718
+ * payer's) signature, or null. */
719
+ export function splitSignedTransaction(tx) {
720
+ const sc = readCompactU16(tx, 0);
721
+ if (!sc || sc[0] < 1)
722
+ return null;
723
+ const msgAt = sc[1] + sc[0] * SIG_LEN;
724
+ if (msgAt >= tx.length)
725
+ return null;
726
+ return { signature: tx.slice(sc[1], sc[1] + SIG_LEN), message: tx.slice(msgAt) };
727
+ }
728
+ /**
729
+ * Strictly check a transaction MESSAGE as a signature over `digest` by
730
+ * `sender`, and verify the signature. Accepted: legacy or v0 without address
731
+ * lookups; exactly one required signer, the fee payer = `sender`; exactly one
732
+ * SPL Memo instruction whose data is {@link envelopeSigningMemo}`(digest)`;
733
+ * nothing else except ComputeBudget instructions (some wallets add a priority
734
+ * fee before signing). Any blockhash (it is never broadcast).
735
+ */
736
+ export function verifyTransactionSignature(sender, digest, message, signature) {
737
+ try {
738
+ const pub = toBytes32(sender, "sender");
739
+ const senderB58 = encodeBase58(pub);
740
+ let at = 0;
741
+ const versioned = (message[0] & 0x80) !== 0;
742
+ if (versioned) {
743
+ if ((message[0] & 0x7f) !== 0)
744
+ return false; // v0 only
745
+ at = 1;
746
+ }
747
+ if (at + 3 > message.length)
748
+ return false;
749
+ if (message[at] !== 1)
750
+ return false; // exactly one required signature
751
+ at += 3;
752
+ const kc = readCompactU16(message, at);
753
+ if (!kc || kc[0] < 2)
754
+ return false;
755
+ at = kc[1];
756
+ const keys = [];
757
+ for (let i = 0; i < kc[0]; i++) {
758
+ if (at + 32 > message.length)
759
+ return false;
760
+ keys.push(encodeBase58(message.subarray(at, at + 32)));
761
+ at += 32;
762
+ }
763
+ if (keys[0] !== senderB58)
764
+ return false;
765
+ at += 32; // blockhash
766
+ const ic = readCompactU16(message, at);
767
+ if (!ic)
768
+ return false;
769
+ at = ic[1];
770
+ const want = TE.encode(envelopeSigningMemo(digest));
771
+ let memos = 0;
772
+ for (let i = 0; i < ic[0]; i++) {
773
+ if (at >= message.length)
774
+ return false;
775
+ const prog = keys[message[at]];
776
+ at += 1;
777
+ const ac = readCompactU16(message, at);
778
+ if (!ac)
779
+ return false;
780
+ for (let j = 0; j < ac[0]; j++)
781
+ if ((message[ac[1] + j] ?? 255) >= keys.length)
782
+ return false;
783
+ at = ac[1] + ac[0];
784
+ const dl = readCompactU16(message, at);
785
+ if (!dl)
786
+ return false;
787
+ if (dl[1] + dl[0] > message.length)
788
+ return false;
789
+ const data = message.subarray(dl[1], dl[1] + dl[0]);
790
+ at = dl[1] + dl[0];
791
+ if (prog === MSG_MEMO_PROGRAM_ID) {
792
+ if (!bytesEqual(data, want))
793
+ return false;
794
+ memos++;
795
+ }
796
+ else if (prog !== COMPUTE_BUDGET_PROGRAM_ID) {
797
+ return false;
798
+ }
799
+ }
800
+ if (memos !== 1)
801
+ return false;
802
+ if (versioned) {
803
+ const lc = readCompactU16(message, at);
804
+ if (!lc || lc[0] !== 0)
805
+ return false; // no address-table lookups
806
+ at = lc[1];
807
+ }
808
+ if (at !== message.length)
809
+ return false;
810
+ return ed25519.verify(signature, message, pub, { zip215: false });
811
+ }
812
+ catch {
813
+ return false;
814
+ }
815
+ }
816
+ /**
817
+ * The Solana off-chain message envelope (SIMD-0048, v0) some wallets wrap a
818
+ * `signMessage` payload in before signing: 0xff "solana offchain", version 0,
819
+ * a zero application domain, `format`, one signer, u16 length, the message.
820
+ * Same bytes as the X1ID app's unlock check.
821
+ */
822
+ export function solanaOffchainEnvelope(message, signer, format) {
823
+ if (message.length > 0xffff)
824
+ throw new MessagingError("bad-input", "message too long for an off-chain envelope");
825
+ const out = new Uint8Array(1 + 15 + 1 + 32 + 1 + 1 + 32 + 2 + message.length);
826
+ let o = 0;
827
+ out[o++] = 0xff;
828
+ out.set(TE.encode("solana offchain"), o);
829
+ o += 15;
830
+ out[o++] = 0; // version
831
+ o += 32; // application domain: zero
832
+ out[o++] = format;
833
+ out[o++] = 1; // signer count
834
+ out.set(signer, o);
835
+ o += 32;
836
+ out[o++] = message.length & 0xff;
837
+ out[o++] = (message.length >> 8) & 0xff;
838
+ out.set(message, o);
839
+ return out;
840
+ }
841
+ /**
842
+ * Verify a wallet's ed25519 signature over `message` — over the raw bytes, or
843
+ * over the SIMD-0048 envelope some wallets sign instead. Strict RFC 8032
844
+ * (non-canonical encodings and small-order keys refused). Returns which form
845
+ * verified, or null.
846
+ */
847
+ export function verifyWalletSignature(wallet, message, signature) {
848
+ let pub;
849
+ try {
850
+ pub = toBytes32(wallet, "wallet");
851
+ }
852
+ catch {
853
+ return null;
854
+ }
855
+ if (!(signature instanceof Uint8Array) || signature.length !== SIG_LEN)
856
+ return null;
857
+ const ok = (m) => {
858
+ try {
859
+ return ed25519.verify(signature, m, pub, { zip215: false });
860
+ }
861
+ catch {
862
+ return false;
863
+ }
864
+ };
865
+ if (ok(message))
866
+ return "raw";
867
+ for (const fmt of [0, 1, 2]) {
868
+ if (message.length <= 0xffff && ok(solanaOffchainEnvelope(message, pub, fmt)))
869
+ return `offchain-v0-fmt${fmt}`;
870
+ }
871
+ return null;
872
+ }
873
+ /** Does the envelope's signature verify for its sender? Returns the mode, or
874
+ * null. This is what the relay checks before storing anything. */
875
+ export function verifyEnvelopeSignature(p) {
876
+ if (p.signatureKind === "transaction") {
877
+ return p.signedTxMessage && verifyTransactionSignature(p.sender, p.digest, p.signedTxMessage, p.signature)
878
+ ? "transaction"
879
+ : null;
880
+ }
881
+ return verifyWalletSignature(p.sender, envelopeSigningMessage(p.digest, p.network), p.signature);
882
+ }
883
+ /**
884
+ * Is the envelope addressed to exactly this (trusted) key record: its
885
+ * recipient wallet is the record's inbox wallet `w` AND its recipient stanza
886
+ * (always the first) is for the record's key `k`? What the relay checks on
887
+ * send against the recipient's on-chain key (spec §6.3). Pure.
888
+ */
889
+ export function envelopeMatchesKey(p, key) {
890
+ const first = p.stanzas[0];
891
+ return p.recipient === key.wallet && first !== undefined && bytesEqual(first.key, key.publicKey);
892
+ }
893
+ /**
894
+ * Verify the sender's signature, then decrypt with `keys` (the reader's
895
+ * keypair; either side of the conversation). Throws {@link MessagingError}:
896
+ * `malformed`, `wrong-network`, `bad-signature`, `not-for-this-key`,
897
+ * `decrypt-failed`.
898
+ */
899
+ export function openMessage(env, keys, opts = {}) {
900
+ const p = parseEnvelope(env);
901
+ if (!p)
902
+ throw new MessagingError("malformed", "not a valid X1ID message envelope");
903
+ if (opts.network !== undefined && p.network !== opts.network) {
904
+ throw new MessagingError("wrong-network", `this message was sent on X1 ${p.network}, not ${opts.network}`);
905
+ }
906
+ const mode = verifyEnvelopeSignature(p);
907
+ if (!mode)
908
+ throw new MessagingError("bad-signature", "the sender's wallet signature does not verify");
909
+ const idx = p.stanzas.findIndex((s) => bytesEqual(s.key, keys.publicKey));
910
+ if (idx < 0)
911
+ throw new MessagingError("not-for-this-key", "this message was not encrypted to your key");
912
+ const st = p.stanzas[idx];
913
+ const fixed = p.body.subarray(0, FIXED_HEADER_LEN);
914
+ let cek;
915
+ let padded;
916
+ try {
917
+ const w = wrapKeys(x25519Shared(keys.secretKey, st.ephemeral), st.ephemeral, st.key);
918
+ cek = xchacha20poly1305(w.k, w.n, concatBytes(fixed, st.key)).decrypt(st.wrapped);
919
+ const nonceAt = FIXED_HEADER_LEN + p.stanzas.length * STANZA_LEN;
920
+ const aad = p.body.subarray(0, nonceAt + NONCE_LEN);
921
+ const ct = p.body.subarray(nonceAt + NONCE_LEN + 4);
922
+ padded = xchacha20poly1305(cek, p.body.slice(nonceAt, nonceAt + NONCE_LEN), aad).decrypt(ct);
923
+ }
924
+ catch {
925
+ throw new MessagingError("decrypt-failed", "the message could not be decrypted with this key");
926
+ }
927
+ cek.fill(0);
928
+ // The recipient stanza is always first; the second is the sender's copy.
929
+ return {
930
+ network: p.network,
931
+ sender: p.sender,
932
+ recipient: p.recipient,
933
+ createdAtMs: p.createdAtMs,
934
+ messageId: p.messageId,
935
+ text: unpad(padded),
936
+ digest: toHex(p.digest),
937
+ signatureMode: mode,
938
+ asSender: idx === 1,
939
+ };
940
+ }
941
+ /** Envelope bytes → the relay's wire string (base64url). */
942
+ export function encodeEnvelope(env) {
943
+ return base64urlEncode(env);
944
+ }
945
+ /** The relay's wire string → envelope bytes, or null. */
946
+ export function decodeEnvelope(s) {
947
+ return base64urlDecode(s);
948
+ }
949
+ // ---------------------------------------------------------------------------
950
+ // Optional on-chain proof of sending (sender-paid)
951
+ // ---------------------------------------------------------------------------
952
+ /** The proof memo for an envelope digest: `x1msg1:<64 hex>` (71 bytes). */
953
+ export function messageProofMemo(digest) {
954
+ if (digest.length !== 32)
955
+ throw new MessagingError("bad-input", "digest must be 32 bytes");
956
+ return MSG_PROOF_MEMO_PREFIX + toHex(digest);
957
+ }
958
+ /** Parse a proof memo back to its digest, or null. */
959
+ export function parseMessageProofMemo(memo) {
960
+ if (!memo.startsWith(MSG_PROOF_MEMO_PREFIX))
961
+ return null;
962
+ const hex = memo.slice(MSG_PROOF_MEMO_PREFIX.length);
963
+ if (hex.length !== 64)
964
+ return null;
965
+ return fromHex(hex);
966
+ }
967
+ /**
968
+ * The SPL Memo instruction that records "this wallet sent the message with
969
+ * this digest" on chain. The SENDER signs and pays the fee (X1ID pays nothing
970
+ * and never sees the transaction). The sender is listed as a signer of the
971
+ * memo itself, so the proof is attributable even if someone else paid.
972
+ */
973
+ export function buildMessageProofMemoIx(p) {
974
+ return {
975
+ programId: MSG_MEMO_PROGRAM_ID,
976
+ keys: [{ pubkey: b58(p.sender, "sender"), isSigner: true, isWritable: false }],
977
+ data: TE.encode(messageProofMemo(p.digest)),
978
+ };
979
+ }