stellar-shade 0.0.1 → 0.1.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/dist/index.js CHANGED
@@ -1,14 +1,15 @@
1
- import { randomBytes } from '@noble/hashes/utils';
2
- import { bytesToNumberLE, numberToBytesLE } from '@noble/curves/abstract/utils';
3
1
  import { sha256 } from '@noble/hashes/sha256';
4
2
  import { ed25519 } from '@noble/curves/ed25519';
3
+ import { randomBytes } from '@noble/hashes/utils';
4
+ import { bytesToNumberLE, numberToBytesLE } from '@noble/curves/abstract/utils';
5
5
  import { sha512 } from '@noble/hashes/sha512';
6
+ import '@noble/hashes/hmac';
6
7
  import { generateMnemonic as generateMnemonic$1, validateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
7
8
  import { wordlist } from '@scure/bip39/wordlists/english';
8
9
  import * as StellarSdk from '@stellar/stellar-sdk';
9
- import { Keypair, Contract, TransactionBuilder, nativeToScVal, StrKey, Networks, Asset } from '@stellar/stellar-sdk';
10
+ import { Networks, TransactionBuilder, Contract, Asset, Transaction, rpc, BASE_FEE, Operation, Keypair, nativeToScVal, StrKey, Memo, Account, Claimant, MuxedAccount } from '@stellar/stellar-sdk';
10
11
 
11
- // ../crypto/src/errors.ts
12
+ // ../crypto/dist/errors.js
12
13
  var PointAtInfinity = class extends Error {
13
14
  constructor(operation) {
14
15
  super(`Point at infinity encountered in ${operation}`);
@@ -34,21 +35,36 @@ var InvalidMetaAddress = class extends Error {
34
35
  }
35
36
  };
36
37
  var L = 2n ** 252n + 27742317777372353535851937790883648493n;
38
+ function generateRandomScalar() {
39
+ const bytes = randomBytes(32);
40
+ const scalar = bytesToNumberLE(bytes) % L;
41
+ return numberToBytesLE(scalar, 32);
42
+ }
37
43
  function validatePoint(point) {
38
44
  if (point.length !== 32) {
39
45
  throw new InvalidPublicKey("Invalid point length");
40
46
  }
47
+ let p;
41
48
  try {
42
- ed25519.ExtendedPoint.fromHex(point);
43
- return true;
49
+ p = ed25519.ExtendedPoint.fromHex(point);
44
50
  } catch (e) {
45
- if (e instanceof InvalidPublicKey) throw e;
51
+ if (e instanceof InvalidPublicKey)
52
+ throw e;
46
53
  throw new InvalidPublicKey("Point not on curve");
47
54
  }
55
+ if (p.equals(ed25519.ExtendedPoint.ZERO)) {
56
+ throw new InvalidPublicKey("Point is the identity element");
57
+ }
58
+ if (!p.isTorsionFree()) {
59
+ throw new InvalidPublicKey("Point is not in the prime-order subgroup");
60
+ }
61
+ return true;
48
62
  }
49
63
  function pointAdd(p1, p2) {
50
- if (p1.length !== 32) throw new InvalidPublicKey("Invalid p1 length");
51
- if (p2.length !== 32) throw new InvalidPublicKey("Invalid p2 length");
64
+ if (p1.length !== 32)
65
+ throw new InvalidPublicKey("Invalid p1 length");
66
+ if (p2.length !== 32)
67
+ throw new InvalidPublicKey("Invalid p2 length");
52
68
  validatePoint(p1);
53
69
  validatePoint(p2);
54
70
  const point1 = ed25519.ExtendedPoint.fromHex(p1);
@@ -60,7 +76,8 @@ function pointAdd(p1, p2) {
60
76
  return result.toRawBytes();
61
77
  }
62
78
  function scalarMultBase(scalar, allowZero = false) {
63
- if (scalar.length !== 32) throw new InvalidScalar("Invalid scalar length");
79
+ if (scalar.length !== 32)
80
+ throw new InvalidScalar("Invalid scalar length");
64
81
  const s = bytesToNumberLE(scalar) % L;
65
82
  if (s === 0n) {
66
83
  if (!allowZero) {
@@ -72,8 +89,10 @@ function scalarMultBase(scalar, allowZero = false) {
72
89
  return result.toRawBytes();
73
90
  }
74
91
  function scalarMult(scalar, point, allowZero = false) {
75
- if (scalar.length !== 32) throw new InvalidScalar("Invalid scalar length");
76
- if (point.length !== 32) throw new InvalidPublicKey("Invalid point length");
92
+ if (scalar.length !== 32)
93
+ throw new InvalidScalar("Invalid scalar length");
94
+ if (point.length !== 32)
95
+ throw new InvalidPublicKey("Invalid point length");
77
96
  validatePoint(point);
78
97
  const s = bytesToNumberLE(scalar) % L;
79
98
  if (s === 0n) {
@@ -90,15 +109,20 @@ function scalarMult(scalar, point, allowZero = false) {
90
109
  return result.toRawBytes();
91
110
  }
92
111
  function scalarAdd(s1, s2) {
93
- if (s1.length !== 32) throw new InvalidScalar("Invalid s1 length");
94
- if (s2.length !== 32) throw new InvalidScalar("Invalid s2 length");
112
+ if (s1.length !== 32)
113
+ throw new InvalidScalar("Invalid s1 length");
114
+ if (s2.length !== 32)
115
+ throw new InvalidScalar("Invalid s2 length");
95
116
  const a = bytesToNumberLE(s1) % L;
96
117
  const b = bytesToNumberLE(s2) % L;
97
118
  const result = (a + b) % L;
119
+ if (result === 0n) {
120
+ throw new InvalidScalar("Zero scalar not allowed");
121
+ }
98
122
  return numberToBytesLE(result, 32);
99
123
  }
100
124
 
101
- // ../crypto/src/keys.ts
125
+ // ../crypto/dist/keys.js
102
126
  function generateMetaAddress() {
103
127
  const spendPrivKey = generateRandomScalar();
104
128
  const viewPrivKey = generateRandomScalar();
@@ -113,11 +137,6 @@ function generateMetaAddress() {
113
137
  }
114
138
  };
115
139
  }
116
- function generateRandomScalar() {
117
- const bytes = randomBytes(32);
118
- const scalar = bytesToNumberLE(bytes) % L;
119
- return numberToBytesLE(scalar, 32);
120
- }
121
140
  function encodeMetaAddress(meta) {
122
141
  if (meta.spendPubKey.length !== 32) {
123
142
  throw new InvalidMetaAddress("Invalid spend public key length");
@@ -143,23 +162,22 @@ function encodeMetaAddress(meta) {
143
162
  combined.set(payload, 0);
144
163
  combined.set(checksum, 64);
145
164
  const hex = Array.from(combined).map((b) => b.toString(16).padStart(2, "0")).join("");
146
- return `st:stellar:${hex}`;
165
+ return `shade:stellar:${hex}`;
147
166
  }
148
167
  function decodeMetaAddress(encoded) {
149
- if (!encoded.startsWith("st:stellar:")) {
168
+ if (!encoded.startsWith("shade:stellar:")) {
150
169
  throw new InvalidMetaAddress("Invalid meta-address prefix");
151
170
  }
152
- const hex = encoded.slice(11);
171
+ const hex = encoded.slice(14);
153
172
  if (hex.length !== 136) {
154
173
  throw new InvalidMetaAddress("Invalid meta-address length");
155
174
  }
175
+ if (!/^[0-9a-fA-F]{136}$/.test(hex)) {
176
+ throw new InvalidMetaAddress("Invalid hex encoding");
177
+ }
156
178
  const combined = new Uint8Array(68);
157
179
  for (let i = 0; i < 68; i++) {
158
- const byte = parseInt(hex.substr(i * 2, 2), 16);
159
- if (isNaN(byte)) {
160
- throw new InvalidMetaAddress("Invalid hex encoding");
161
- }
162
- combined[i] = byte;
180
+ combined[i] = parseInt(hex.substr(i * 2, 2), 16);
163
181
  }
164
182
  const payload = combined.slice(0, 64);
165
183
  const checksum = combined.slice(64, 68);
@@ -207,7 +225,7 @@ function viewTag(sharedSecret) {
207
225
  return hash[0];
208
226
  }
209
227
 
210
- // ../crypto/src/stellar-keys.ts
228
+ // ../crypto/dist/stellar-keys.js
211
229
  var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
212
230
  var VERSION_BYTE_PUBLIC_KEY = 6 << 3;
213
231
  function crc16XModem(data) {
@@ -258,7 +276,7 @@ function encodePublicKey(pubKey) {
258
276
  return base32Encode(fullPayload);
259
277
  }
260
278
 
261
- // ../crypto/src/scan.ts
279
+ // ../crypto/dist/scan.js
262
280
  function checkViewTag(viewPrivKey, ephemeralPubKey, expectedTag) {
263
281
  if (viewPrivKey.length !== 32) {
264
282
  throw new Error("Invalid view private key length");
@@ -286,9 +304,13 @@ function scanAnnouncements(viewPrivKey, spendPubKey, announcements) {
286
304
  const results = [];
287
305
  const tagMatches = [];
288
306
  for (const announcement of announcements) {
289
- const tagResult = checkViewTag(viewPrivKey, announcement.ephemeralPubKey, announcement.viewTag);
290
- if (tagResult.matches && tagResult.sharedSecret) {
291
- tagMatches.push({ announcement, sharedSecret: tagResult.sharedSecret });
307
+ try {
308
+ const tagResult = checkViewTag(viewPrivKey, announcement.ephemeralPubKey, announcement.viewTag);
309
+ if (tagResult.matches && tagResult.sharedSecret) {
310
+ tagMatches.push({ announcement, sharedSecret: tagResult.sharedSecret });
311
+ }
312
+ } catch {
313
+ continue;
292
314
  }
293
315
  }
294
316
  for (const { announcement, sharedSecret } of tagMatches) {
@@ -305,24 +327,7 @@ function scanAnnouncements(viewPrivKey, spendPubKey, announcements) {
305
327
  }
306
328
  return results;
307
329
  }
308
-
309
- // ../crypto/src/recover.ts
310
- function recoverStealthPrivateKey(spendPrivKey, viewPrivKey, ephemeralPubKey) {
311
- if (spendPrivKey.length !== 32) {
312
- throw new Error("Invalid spend private key length");
313
- }
314
- if (viewPrivKey.length !== 32) {
315
- throw new Error("Invalid view private key length");
316
- }
317
- if (ephemeralPubKey.length !== 32) {
318
- throw new Error("Invalid ephemeral public key length");
319
- }
320
- const S = scalarMult(viewPrivKey, ephemeralPubKey);
321
- const s = hashToScalar(S);
322
- const stealthPrivKey = scalarAdd(spendPrivKey, s);
323
- return stealthPrivKey;
324
- }
325
- function signWithStealthKey(message, privateScalar) {
330
+ function signWithRawScalarBytes(message, privateScalar) {
326
331
  if (privateScalar.length !== 32) {
327
332
  throw new Error("Invalid stealth private key length");
328
333
  }
@@ -335,6 +340,7 @@ function signWithStealthKey(message, privateScalar) {
335
340
  nonceInput.set(privateScalar, 0);
336
341
  nonceInput.set(message, 32);
337
342
  const r = bytesToNumberLE(sha512(nonceInput)) % L;
343
+ nonceInput.fill(0);
338
344
  const R = ed25519.ExtendedPoint.BASE.multiply(r);
339
345
  const Rbytes = R.toRawBytes();
340
346
  const challengeInput = new Uint8Array(32 + 32 + message.length);
@@ -342,12 +348,114 @@ function signWithStealthKey(message, privateScalar) {
342
348
  challengeInput.set(pubKeyBytes, 32);
343
349
  challengeInput.set(message, 64);
344
350
  const k = bytesToNumberLE(sha512(challengeInput)) % L;
351
+ challengeInput.fill(0);
345
352
  const S = (r + k * a) % L;
346
353
  const sig = new Uint8Array(64);
347
354
  sig.set(Rbytes, 0);
348
355
  sig.set(numberToBytesLE(S, 32), 32);
349
356
  return sig;
350
357
  }
358
+ var StealthScalar = class _StealthScalar {
359
+ /** Private field: invisible to structural typing, inaccessible outside. */
360
+ #bytes;
361
+ constructor(bytes) {
362
+ this.#bytes = bytes;
363
+ }
364
+ /**
365
+ * Wrap a raw 32-byte ed25519 scalar (interop/tests). The bytes are
366
+ * defensively copied — mutating (or zeroing) the input afterwards does not
367
+ * affect the wrapper, and vice versa.
368
+ *
369
+ * @param bytes - 32-byte raw scalar (little-endian)
370
+ * @throws {InvalidScalar} If `bytes` is not exactly 32 bytes.
371
+ */
372
+ static fromBytes(bytes) {
373
+ if (bytes.length !== 32) {
374
+ throw new InvalidScalar("Invalid stealth private key length");
375
+ }
376
+ return new _StealthScalar(new Uint8Array(bytes));
377
+ }
378
+ /** Throw if the scalar has been zeroized (an all-zero scalar is never valid). */
379
+ #assertLive() {
380
+ if (this.#bytes.every((b) => b === 0)) {
381
+ throw new InvalidScalar("Stealth scalar is all-zero (already zeroized?) \u2014 it can no longer be used");
382
+ }
383
+ }
384
+ /**
385
+ * Sign a message with the wrapped raw scalar. Produces a standard ed25519
386
+ * signature that verifies against {@link publicKey} (scalar * G).
387
+ *
388
+ * @param message - The message to sign (must be non-empty)
389
+ * @returns 64-byte ed25519 signature (R || S)
390
+ * @throws {InvalidScalar} If the scalar has been {@link zeroize}d.
391
+ */
392
+ sign(message) {
393
+ this.#assertLive();
394
+ return signWithRawScalarBytes(message, this.#bytes);
395
+ }
396
+ /**
397
+ * The stealth public key this scalar signs for (scalar * G). Compare it to
398
+ * the announced stealth public key to verify correspondence.
399
+ *
400
+ * @throws {InvalidScalar} If the scalar has been {@link zeroize}d.
401
+ */
402
+ publicKey() {
403
+ this.#assertLive();
404
+ return scalarMultBase(this.#bytes);
405
+ }
406
+ /**
407
+ * Overwrite the wrapped scalar with zeros. Call once you are done signing;
408
+ * any later {@link sign}/{@link publicKey}/{@link dangerouslyToRawBytes}
409
+ * throws instead of operating on a dead key. Idempotent.
410
+ */
411
+ zeroize() {
412
+ this.#bytes.fill(0);
413
+ }
414
+ /**
415
+ * DANGER — escape hatch returning a copy of the raw 32-byte scalar. Misuse
416
+ * of these bytes permanently destroys funds:
417
+ *
418
+ * The value is a raw ed25519 SCALAR, **not an ed25519 seed**. Seed-based
419
+ * Keypair APIs (`Keypair.fromRawEd25519Seed()`, `ed25519.sign()`, wallet
420
+ * key imports) HASH the input to derive a different signing scalar, so the
421
+ * resulting keypair's public key does NOT match the stealth address — the
422
+ * contract/network rejects its signatures and the funds at that address
423
+ * become PERMANENTLY UNWITHDRAWABLE.
424
+ *
425
+ * Only pass these bytes to APIs that consume raw scalars directly (e.g. the
426
+ * deprecated `signWithStealthKey(message, rawBytes)` overload). Prefer
427
+ * {@link sign}/{@link publicKey}, and zero any copy you make (`copy.fill(0)`)
428
+ * as soon as you are done with it.
429
+ *
430
+ * @returns A fresh copy of the 32-byte raw scalar.
431
+ * @throws {InvalidScalar} If the scalar has been {@link zeroize}d.
432
+ */
433
+ dangerouslyToRawBytes() {
434
+ this.#assertLive();
435
+ return new Uint8Array(this.#bytes);
436
+ }
437
+ };
438
+
439
+ // ../crypto/dist/recover.js
440
+ function recoverStealthPrivateKey(spendPrivKey, viewPrivKey, ephemeralPubKey) {
441
+ if (spendPrivKey.length !== 32) {
442
+ throw new Error("Invalid spend private key length");
443
+ }
444
+ if (viewPrivKey.length !== 32) {
445
+ throw new Error("Invalid view private key length");
446
+ }
447
+ if (ephemeralPubKey.length !== 32) {
448
+ throw new Error("Invalid ephemeral public key length");
449
+ }
450
+ const S = scalarMult(viewPrivKey, ephemeralPubKey);
451
+ const s = hashToScalar(S);
452
+ const stealthPrivKey = scalarAdd(spendPrivKey, s);
453
+ const scalar = StealthScalar.fromBytes(stealthPrivKey);
454
+ stealthPrivKey.fill(0);
455
+ return scalar;
456
+ }
457
+ new TextEncoder().encode("amount");
458
+ new TextEncoder().encode("amount-mac");
351
459
  function deriveStealthAddressWithSecret(spendPubKey, viewPubKey, ephemeralPrivKey) {
352
460
  const R = scalarMultBase(ephemeralPrivKey);
353
461
  const S = scalarMult(ephemeralPrivKey, viewPubKey);
@@ -373,8 +481,8 @@ function mnemonicToStealthKeys(mnemonic, passphrase) {
373
481
  throw new Error("Invalid mnemonic phrase");
374
482
  }
375
483
  const seed = mnemonicToSeedSync(mnemonic, "");
376
- const spendTag = textEncoder.encode("stealth-spend");
377
- const viewTag2 = textEncoder.encode("stealth-view");
484
+ const spendTag = textEncoder.encode("shade-spend");
485
+ const viewTag2 = textEncoder.encode("shade-view");
378
486
  const spendInput = new Uint8Array(spendTag.length + seed.length);
379
487
  spendInput.set(spendTag, 0);
380
488
  spendInput.set(seed, spendTag.length);
@@ -394,13 +502,347 @@ function mnemonicToStealthKeys(mnemonic, passphrase) {
394
502
  metaAddress: { spendPubKey, viewPubKey }
395
503
  };
396
504
  }
505
+
506
+ // ../crypto/dist/derive-signature.js
507
+ var textEncoder2 = new TextEncoder();
508
+ var KEY_DERIVATION_CONTEXT_V1 = "stellar-shade-keys-v1";
509
+ function buildKeyDerivationMessage(opts) {
510
+ const network = opts?.network ?? "any";
511
+ const appId = opts?.appId ?? "default";
512
+ return [
513
+ KEY_DERIVATION_CONTEXT_V1,
514
+ `network:${network}`,
515
+ `app:${appId}`,
516
+ "WARNING: Signing this message derives your stealth keys. Only sign it in apps you trust."
517
+ ].join("\n");
518
+ }
519
+ function deriveKeysFromSignature(signature) {
520
+ if (signature.length !== 64) {
521
+ throw new InvalidScalar("Signature must be 64 bytes");
522
+ }
523
+ if (signature.every((byte) => byte === 0)) {
524
+ throw new InvalidScalar("Signature must not be all zeros");
525
+ }
526
+ const spendTag = textEncoder2.encode("shade-spend");
527
+ const viewTag2 = textEncoder2.encode("shade-view");
528
+ const spendInput = new Uint8Array(spendTag.length + signature.length);
529
+ spendInput.set(spendTag, 0);
530
+ spendInput.set(signature, spendTag.length);
531
+ const viewInput = new Uint8Array(viewTag2.length + signature.length);
532
+ viewInput.set(viewTag2, 0);
533
+ viewInput.set(signature, viewTag2.length);
534
+ const spendPrivKey = hashToScalar(spendInput);
535
+ const viewPrivKey = hashToScalar(viewInput);
536
+ spendInput.fill(0);
537
+ viewInput.fill(0);
538
+ const spendPubKey = scalarMultBase(spendPrivKey);
539
+ const viewPubKey = scalarMultBase(viewPrivKey);
540
+ return {
541
+ spendPrivKey,
542
+ viewPrivKey,
543
+ metaAddress: { spendPubKey, viewPubKey }
544
+ };
545
+ }
546
+
547
+ // src/errors.ts
548
+ var ShadeError = class extends Error {
549
+ /**
550
+ * Stable machine-readable identifier for this error kind (snake_case).
551
+ * Guaranteed not to change across SDK versions, unlike the message text.
552
+ */
553
+ code;
554
+ constructor(code, message) {
555
+ super(message);
556
+ this.name = "ShadeError";
557
+ this.code = code;
558
+ }
559
+ };
560
+ var MethodRequiredError = class extends ShadeError {
561
+ constructor() {
562
+ super(
563
+ "method_required",
564
+ "A delivery method is required. Pass opts.method: 'pool' | 'account' | 'auto'."
565
+ );
566
+ this.name = "MethodRequiredError";
567
+ }
568
+ };
569
+ var MethodNotEnabledError = class extends ShadeError {
570
+ constructor(method) {
571
+ super(
572
+ "method_not_enabled",
573
+ `Delivery method '${method}' is not enabled. Add it to ClientConfig.methods to use it.`
574
+ );
575
+ this.name = "MethodNotEnabledError";
576
+ }
577
+ };
578
+ var MethodNotAvailableError = class extends ShadeError {
579
+ constructor(message) {
580
+ super("method_not_available", message);
581
+ this.name = "MethodNotAvailableError";
582
+ }
583
+ };
584
+ var MinimumAmountError = class extends ShadeError {
585
+ constructor(amount) {
586
+ super(
587
+ "minimum_amount",
588
+ `Account sends require an amount strictly greater than 1 XLM (got ${amount}).`
589
+ );
590
+ this.name = "MinimumAmountError";
591
+ }
592
+ };
593
+ var ClaimAmountError = class extends ShadeError {
594
+ /** The maximum amount that could be claimed for this account. */
595
+ max;
596
+ constructor(requested, max) {
597
+ super(
598
+ "claim_amount_exceeds_max",
599
+ `Partial claim of ${requested} XLM exceeds the maximum claimable ${max} XLM (account must retain its base reserve and cover the fee).`
600
+ );
601
+ this.name = "ClaimAmountError";
602
+ this.max = max;
603
+ }
604
+ };
605
+ var ClaimAmountRequiresNoMergeError = class extends ShadeError {
606
+ constructor(kind = "native-merge") {
607
+ super(
608
+ "claim_amount_requires_no_merge",
609
+ kind === "token" ? "opts.amount is not supported on an account-method token claim: the claimable balance is always claimed (and paid out) in full. Drop opts.amount, then transfer any partial amount onward separately." : "opts.amount was passed but this account-method native claim would MERGE the stealth account and sweep its ENTIRE balance, ignoring amount. Pass opts.merge: false for a partial claim, or drop opts.amount to sweep."
610
+ );
611
+ this.name = "ClaimAmountRequiresNoMergeError";
612
+ }
613
+ };
614
+ var InvalidAmountError = class extends ShadeError {
615
+ constructor(amount) {
616
+ super(
617
+ "invalid_amount",
618
+ `Amount must be a positive finite number (got ${String(amount)}).`
619
+ );
620
+ this.name = "InvalidAmountError";
621
+ }
622
+ };
623
+ var SponsoredClaimMismatchError = class extends ShadeError {
624
+ constructor(detail) {
625
+ super(
626
+ "sponsored_claim_mismatch",
627
+ `Refusing to sign sponsored claim: prepared transaction does not match the expected operations (${detail}). The relayer may be malicious.`
628
+ );
629
+ this.name = "SponsoredClaimMismatchError";
630
+ }
631
+ };
632
+ var WrongPasswordError = class extends ShadeError {
633
+ constructor() {
634
+ super(
635
+ "wrong_password",
636
+ "Wrong password: could not decrypt the stored stealth keys."
637
+ );
638
+ this.name = "WrongPasswordError";
639
+ }
640
+ };
641
+ var SessionIntegrityError = class extends ShadeError {
642
+ constructor(which) {
643
+ super(
644
+ "session_integrity",
645
+ `Session integrity check failed: the stored ${which} public key does not match the decrypted private key. The stored session may have been tampered with.`
646
+ );
647
+ this.name = "SessionIntegrityError";
648
+ }
649
+ };
650
+ var NoBalanceError = class extends ShadeError {
651
+ constructor() {
652
+ super("no_balance", "Stealth address has no balance in the pool.");
653
+ this.name = "NoBalanceError";
654
+ }
655
+ };
656
+ var AnnouncementNotFoundError = class extends ShadeError {
657
+ constructor() {
658
+ super(
659
+ "announcement_not_found",
660
+ "Could not find announcement for this stealth address."
661
+ );
662
+ this.name = "AnnouncementNotFoundError";
663
+ }
664
+ };
665
+ var StealthAccountNotFoundError = class extends ShadeError {
666
+ constructor() {
667
+ super(
668
+ "stealth_account_not_found",
669
+ "Stealth account not found on Horizon \u2014 has the send confirmed?"
670
+ );
671
+ this.name = "StealthAccountNotFoundError";
672
+ }
673
+ };
674
+ var DestinationTrustlineError = class extends ShadeError {
675
+ constructor(message) {
676
+ super("destination_trustline", message);
677
+ this.name = "DestinationTrustlineError";
678
+ }
679
+ };
680
+ var FeePayerRequiredError = class extends ShadeError {
681
+ constructor() {
682
+ super(
683
+ "fee_payer_required",
684
+ "A fee payer secret is required for pool withdrawals."
685
+ );
686
+ this.name = "FeePayerRequiredError";
687
+ }
688
+ };
689
+ var FeePayerAddressRequiredError = class extends ShadeError {
690
+ constructor() {
691
+ super(
692
+ "fee_payer_address_required",
693
+ "feePayerAddress is required when signTransaction is set on a pool claim. Pass the fee payer G-address (opts.feePayerAddress) \u2014 the SDK signs it via signTransaction."
694
+ );
695
+ this.name = "FeePayerAddressRequiredError";
696
+ }
697
+ };
698
+ var EntryArchivedRestoringError = class extends ShadeError {
699
+ constructor(cause) {
700
+ super(
701
+ "entry_archived_restoring",
702
+ `Stealth entry is archived and the automatic restore failed (${cause}). The funds are safe but the withdraw cannot proceed until the Balance/Nonce entry is restored \u2014 retry, or restore the footprint manually.`
703
+ );
704
+ this.name = "EntryArchivedRestoringError";
705
+ }
706
+ };
707
+ var TransactionRetryableError = class extends ShadeError {
708
+ /** Marks this error as safe to retry (the tx never entered the ledger). */
709
+ retryable = true;
710
+ constructor(status) {
711
+ super(
712
+ "transaction_retryable",
713
+ `Transaction was not submitted (RPC status: ${status}). Nothing landed on-chain \u2014 retry the submission with a fresh transaction.`
714
+ );
715
+ this.name = "TransactionRetryableError";
716
+ }
717
+ };
718
+ var TransactionTimeoutError = class extends ShadeError {
719
+ /**
720
+ * NOT safe to blind-retry: unlike {@link TransactionRetryableError}, the
721
+ * transaction may still be applied after this error is thrown.
722
+ */
723
+ retryable = false;
724
+ /** Hash of the still-pending transaction — poll it before any resubmission. */
725
+ txHash;
726
+ constructor(txHash) {
727
+ super(
728
+ "transaction_timeout",
729
+ `Transaction ${txHash} confirmation timed out while still pending. It may STILL land on-chain \u2014 do NOT blindly resubmit (risk of double-send); poll this hash to a terminal status first.`
730
+ );
731
+ this.name = "TransactionTimeoutError";
732
+ this.txHash = txHash;
733
+ }
734
+ };
735
+ var RelayerHttpError = class extends ShadeError {
736
+ /** HTTP status the relayer responded with. */
737
+ status;
738
+ /** The relayer's own `code` field from the error body, when present. */
739
+ relayerCode;
740
+ constructor(path, status, relayerCode, detail) {
741
+ super(
742
+ "relayer_http_error",
743
+ `Relayer ${path} failed (${status}): ${relayerCode ?? detail ?? "unknown"}`
744
+ );
745
+ this.name = "RelayerHttpError";
746
+ this.status = status;
747
+ this.relayerCode = relayerCode;
748
+ }
749
+ };
750
+ var RelayerNetworkError = class extends ShadeError {
751
+ constructor(path, detail) {
752
+ super("relayer_unreachable", `Relayer ${path} unreachable: ${detail}`);
753
+ this.name = "RelayerNetworkError";
754
+ }
755
+ };
756
+ var IndexerHttpError = class extends ShadeError {
757
+ /** HTTP status the indexer responded with. */
758
+ status;
759
+ /** The indexer's own `code` field from the error body, when present. */
760
+ indexerCode;
761
+ constructor(path, status, indexerCode, detail) {
762
+ super(
763
+ "indexer_http_error",
764
+ `Indexer ${path} failed (${status}): ${indexerCode ?? detail ?? "unknown"}`
765
+ );
766
+ this.name = "IndexerHttpError";
767
+ this.status = status;
768
+ this.indexerCode = indexerCode;
769
+ }
770
+ };
771
+ var IndexerNetworkError = class extends ShadeError {
772
+ constructor(path, detail) {
773
+ super("indexer_network_error", `Indexer ${path} unreachable: ${detail}`);
774
+ this.name = "IndexerNetworkError";
775
+ }
776
+ };
777
+ var NoHealthyRelayerError = class extends ShadeError {
778
+ /** Per-URL rejection reason for every probed candidate. */
779
+ candidates;
780
+ constructor(candidates) {
781
+ const detail = Object.entries(candidates).map(([url, reason]) => `${url}: ${reason}`).join("; ");
782
+ super(
783
+ "no_healthy_relayer",
784
+ `No healthy relayer among ${Object.keys(candidates).length} candidate(s) \u2014 ${detail}`
785
+ );
786
+ this.name = "NoHealthyRelayerError";
787
+ this.candidates = candidates;
788
+ }
789
+ };
790
+ var ContractIdRequiredError = class extends ShadeError {
791
+ constructor(network) {
792
+ super(
793
+ "contract_id_required",
794
+ `contractId is required for network '${network}'. Deploy the pool contract and pass its C-address as ClientConfig.contractId (there is no built-in default).`
795
+ );
796
+ this.name = "ContractIdRequiredError";
797
+ }
798
+ };
799
+ var UnsupportedNetworkError = class extends ShadeError {
800
+ /** The unknown network name that was requested. */
801
+ network;
802
+ /** The network names this SDK build supports. */
803
+ supported;
804
+ constructor(network, supported) {
805
+ super(
806
+ "unsupported_network",
807
+ `Unsupported network '${network}'. Supported: ${supported.join(", ")}. (The local network has been removed \u2014 dev/test runs on testnet; mainnet arrives after the external audit.)`
808
+ );
809
+ this.name = "UnsupportedNetworkError";
810
+ this.network = network;
811
+ this.supported = supported;
812
+ }
813
+ };
814
+
815
+ // src/soroban.ts
816
+ var NETWORKS = {
817
+ testnet: {
818
+ networkPassphrase: Networks.TESTNET,
819
+ rpcUrl: "https://soroban-testnet.stellar.org",
820
+ horizonUrl: "https://horizon-testnet.stellar.org",
821
+ allowHttp: false
822
+ }
823
+ // post-audit (mainnet): uncomment and fill in the production RPC endpoint.
824
+ // public: {
825
+ // networkPassphrase: Networks.PUBLIC,
826
+ // rpcUrl: '<soroban mainnet rpc>',
827
+ // horizonUrl: 'https://horizon.stellar.org',
828
+ // allowHttp: false,
829
+ // },
830
+ };
831
+ function networkNameForPassphrase(passphrase) {
832
+ for (const [name, def] of Object.entries(NETWORKS)) {
833
+ if (def.networkPassphrase === passphrase) return name;
834
+ }
835
+ return void 0;
836
+ }
397
837
  function getNetworkConfig(network) {
398
- const networkPassphrase = network === "local" ? Networks.STANDALONE : Networks.TESTNET;
399
- const rpcUrl = network === "local" ? "http://localhost:8000/soroban/rpc" : "https://soroban-testnet.stellar.org";
400
- const server = new StellarSdk.rpc.Server(rpcUrl, {
401
- allowHttp: network === "local"
402
- });
403
- return { networkPassphrase, rpcUrl, server };
838
+ const def = NETWORKS[network];
839
+ if (!def) {
840
+ throw new UnsupportedNetworkError(network, Object.keys(NETWORKS));
841
+ }
842
+ return {
843
+ ...def,
844
+ server: new StellarSdk.rpc.Server(def.rpcUrl, { allowHttp: def.allowHttp })
845
+ };
404
846
  }
405
847
  function createSimulationTx(operation, networkPassphrase) {
406
848
  return new TransactionBuilder(
@@ -429,6 +871,17 @@ function resolveTokenAddress(assetArg, networkPassphrase) {
429
871
  }
430
872
  return new Asset(parts[0], parts[1]).contractId(networkPassphrase);
431
873
  }
874
+ function labelForToken(tokenAddress, networkPassphrase) {
875
+ if (!tokenAddress || tokenAddress === "unknown") return tokenAddress;
876
+ if (tokenAddress === "native" || tokenAddress === "XLM") return "XLM";
877
+ try {
878
+ if (tokenAddress === Asset.native().contractId(networkPassphrase)) {
879
+ return "XLM";
880
+ }
881
+ } catch {
882
+ }
883
+ return tokenAddress;
884
+ }
432
885
  async function waitForTransaction(server, hash) {
433
886
  let attempts = 0;
434
887
  while (attempts < 30) {
@@ -443,13 +896,32 @@ async function waitForTransaction(server, hash) {
443
896
  }
444
897
  attempts++;
445
898
  }
446
- throw new Error("Transaction confirmation timed out");
899
+ throw new TransactionTimeoutError(hash);
900
+ }
901
+ async function resolveSendResult(server, result) {
902
+ switch (result.status) {
903
+ case "SUCCESS":
904
+ return result.hash;
905
+ case "PENDING":
906
+ await waitForTransaction(server, result.hash);
907
+ return result.hash;
908
+ case "DUPLICATE":
909
+ await waitForTransaction(server, result.hash);
910
+ return result.hash;
911
+ case "ERROR":
912
+ throw new Error("Transaction submission failed");
913
+ default:
914
+ throw new TransactionRetryableError(result.status);
915
+ }
447
916
  }
448
- async function fetchAnnouncements(contractId, server, networkPassphrase) {
917
+ async function fetchAnnouncements(contractId, server, networkPassphrase, start = 0, limit = 1e3) {
449
918
  const result = await simulateReadOnly(
450
919
  contractId,
451
920
  "get_announcements",
452
- [nativeToScVal(0, { type: "u64" }), nativeToScVal(1e3, { type: "u64" })],
921
+ [
922
+ nativeToScVal(start, { type: "u64" }),
923
+ nativeToScVal(limit, { type: "u64" })
924
+ ],
453
925
  server,
454
926
  networkPassphrase
455
927
  );
@@ -467,6 +939,17 @@ async function fetchAnnouncements(contractId, server, networkPassphrase) {
467
939
  };
468
940
  });
469
941
  }
942
+ async function fetchAnnouncementCount(contractId, server, networkPassphrase) {
943
+ const result = await simulateReadOnly(
944
+ contractId,
945
+ "get_announcement_count",
946
+ [],
947
+ server,
948
+ networkPassphrase
949
+ );
950
+ if (result === null || result === void 0) return 0;
951
+ return Number(result);
952
+ }
470
953
  async function queryBalance(contractId, stealthPk, tokenAddress, server, networkPassphrase) {
471
954
  const result = await simulateReadOnly(
472
955
  contractId,
@@ -502,15 +985,21 @@ function u64ToBigEndian(value) {
502
985
  dv.setBigUint64(0, value);
503
986
  return buf;
504
987
  }
505
- function buildWithdrawMessage(stealthPk, tokenAddress, amount, destination, nonce) {
988
+ function buildWithdrawMessage(stealthPk, tokenAddress, amount, destination, nonce, contractId, networkPassphrase) {
989
+ const domainTag = Buffer.from("SHADE-POOL-WITHDRAW-V1", "utf-8");
506
990
  const tokenBytes = Buffer.from(tokenAddress, "utf-8");
507
991
  const destBytes = Buffer.from(destination, "utf-8");
992
+ const contractBytes = Buffer.from(contractId, "utf-8");
508
993
  if (tokenBytes.length !== 56) throw new Error(`Token address must be 56 bytes StrKey, got ${tokenBytes.length}`);
509
994
  if (destBytes.length !== 56) throw new Error(`Destination must be 56 bytes StrKey, got ${destBytes.length}`);
995
+ if (contractBytes.length !== 56) throw new Error(`Contract address must be 56 bytes StrKey, got ${contractBytes.length}`);
510
996
  const amountBytes = i128ToBigEndian(amount);
511
997
  const nonceBytes = u64ToBigEndian(nonce);
512
- const msg = new Uint8Array(32 + 56 + 16 + 56 + 8);
998
+ const networkId = sha256(Buffer.from(networkPassphrase, "utf-8"));
999
+ const msg = new Uint8Array(22 + 32 + 56 + 16 + 56 + 8 + 56 + 32);
513
1000
  let offset = 0;
1001
+ msg.set(domainTag, offset);
1002
+ offset += 22;
514
1003
  msg.set(stealthPk, offset);
515
1004
  offset += 32;
516
1005
  msg.set(tokenBytes, offset);
@@ -520,73 +1009,910 @@ function buildWithdrawMessage(stealthPk, tokenAddress, amount, destination, nonc
520
1009
  msg.set(destBytes, offset);
521
1010
  offset += 56;
522
1011
  msg.set(nonceBytes, offset);
1012
+ offset += 8;
1013
+ msg.set(contractBytes, offset);
1014
+ offset += 56;
1015
+ msg.set(networkId, offset);
523
1016
  return sha256(msg);
524
1017
  }
525
1018
 
526
- // src/client.ts
527
- var DEFAULT_CONTRACTS = {
528
- local: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGABAX"
529
- };
530
- var StealthClient = class {
531
- contractId;
532
- networkPassphrase;
533
- server;
534
- constructor(config) {
535
- this.contractId = config.contractId || DEFAULT_CONTRACTS[config.network] || "";
536
- const netConfig = getNetworkConfig(config.network);
537
- this.networkPassphrase = netConfig.networkPassphrase;
538
- this.server = netConfig.server;
1019
+ // src/wallet.ts
1020
+ function stealthKeysFromRaw(raw) {
1021
+ return {
1022
+ metaAddress: encodeMetaAddress(raw.metaAddress),
1023
+ spendPubKey: Buffer.from(raw.metaAddress.spendPubKey).toString("hex"),
1024
+ spendPrivKey: Buffer.from(raw.spendPrivKey).toString("hex"),
1025
+ viewPubKey: Buffer.from(raw.metaAddress.viewPubKey).toString("hex"),
1026
+ viewPrivKey: Buffer.from(raw.viewPrivKey).toString("hex")
1027
+ };
1028
+ }
1029
+ var DEFAULT_KEY_SCOPE = "stealth";
1030
+ var DEFAULT_APP_ID = "default";
1031
+ function decodeToBytes(raw) {
1032
+ const isSignatureHex = raw.length === 128 && /^[0-9a-fA-F]+$/.test(raw);
1033
+ const bytes = isSignatureHex ? new Uint8Array(Buffer.from(raw, "hex")) : new Uint8Array(Buffer.from(raw, "base64"));
1034
+ if (bytes.length !== 64) {
1035
+ throw new Error(
1036
+ `Wallet signature must decode to exactly 64 bytes, got ${bytes.length}`
1037
+ );
1038
+ }
1039
+ return bytes;
1040
+ }
1041
+ function normalizeSignature(result) {
1042
+ let bytes;
1043
+ if (result instanceof Uint8Array) {
1044
+ bytes = result;
1045
+ } else if (typeof result === "string") {
1046
+ bytes = decodeToBytes(result);
1047
+ } else if (result && typeof result === "object" && "signedMessage" in result) {
1048
+ const inner = result.signedMessage;
1049
+ bytes = inner instanceof Uint8Array ? inner : decodeToBytes(inner);
1050
+ } else {
1051
+ throw new Error("Unsupported signer result shape");
1052
+ }
1053
+ if (bytes.length !== 64) {
1054
+ throw new Error(
1055
+ `Wallet signature must be exactly 64 bytes, got ${bytes.length}`
1056
+ );
1057
+ }
1058
+ return bytes;
1059
+ }
1060
+ function equalBytes(a, b) {
1061
+ if (a.length !== b.length) return false;
1062
+ for (let i = 0; i < a.length; i++) {
1063
+ if (a[i] !== b[i]) return false;
1064
+ }
1065
+ return true;
1066
+ }
1067
+ async function keysFromWalletSignature(signer, opts) {
1068
+ const message = buildKeyDerivationMessage({
1069
+ network: opts?.keyScope ?? DEFAULT_KEY_SCOPE,
1070
+ appId: opts?.appId ?? DEFAULT_APP_ID
1071
+ });
1072
+ const signature = normalizeSignature(await signer(message));
1073
+ if (opts?.verifyDeterminism !== false) {
1074
+ const second = normalizeSignature(await signer(message));
1075
+ if (!equalBytes(signature, second)) {
1076
+ throw new Error(
1077
+ "Signer is non-deterministic: two signatures over the same message differ. Wallet-derived stealth keys require a deterministic (RFC 8032) signer."
1078
+ );
1079
+ }
539
1080
  }
1081
+ return stealthKeysFromRaw(deriveKeysFromSignature(signature));
1082
+ }
1083
+
1084
+ // src/horizon.ts
1085
+ var CLAIMABLE_BALANCE_PAGE_LIMIT = 200;
1086
+ var MAX_CLAIMABLE_BALANCE_PAGES = 50;
1087
+ var HorizonClient = class {
1088
+ baseUrl;
1089
+ fetchFn;
540
1090
  /**
541
- * Generate a new random stealth key pair.
542
- * No network connection needed.
1091
+ * @param baseUrl - Horizon root URL (no trailing slash required).
1092
+ * @param fetchFn - Injectable fetch (defaults to the global `fetch`).
543
1093
  */
544
- static keygen() {
545
- const keys = generateMetaAddress();
546
- const metaAddress = encodeMetaAddress(keys.metaAddress);
547
- return {
548
- metaAddress,
549
- spendPubKey: Buffer.from(keys.metaAddress.spendPubKey).toString("hex"),
550
- spendPrivKey: Buffer.from(keys.spendPrivKey).toString("hex"),
551
- viewPubKey: Buffer.from(keys.metaAddress.viewPubKey).toString("hex"),
552
- viewPrivKey: Buffer.from(keys.viewPrivKey).toString("hex")
553
- };
1094
+ constructor(baseUrl, fetchFn) {
1095
+ this.baseUrl = baseUrl.replace(/\/$/, "");
1096
+ this.fetchFn = fetchFn ?? globalThis.fetch;
1097
+ }
1098
+ async getJson(path) {
1099
+ const res = await this.fetchFn(`${this.baseUrl}${path}`);
1100
+ if (!res.ok) {
1101
+ throw new Error(`Horizon GET ${path} failed: ${res.status}`);
1102
+ }
1103
+ return await res.json();
554
1104
  }
555
1105
  /**
556
- * Generate stealth keys from a BIP-39 mnemonic.
557
- * Returns the mnemonic alongside the keys for backup.
558
- * No network connection needed.
1106
+ * Page transactions in ascending order.
1107
+ *
1108
+ * @param cursor - Paging token to resume from (omit for the beginning).
1109
+ * @param limit - Page size (default 200, Horizon's max).
1110
+ * @returns The page's transaction records (may be empty).
559
1111
  */
560
- static fromMnemonic(mnemonic) {
561
- const phrase = mnemonic || generateMnemonic();
562
- const keys = mnemonicToStealthKeys(phrase);
563
- const metaAddress = encodeMetaAddress(keys.metaAddress);
564
- return {
565
- mnemonic: phrase,
566
- metaAddress,
567
- spendPubKey: Buffer.from(keys.metaAddress.spendPubKey).toString("hex"),
568
- spendPrivKey: Buffer.from(keys.spendPrivKey).toString("hex"),
569
- viewPubKey: Buffer.from(keys.metaAddress.viewPubKey).toString("hex"),
570
- viewPrivKey: Buffer.from(keys.viewPrivKey).toString("hex")
571
- };
1112
+ async getTransactions(cursor, limit = 200) {
1113
+ const params = new URLSearchParams({
1114
+ order: "asc",
1115
+ limit: String(limit)
1116
+ });
1117
+ if (cursor) params.set("cursor", cursor);
1118
+ const page = await this.getJson(
1119
+ `/transactions?${params.toString()}`
1120
+ );
1121
+ return page._embedded?.records ?? [];
572
1122
  }
573
1123
  /**
574
- * Send tokens to a stealth address.
575
- *
576
- * Derives a one-time stealth address from the recipient's meta-address,
577
- * deposits tokens into the pool contract, and records the announcement.
1124
+ * The paging token of the newest transaction this Horizon serves (one
1125
+ * `order=desc&limit=1` page), or undefined on an empty network. The scan
1126
+ * uses it to sanity-clamp cursors adopted from indexer-supplied data: a
1127
+ * persisted scan cursor must never exceed the chain position Horizon
1128
+ * itself reports, or a malicious feed could blind every future scan.
1129
+ */
1130
+ async getLatestTransactionToken() {
1131
+ const params = new URLSearchParams({ order: "desc", limit: "1" });
1132
+ const page = await this.getJson(
1133
+ `/transactions?${params.toString()}`
1134
+ );
1135
+ return page._embedded?.records?.[0]?.paging_token;
1136
+ }
1137
+ /**
1138
+ * Fetch the operations belonging to a single transaction.
578
1139
  *
579
- * @param metaAddress - Recipient's meta-address (st:stellar:... format)
580
- * @param amount - Amount in whole units (e.g. 100 = 100 XLM)
581
- * @param senderSecret - Sender's Stellar secret key
582
- * @param opts - Optional: asset to send
1140
+ * @param txHash - Transaction hash.
1141
+ * @returns The transaction's operation records.
583
1142
  */
584
- async send(metaAddress, amount, senderSecret, opts) {
585
- if (amount <= 0) throw new Error("Amount must be positive");
1143
+ async getTransactionOperations(txHash) {
1144
+ const page = await this.getJson(
1145
+ `/transactions/${txHash}/operations?limit=200`
1146
+ );
1147
+ return page._embedded?.records ?? [];
1148
+ }
1149
+ /**
1150
+ * Probe an account. Returns null if the account does not exist (404).
1151
+ *
1152
+ * @param address - Stellar G-address.
1153
+ */
1154
+ async getAccount(address) {
1155
+ const res = await this.fetchFn(`${this.baseUrl}/accounts/${address}`);
1156
+ if (res.status === 404) return null;
1157
+ if (!res.ok) throw new Error(`Horizon GET account failed: ${res.status}`);
1158
+ return await res.json();
1159
+ }
1160
+ /**
1161
+ * List ALL claimable balances for which the given address is a claimant,
1162
+ * paging through every Horizon page (not just the first).
1163
+ *
1164
+ * Used by the token account method: a direct token send lands as a
1165
+ * CreateClaimableBalance to the derived stealth address, which the recipient
1166
+ * later claims. Returns an empty array when the account has none.
1167
+ *
1168
+ * Paging matters for correctness, not just completeness: anyone can create a
1169
+ * claimable balance naming a (public, on-chain) stealth address as claimant,
1170
+ * so an attacker could spam cheap balances to push the genuine payment past
1171
+ * the first page and make the scanner silently miss it. Each page is resumed
1172
+ * via the last record's `paging_token` (falling back to the `cursor` in
1173
+ * `_links.next.href`), stopping on a short page, a non-advancing cursor, or
1174
+ * the defensive {@link MAX_CLAIMABLE_BALANCE_PAGES} bound.
1175
+ *
1176
+ * @param claimant - Stellar G-address to filter claimable balances by.
1177
+ */
1178
+ async getClaimableBalances(claimant) {
1179
+ const all = [];
1180
+ let cursor;
1181
+ for (let i = 0; i < MAX_CLAIMABLE_BALANCE_PAGES; i++) {
1182
+ const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : "";
1183
+ const page = await this.getJson(
1184
+ `/claimable_balances?claimant=${claimant}&limit=${CLAIMABLE_BALANCE_PAGE_LIMIT}${cursorParam}`
1185
+ );
1186
+ const records = page._embedded?.records ?? [];
1187
+ all.push(...records);
1188
+ if (records.length < CLAIMABLE_BALANCE_PAGE_LIMIT) break;
1189
+ const next = records[records.length - 1]?.paging_token ?? this.cursorFromNextLink(page._links?.next?.href);
1190
+ if (!next || next === cursor) break;
1191
+ cursor = next;
1192
+ }
1193
+ return all;
1194
+ }
1195
+ /**
1196
+ * Extract the `cursor` query parameter from a Horizon `_links.next.href`.
1197
+ * Fallback for full pages whose records carry no `paging_token`.
1198
+ */
1199
+ cursorFromNextLink(href) {
1200
+ if (!href) return void 0;
1201
+ try {
1202
+ return new URL(href, this.baseUrl).searchParams.get("cursor") ?? void 0;
1203
+ } catch {
1204
+ return void 0;
1205
+ }
1206
+ }
1207
+ /**
1208
+ * Submit a base64 transaction envelope XDR to Horizon.
1209
+ *
1210
+ * @param xdr - base64-encoded transaction envelope.
1211
+ * @returns The submitted transaction hash.
1212
+ */
1213
+ async submitTransaction(xdr) {
1214
+ const res = await this.fetchFn(`${this.baseUrl}/transactions`, {
1215
+ method: "POST",
1216
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1217
+ body: `tx=${encodeURIComponent(xdr)}`
1218
+ });
1219
+ const body = await res.json();
1220
+ if (!res.ok || !body.hash) {
1221
+ throw new Error(
1222
+ `Horizon submit failed: ${JSON.stringify(body.extras?.result_codes ?? res.status)}`
1223
+ );
1224
+ }
1225
+ return { hash: body.hash };
1226
+ }
1227
+ };
1228
+
1229
+ // src/indexer.ts
1230
+ var DEFAULT_TIMEOUT_MS = 1e4;
1231
+ var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
1232
+ function withTimeout(promise, ms) {
1233
+ void promise.catch(() => {
1234
+ });
1235
+ let timer;
1236
+ const timeout = new Promise((resolve) => {
1237
+ timer = setTimeout(() => resolve(TIMED_OUT), ms);
1238
+ });
1239
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
1240
+ }
1241
+ var IndexerClient = class {
1242
+ baseUrl;
1243
+ fetchFn;
1244
+ timeoutMs;
1245
+ /**
1246
+ * @param baseUrl - Indexer root URL (no trailing slash required).
1247
+ * @param fetchFn - Injectable fetch (defaults to the global `fetch`).
1248
+ * @param opts - Optional per-request timeout in ms (default 10000).
1249
+ */
1250
+ constructor(baseUrl, fetchFn, opts) {
1251
+ this.baseUrl = baseUrl.replace(/\/$/, "");
1252
+ this.fetchFn = fetchFn ?? globalThis.fetch;
1253
+ this.timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1254
+ }
1255
+ async get(path) {
1256
+ let res;
1257
+ try {
1258
+ const raced = await withTimeout(
1259
+ this.fetchFn(`${this.baseUrl}${path}`),
1260
+ this.timeoutMs
1261
+ );
1262
+ if (raced === TIMED_OUT) {
1263
+ throw new IndexerNetworkError(
1264
+ path,
1265
+ `request timed out after ${this.timeoutMs}ms`
1266
+ );
1267
+ }
1268
+ res = raced;
1269
+ } catch (err) {
1270
+ if (err instanceof IndexerNetworkError) throw err;
1271
+ throw new IndexerNetworkError(
1272
+ path,
1273
+ err instanceof Error ? err.message : String(err)
1274
+ );
1275
+ }
1276
+ return this.decode(path, res);
1277
+ }
1278
+ /**
1279
+ * Decode an indexer response. A non-JSON body on an error status (e.g. a
1280
+ * proxy's HTML 502 page) must not mask the status, so decode failures fall
1281
+ * back to an empty body there; a non-JSON body on a 2xx is a broken indexer
1282
+ * and surfaces as a transport error.
1283
+ */
1284
+ async decode(path, res) {
1285
+ let data;
1286
+ try {
1287
+ data = await res.json();
1288
+ } catch {
1289
+ data = void 0;
1290
+ }
1291
+ if (!res.ok) {
1292
+ throw new IndexerHttpError(path, res.status, data?.code, data?.error);
1293
+ }
1294
+ if (data === void 0) {
1295
+ throw new IndexerNetworkError(path, "invalid JSON response body");
1296
+ }
1297
+ return data;
1298
+ }
1299
+ /** Probe the indexer's health and coverage window. */
1300
+ async health() {
1301
+ return this.get("/health");
1302
+ }
1303
+ /**
1304
+ * Fetch one page of announcements strictly after `cursor` (omit for the
1305
+ * start of coverage).
1306
+ *
1307
+ * @param cursor - Feed position to resume from (records returned are > it).
1308
+ * @param limit - Page size (the service caps it at 200).
1309
+ * @returns The page's records plus the cursor to adopt for the next call.
1310
+ */
1311
+ async getAnnouncements(cursor, limit) {
1312
+ const params = new URLSearchParams();
1313
+ if (cursor !== void 0) params.set("cursor", cursor);
1314
+ if (limit !== void 0) params.set("limit", String(limit));
1315
+ const query = params.toString();
1316
+ return this.get(
1317
+ `/announcements${query ? `?${query}` : ""}`
1318
+ );
1319
+ }
1320
+ };
1321
+
1322
+ // src/stroops.ts
1323
+ var STROOP_DECIMALS = 7;
1324
+ var STROOPS_PER_UNIT = 10n ** BigInt(STROOP_DECIMALS);
1325
+ function parseStroops(amount) {
1326
+ const trimmed = amount.trim();
1327
+ if (!/^\d+(\.\d+)?$/.test(trimmed)) {
1328
+ throw new Error(
1329
+ `Invalid amount "${amount}" \u2014 expected a non-negative decimal number.`
1330
+ );
1331
+ }
1332
+ const parts = trimmed.split(".");
1333
+ const wholePart = parts[0] ?? "0";
1334
+ const fracPartRaw = parts[1] ?? "";
1335
+ if (fracPartRaw.length > STROOP_DECIMALS) {
1336
+ throw new Error(
1337
+ `Amount "${amount}" has more than ${STROOP_DECIMALS} decimal places (smaller than one stroop).`
1338
+ );
1339
+ }
1340
+ const fracPart = fracPartRaw.padEnd(STROOP_DECIMALS, "0");
1341
+ return BigInt(wholePart) * STROOPS_PER_UNIT + BigInt(fracPart);
1342
+ }
1343
+ function numberToStroops(amount) {
1344
+ if (!Number.isFinite(amount) || amount < 0) {
1345
+ throw new Error(`Invalid amount ${amount} \u2014 expected a non-negative number.`);
1346
+ }
1347
+ return parseStroops(amount.toString());
1348
+ }
1349
+ function formatStroops(stroops) {
1350
+ const negative = stroops < 0n;
1351
+ const abs = negative ? -stroops : stroops;
1352
+ const whole = abs / STROOPS_PER_UNIT;
1353
+ const frac = abs % STROOPS_PER_UNIT;
1354
+ const fracStr = frac.toString().padStart(STROOP_DECIMALS, "0");
1355
+ return `${negative ? "-" : ""}${whole.toString()}.${fracStr}`;
1356
+ }
1357
+ function challengeMessage(endpoint, fundingAccount, nonce, amount, bind) {
1358
+ const base = `shade-relayer:v1:${endpoint}:${fundingAccount}:${nonce}:${amount}`;
1359
+ return bind ? `${base}:${bind}` : base;
1360
+ }
1361
+ var RelayerClient = class {
1362
+ baseUrl;
1363
+ fetchFn;
1364
+ fundingSigner;
1365
+ fundingAccount;
1366
+ rpcServer;
1367
+ /**
1368
+ * @param baseUrl - Relayer root URL. A trailing `/relay` is stripped so a
1369
+ * bare relay URL (back-compat) resolves to the service root.
1370
+ * @param fetchFn - Injectable fetch (defaults to the global `fetch`).
1371
+ * @param opts - Optional default `fundingAccount` + `fundingSigner` used to
1372
+ * authenticate fee-spending requests against a credit-gated relayer, and
1373
+ * an optional `rpcServer` (an `rpc.Server`, or any object with its
1374
+ * `getTransaction`) used to confirm-poll returned tx hashes when a call
1375
+ * sets `confirm: true`.
1376
+ */
1377
+ constructor(baseUrl, fetchFn, opts) {
1378
+ this.baseUrl = baseUrl.replace(/\/relay\/?$/, "").replace(/\/$/, "");
1379
+ this.fetchFn = fetchFn ?? globalThis.fetch;
1380
+ this.fundingAccount = opts?.fundingAccount;
1381
+ this.fundingSigner = opts?.fundingSigner;
1382
+ this.rpcServer = opts?.rpcServer;
1383
+ }
1384
+ /**
1385
+ * Poll a relayer-returned txHash until it lands on-chain (SDK-TXHASH-TRUST).
1386
+ * A relayer could return a fabricated or failed hash with a 200; polling to a
1387
+ * terminal status is what turns "the relayer said so" into "the chain says
1388
+ * so". Deliberately loud when no poll handle exists — silently skipping the
1389
+ * confirmation a caller asked for would defeat its purpose. On timeout the
1390
+ * underlying `waitForTransaction` throws `TransactionTimeoutError` carrying
1391
+ * the txHash, so the caller can keep polling instead of blindly resubmitting.
1392
+ */
1393
+ async confirmOnChain(txHash) {
1394
+ if (!this.rpcServer) {
1395
+ throw new Error(
1396
+ "confirm: true requires an RPC handle: pass opts.rpcServer (an rpc.Server for the same network) to the RelayerClient constructor."
1397
+ );
1398
+ }
1399
+ await waitForTransaction(this.rpcServer, txHash);
1400
+ }
1401
+ /**
1402
+ * Fetch a fresh challenge nonce for `fundingAccount`, sign the canonical
1403
+ * message binding `endpoint` + account + nonce + `amount`, and return the
1404
+ * `{ fundingAccount, nonce, signature }` to attach to a fee-spending request.
1405
+ * Returns `undefined` when no signer is available (non-gated relayer path).
1406
+ */
1407
+ async signedAuth(endpoint, fundingAccount, signer, amount, bind) {
1408
+ const account = fundingAccount ?? this.fundingAccount;
1409
+ const fundingSigner = signer ?? this.fundingSigner;
1410
+ if (!account || !fundingSigner) return void 0;
1411
+ const authAmount = amount ?? "0";
1412
+ const { nonce } = await this.get(
1413
+ `/credit/challenge?account=${encodeURIComponent(account)}`
1414
+ );
1415
+ const message = challengeMessage(endpoint, account, nonce, authAmount, bind);
1416
+ const raw = await fundingSigner(message);
1417
+ const signature = typeof raw === "string" ? raw : Buffer.from(raw).toString("base64");
1418
+ return { nonce, signature };
1419
+ }
1420
+ // Transport failures throw RelayerNetworkError and non-2xx responses throw
1421
+ // RelayerHttpError (message text unchanged) so callers — and RelayerPool's
1422
+ // failover — can branch on the failure KIND: unreachable/5xx is a relayer
1423
+ // fault worth failing over, 4xx is this request's fault and would repeat.
1424
+ async post(path, body) {
1425
+ let res;
1426
+ try {
1427
+ res = await this.fetchFn(`${this.baseUrl}${path}`, {
1428
+ method: "POST",
1429
+ headers: { "Content-Type": "application/json" },
1430
+ body: JSON.stringify(body)
1431
+ });
1432
+ } catch (err) {
1433
+ throw new RelayerNetworkError(
1434
+ path,
1435
+ err instanceof Error ? err.message : String(err)
1436
+ );
1437
+ }
1438
+ return this.decode(path, res);
1439
+ }
1440
+ async get(path) {
1441
+ let res;
1442
+ try {
1443
+ res = await this.fetchFn(`${this.baseUrl}${path}`);
1444
+ } catch (err) {
1445
+ throw new RelayerNetworkError(
1446
+ path,
1447
+ err instanceof Error ? err.message : String(err)
1448
+ );
1449
+ }
1450
+ return this.decode(path, res);
1451
+ }
1452
+ /**
1453
+ * Decode a relayer response. A non-JSON body on an error status (e.g. a
1454
+ * proxy's HTML 502 page) must not mask the status, so decode failures fall
1455
+ * back to an empty body there; a non-JSON body on a 2xx is a broken relayer
1456
+ * and surfaces as a transport error.
1457
+ */
1458
+ async decode(path, res) {
1459
+ let data;
1460
+ try {
1461
+ data = await res.json();
1462
+ } catch {
1463
+ data = void 0;
1464
+ }
1465
+ if (!res.ok) {
1466
+ throw new RelayerHttpError(path, res.status, data?.code, data?.error);
1467
+ }
1468
+ if (data === void 0) {
1469
+ throw new RelayerNetworkError(path, "invalid JSON response body");
1470
+ }
1471
+ return data;
1472
+ }
1473
+ /** Probe the relayer's health, balance, and address. */
1474
+ async health() {
1475
+ return this.get("/health");
1476
+ }
1477
+ /**
1478
+ * Fee-bump and submit a signed transaction envelope.
1479
+ *
1480
+ * @param xdr - base64 signed transaction envelope.
1481
+ * @param opts - Optional funding account to debit the fee against, and
1482
+ * `confirm` to poll the returned hash to an on-chain terminal status.
1483
+ * @returns The submitted transaction hash.
1484
+ */
1485
+ async relay(xdr, opts) {
1486
+ const fundingAccount = opts?.fundingAccount ?? this.fundingAccount;
1487
+ const fundingSigner = opts?.fundingSigner ?? this.fundingSigner;
1488
+ const innerTxHash = opts?.networkPassphrase ? new Transaction(xdr, opts.networkPassphrase).hash().toString("hex") : void 0;
1489
+ let authAmount = opts?.authAmount;
1490
+ if (fundingAccount && fundingSigner && authAmount === void 0) {
1491
+ authAmount = await this.maxRelayFee();
1492
+ }
1493
+ const auth = await this.signedAuth(
1494
+ "relay",
1495
+ fundingAccount,
1496
+ opts?.fundingSigner,
1497
+ authAmount,
1498
+ innerTxHash
1499
+ );
1500
+ const result = await this.post("/relay", {
1501
+ xdr,
1502
+ fundingAccount,
1503
+ ...auth ? { ...auth, authAmount } : {}
1504
+ });
1505
+ if (opts?.confirm) {
1506
+ await this.confirmOnChain(result.txHash);
1507
+ }
1508
+ return result;
1509
+ }
1510
+ /** Cached per-client relayer fee ceiling (XLM, 7-dp), fetched from /health. */
1511
+ cachedMaxRelayFee;
1512
+ /**
1513
+ * The relayer's advertised max fee-bump fee (`maxRelayFeeXlm` from /health),
1514
+ * as the fee ceiling to authorize. Falls back to the protocol default cap
1515
+ * (0.1 XLM) when an older relayer does not advertise it. Cached per client.
1516
+ */
1517
+ async maxRelayFee() {
1518
+ if (this.cachedMaxRelayFee) return this.cachedMaxRelayFee;
1519
+ let cap = 0.1;
1520
+ try {
1521
+ const h = await this.health();
1522
+ if (typeof h.maxRelayFeeXlm === "number" && h.maxRelayFeeXlm > 0) {
1523
+ cap = h.maxRelayFeeXlm;
1524
+ }
1525
+ } catch {
1526
+ }
1527
+ this.cachedMaxRelayFee = cap.toFixed(7);
1528
+ return this.cachedMaxRelayFee;
1529
+ }
1530
+ /** Cached per-client sponsored-reserve estimate; `null` = fetched, absent. */
1531
+ cachedSponsoredReserve;
1532
+ /**
1533
+ * The relayer's advertised sponsored-reserve estimate
1534
+ * (`sponsoredReserveEstimate` from /health), parsed to exact stroops.
1535
+ * Returns `undefined` when the relayer does not advertise one, the value is
1536
+ * unparsable, or /health is unreachable — the caller falls back to its own
1537
+ * mirrored constant, so a health fault never breaks a claim. Cached per
1538
+ * client, mirroring {@link maxRelayFee}.
1539
+ */
1540
+ async sponsoredReserveEstimateStroops() {
1541
+ if (this.cachedSponsoredReserve === void 0) {
1542
+ let estimate = null;
1543
+ try {
1544
+ const h = await this.health();
1545
+ if (typeof h.sponsoredReserveEstimate === "string") {
1546
+ estimate = parseStroops(h.sponsoredReserveEstimate);
1547
+ }
1548
+ } catch {
1549
+ }
1550
+ this.cachedSponsoredReserve = estimate;
1551
+ }
1552
+ return this.cachedSponsoredReserve ?? void 0;
1553
+ }
1554
+ /**
1555
+ * Create a stealth account from the relayer's own balance (funded
1556
+ * CreateAccount — no sponsorship sandwich).
1557
+ *
1558
+ * @param address - Stealth G-address to create.
1559
+ * @param opts - Starting balance and optional funding account.
1560
+ */
1561
+ async sponsor(address, opts) {
1562
+ const fundingAccount = opts?.fundingAccount ?? this.fundingAccount;
1563
+ const auth = await this.signedAuth(
1564
+ "sponsor",
1565
+ fundingAccount,
1566
+ opts?.fundingSigner,
1567
+ opts?.authAmount
1568
+ );
1569
+ return this.post("/sponsor", {
1570
+ address,
1571
+ startingBalance: opts?.startingBalance,
1572
+ fundingAccount,
1573
+ ...auth ?? {}
1574
+ });
1575
+ }
1576
+ /**
1577
+ * Ask the relayer to build the sponsored claimable-balance-claim transaction.
1578
+ * Returns UNSIGNED XDR the client must co-sign with the stealth key before
1579
+ * calling {@link sponsorClaimSubmit}.
1580
+ */
1581
+ async sponsorClaimPrepare(args) {
1582
+ return this.post("/sponsor-claim/prepare", args);
1583
+ }
1584
+ /**
1585
+ * Submit a sponsor-claim transaction the client has co-signed. The relayer
1586
+ * re-derives the expected operations from the trusted inputs
1587
+ * (`stealthAddress`, `asset`, `balanceId`) and verifies the submitted ops
1588
+ * match field-by-field before adding its signature and submitting.
1589
+ *
1590
+ * @param xdr - The stealth-co-signed transaction XDR.
1591
+ * @param args - The trusted claim inputs (and optional funding account).
1592
+ * `confirm: true` polls the returned hash to an on-chain terminal status
1593
+ * (requires `rpcServer` in the constructor opts).
1594
+ */
1595
+ async sponsorClaimSubmit(xdr, args) {
1596
+ const fundingAccount = args.fundingAccount ?? this.fundingAccount;
1597
+ const auth = await this.signedAuth(
1598
+ "sponsor-claim",
1599
+ fundingAccount,
1600
+ args.fundingSigner,
1601
+ args.authAmount
1602
+ );
1603
+ const result = await this.post("/sponsor-claim/submit", {
1604
+ xdr,
1605
+ stealthAddress: args.stealthAddress,
1606
+ asset: args.asset,
1607
+ balanceId: args.balanceId,
1608
+ destination: args.destination,
1609
+ amount: args.amount,
1610
+ fundingAccount,
1611
+ ...auth ?? {}
1612
+ });
1613
+ if (args.confirm) {
1614
+ await this.confirmOnChain(result.txHash);
1615
+ }
1616
+ return result;
1617
+ }
1618
+ /**
1619
+ * Report a completed on-chain deposit to the relayer so it credits the app's
1620
+ * funding account with the paid amount.
1621
+ */
1622
+ async creditClaim(fundingAccount, txHash) {
1623
+ return this.post("/credit/claim", { fundingAccount, txHash });
1624
+ }
1625
+ /** Read the current credit balance for an app funding account. */
1626
+ async creditBalance(fundingAccount) {
1627
+ return this.get(`/credit/${fundingAccount}`);
1628
+ }
1629
+ };
1630
+
1631
+ // src/relayerPool.ts
1632
+ function normalizeRelayList(relay) {
1633
+ if (relay === void 0) return void 0;
1634
+ const list = (Array.isArray(relay) ? relay : [relay]).map((url) => url.trim()).filter(Boolean);
1635
+ return list.length > 0 ? list : void 0;
1636
+ }
1637
+ var DEFAULT_PROBE_TIMEOUT_MS = 2500;
1638
+ var DEFAULT_ATTEMPT_TIMEOUT_MS = 1e4;
1639
+ var DEFAULT_MIN_RELAYER_BALANCE_XLM = 1;
1640
+ var DEFAULT_PROBE_TTL_MS = 3e4;
1641
+ var TIMED_OUT2 = /* @__PURE__ */ Symbol("timed out");
1642
+ function withTimeout2(promise, ms) {
1643
+ void promise.catch(() => {
1644
+ });
1645
+ let timer;
1646
+ const timeout = new Promise((resolve) => {
1647
+ timer = setTimeout(() => resolve(TIMED_OUT2), ms);
1648
+ });
1649
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
1650
+ }
1651
+ function shuffle(items, rng) {
1652
+ for (let i = items.length - 1; i > 0; i--) {
1653
+ const j = Math.floor(rng() * (i + 1));
1654
+ [items[i], items[j]] = [items[j], items[i]];
1655
+ }
1656
+ }
1657
+ var RelayerPool = class _RelayerPool {
1658
+ candidates;
1659
+ network;
1660
+ selection;
1661
+ fetchFn;
1662
+ probeTimeoutMs;
1663
+ attemptTimeoutMs;
1664
+ minBalanceXlm;
1665
+ probeTtlMs;
1666
+ rng;
1667
+ cachedProbe;
1668
+ cachedProbeAt = 0;
1669
+ constructor(candidates, opts) {
1670
+ if (candidates.length === 0) {
1671
+ throw new Error("RelayerPool requires at least one candidate URL");
1672
+ }
1673
+ this.candidates = [...candidates];
1674
+ this.network = opts?.network;
1675
+ this.selection = opts?.selection ?? "random";
1676
+ this.fetchFn = opts?.fetchFn;
1677
+ this.probeTimeoutMs = opts?.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
1678
+ this.attemptTimeoutMs = opts?.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
1679
+ this.minBalanceXlm = opts?.minBalanceXlm ?? DEFAULT_MIN_RELAYER_BALANCE_XLM;
1680
+ this.probeTtlMs = opts?.probeTtlMs ?? DEFAULT_PROBE_TTL_MS;
1681
+ this.rng = opts?.rng ?? Math.random;
1682
+ }
1683
+ /**
1684
+ * Build a pool from any accepted relayer config shape, or `undefined` when
1685
+ * the input normalizes to "no relayer" — the back-compat seam that lets
1686
+ * `RelayerPool.from(opts.relay)` slot in wherever a single URL was used.
1687
+ */
1688
+ static from(relay, opts) {
1689
+ const list = normalizeRelayList(relay);
1690
+ return list ? new _RelayerPool(list, opts) : void 0;
1691
+ }
1692
+ /**
1693
+ * Probe every candidate's `/health` in parallel under one shared time
1694
+ * budget. Results are cached per pool instance for {@link
1695
+ * RelayerPoolOpts.probeTtlMs}; `force` refreshes. Only transport-level
1696
+ * outcomes are recorded here — health-rule classification happens per call
1697
+ * in {@link select}/{@link withRelayer}, because it depends on the caller's
1698
+ * funding context.
1699
+ */
1700
+ async probe(force = false) {
1701
+ const now = Date.now();
1702
+ if (!force && this.cachedProbe && now - this.cachedProbeAt < this.probeTtlMs) {
1703
+ return this.cachedProbe;
1704
+ }
1705
+ const outcomes = await Promise.all(
1706
+ this.candidates.map(async (url) => {
1707
+ const client = new RelayerClient(url, this.fetchFn);
1708
+ try {
1709
+ const health = await withTimeout2(client.health(), this.probeTimeoutMs);
1710
+ if (health === TIMED_OUT2) return { url, reason: "timeout" };
1711
+ return { url, health };
1712
+ } catch (err) {
1713
+ if (err instanceof RelayerHttpError) {
1714
+ return { url, reason: `http_${err.status}` };
1715
+ }
1716
+ const detail = err instanceof Error ? err.message : String(err);
1717
+ return { url, reason: `unreachable: ${detail}` };
1718
+ }
1719
+ })
1720
+ );
1721
+ this.cachedProbe = outcomes;
1722
+ this.cachedProbeAt = now;
1723
+ return outcomes;
1724
+ }
1725
+ /**
1726
+ * Pick one healthy relayer URL for this call, or throw
1727
+ * {@link NoHealthyRelayerError} naming every candidate's rejection reason.
1728
+ * A single-candidate pool returns its URL without probing (pass-through).
1729
+ */
1730
+ async select(ctx) {
1731
+ if (this.candidates.length === 1) return this.candidates[0];
1732
+ const { healthy, reasons } = await this.rankHealthy(ctx);
1733
+ if (healthy.length === 0) throw new NoHealthyRelayerError(reasons);
1734
+ return healthy[0];
1735
+ }
1736
+ /**
1737
+ * Run `fn` against a selected relayer, failing over to the next healthy
1738
+ * candidate ONLY on transport faults — {@link RelayerNetworkError}, a 5xx
1739
+ * {@link RelayerHttpError}, or an attempt timeout. A 4xx (bad request,
1740
+ * insufficient credit) or any non-transport error would just repeat and
1741
+ * rethrows immediately. At most 2 attempts; the attempt timeout applies
1742
+ * only while another candidate remains.
1743
+ *
1744
+ * Failover after an ambiguous timeout cannot double-spend: both attempts
1745
+ * fee-bump the SAME signed inner tx, so its sequence number lets at most
1746
+ * one land (`txBAD_SEQ` for the loser).
1747
+ */
1748
+ async withRelayer(fn, ctx) {
1749
+ if (this.candidates.length === 1) {
1750
+ const url = this.candidates[0];
1751
+ return fn(this.buildClient(url, ctx), url);
1752
+ }
1753
+ const { healthy, reasons } = await this.rankHealthy(ctx);
1754
+ if (healthy.length === 0) throw new NoHealthyRelayerError(reasons);
1755
+ const attempts = healthy.slice(0, 2);
1756
+ let lastError;
1757
+ for (let i = 0; i < attempts.length; i++) {
1758
+ const url = attempts[i];
1759
+ const hasFallback = i < attempts.length - 1;
1760
+ try {
1761
+ const attempt = fn(this.buildClient(url, ctx), url);
1762
+ const result = hasFallback ? await withTimeout2(attempt, this.attemptTimeoutMs) : await attempt;
1763
+ if (result === TIMED_OUT2) {
1764
+ lastError = new RelayerNetworkError(
1765
+ url,
1766
+ `attempt timed out after ${this.attemptTimeoutMs}ms`
1767
+ );
1768
+ continue;
1769
+ }
1770
+ return result;
1771
+ } catch (err) {
1772
+ const transient = err instanceof RelayerNetworkError || err instanceof RelayerHttpError && err.status >= 500;
1773
+ if (transient && hasFallback) {
1774
+ lastError = err;
1775
+ continue;
1776
+ }
1777
+ throw err;
1778
+ }
1779
+ }
1780
+ throw lastError;
1781
+ }
1782
+ /** Per-call client carrying the caller's funding auth + confirm handle. */
1783
+ buildClient(url, ctx) {
1784
+ return new RelayerClient(url, this.fetchFn, {
1785
+ fundingAccount: ctx?.fundingAccount,
1786
+ fundingSigner: ctx?.fundingSigner,
1787
+ rpcServer: ctx?.rpcServer
1788
+ });
1789
+ }
1790
+ /**
1791
+ * Classify every probed candidate for this call's context and order the
1792
+ * healthy ones by the selection strategy.
1793
+ */
1794
+ async rankHealthy(ctx) {
1795
+ const outcomes = await this.probe();
1796
+ const healthy = [];
1797
+ const reasons = {};
1798
+ for (const outcome of outcomes) {
1799
+ if (!outcome.health) {
1800
+ reasons[outcome.url] = outcome.reason ?? "unreachable";
1801
+ continue;
1802
+ }
1803
+ const reason = this.healthReason(outcome.health, ctx);
1804
+ if (reason) reasons[outcome.url] = reason;
1805
+ else healthy.push(outcome.url);
1806
+ }
1807
+ if (this.selection === "random") shuffle(healthy, this.rng);
1808
+ return { healthy, reasons };
1809
+ }
1810
+ /** The health rule. Returns the rejection reason, or `undefined` = healthy. */
1811
+ healthReason(health, ctx) {
1812
+ if (health.status !== "ok") return "status_not_ok";
1813
+ if (this.network !== void 0 && health.network !== void 0 && health.network !== this.network) {
1814
+ return `network_mismatch (${health.network} != ${this.network})`;
1815
+ }
1816
+ if (health.balance !== void 0) {
1817
+ const balance = Number(health.balance);
1818
+ if (!Number.isFinite(balance) || balance < this.minBalanceXlm) {
1819
+ return `balance_below_min (${health.balance} < ${this.minBalanceXlm})`;
1820
+ }
1821
+ }
1822
+ const hasFundingAuth = !!(ctx?.fundingAccount && ctx?.fundingSigner);
1823
+ if (health.requireCredit !== false && !hasFundingAuth) {
1824
+ return "credit_gated_no_funding_auth";
1825
+ }
1826
+ return void 0;
1827
+ }
1828
+ };
1829
+ async function signTx(tx, secretOrAddress, networkPassphrase, signer) {
1830
+ if (signer) {
1831
+ const signed = await signer(tx.toXDR(), {
1832
+ networkPassphrase,
1833
+ address: secretOrAddress
1834
+ });
1835
+ return TransactionBuilder.fromXDR(signed, networkPassphrase);
1836
+ }
1837
+ tx.sign(Keypair.fromSecret(secretOrAddress));
1838
+ return tx;
1839
+ }
1840
+ async function prepareWithRestore(tx, rebuildTx, server, networkPassphrase, signRestore, submitRestore, notify) {
1841
+ const sim = await server.simulateTransaction(tx);
1842
+ if (rpc.Api.isSimulationError(sim)) {
1843
+ throw new Error(sim.error);
1844
+ }
1845
+ if (rpc.Api.isSimulationRestore(sim)) {
1846
+ notify?.("Stealth entry archived, restoring before withdraw\u2026");
1847
+ await runRestore(
1848
+ sim.restorePreamble,
1849
+ tx.source,
1850
+ server,
1851
+ networkPassphrase,
1852
+ signRestore,
1853
+ submitRestore
1854
+ );
1855
+ let rebuilt;
1856
+ try {
1857
+ const freshAccount = await server.getAccount(tx.source);
1858
+ rebuilt = rebuildTx(freshAccount);
1859
+ } catch (err) {
1860
+ throw new EntryArchivedRestoringError(err.message);
1861
+ }
1862
+ const fresh = await server.simulateTransaction(rebuilt);
1863
+ if (rpc.Api.isSimulationError(fresh)) {
1864
+ throw new EntryArchivedRestoringError(fresh.error);
1865
+ }
1866
+ if (rpc.Api.isSimulationRestore(fresh)) {
1867
+ throw new EntryArchivedRestoringError(
1868
+ "entry still archived after restore submitted"
1869
+ );
1870
+ }
1871
+ return rpc.assembleTransaction(rebuilt, fresh).build();
1872
+ }
1873
+ return rpc.assembleTransaction(tx, sim).build();
1874
+ }
1875
+ async function runRestore(restorePreamble, source, server, networkPassphrase, signRestore, submitRestore) {
1876
+ try {
1877
+ const account = await server.getAccount(source);
1878
+ const fee = (BigInt(restorePreamble.minResourceFee) + BigInt(BASE_FEE)).toString();
1879
+ const restoreTx = new TransactionBuilder(account, {
1880
+ fee,
1881
+ networkPassphrase
1882
+ }).setSorobanData(restorePreamble.transactionData.build()).addOperation(Operation.restoreFootprint({})).setTimeout(30).build();
1883
+ const signed = await signRestore(restoreTx);
1884
+ await submitRestore(signed);
1885
+ } catch (err) {
1886
+ if (err instanceof EntryArchivedRestoringError) throw err;
1887
+ throw new EntryArchivedRestoringError(err.message);
1888
+ }
1889
+ }
1890
+
1891
+ // src/methods/pool.ts
1892
+ var POOL_PAGE_SIZE = 200;
1893
+ var PoolAdapter = class {
1894
+ constructor(contractId, networkPassphrase, server, opts) {
1895
+ this.contractId = contractId;
1896
+ this.networkPassphrase = networkPassphrase;
1897
+ this.server = server;
1898
+ this.relayerSelection = opts?.relayerSelection;
1899
+ }
1900
+ contractId;
1901
+ networkPassphrase;
1902
+ server;
1903
+ method = "pool";
1904
+ relayerSelection;
1905
+ /**
1906
+ * Derive a one-time stealth address and deposit into the pool contract,
1907
+ * recording the announcement atomically with the deposit.
1908
+ */
1909
+ async send(params) {
1910
+ const { metaAddress, amount, senderSecret, asset, signTransaction } = params;
1911
+ if (amount <= 0) throw new Error("Amount must be positive");
586
1912
  const { spendPubKey, viewPubKey } = decodeMetaAddress(metaAddress);
587
- const senderKeypair = Keypair.fromSecret(senderSecret);
588
- const tokenAddress = resolveTokenAddress(opts?.asset, this.networkPassphrase);
589
- const stroops = BigInt(Math.round(amount * 1e7));
1913
+ const senderPublicKey = signTransaction ? senderSecret : Keypair.fromSecret(senderSecret).publicKey();
1914
+ const tokenAddress = resolveTokenAddress(asset, this.networkPassphrase);
1915
+ const stroops = numberToStroops(amount);
590
1916
  const ephemeralPrivKey = new Uint8Array(randomBytes(32));
591
1917
  const stealth = deriveStealthAddressWithSecret(
592
1918
  spendPubKey,
@@ -594,14 +1920,14 @@ var StealthClient = class {
594
1920
  ephemeralPrivKey
595
1921
  );
596
1922
  const contract = new Contract(this.contractId);
597
- const account = await this.server.getAccount(senderKeypair.publicKey());
1923
+ const account = await this.server.getAccount(senderPublicKey);
598
1924
  const depositTx = new TransactionBuilder(account, {
599
1925
  fee: "100",
600
1926
  networkPassphrase: this.networkPassphrase
601
1927
  }).addOperation(
602
1928
  contract.call(
603
1929
  "deposit",
604
- new StellarSdk.Address(senderKeypair.publicKey()).toScVal(),
1930
+ new StellarSdk.Address(senderPublicKey).toScVal(),
605
1931
  new StellarSdk.Address(tokenAddress).toScVal(),
606
1932
  nativeToScVal(stroops, { type: "i128" }),
607
1933
  nativeToScVal(Buffer.from(stealth.stealthPubKey)),
@@ -610,35 +1936,59 @@ var StealthClient = class {
610
1936
  )
611
1937
  ).setTimeout(30).build();
612
1938
  const prepared = await this.server.prepareTransaction(depositTx);
613
- prepared.sign(senderKeypair);
614
- const result = await this.server.sendTransaction(prepared);
615
- if (result.status === "ERROR") {
616
- throw new Error("Transaction submission failed");
617
- }
618
- if (result.status === "PENDING") {
619
- await waitForTransaction(this.server, result.hash);
620
- }
1939
+ const signed = await signTx(
1940
+ prepared,
1941
+ senderSecret,
1942
+ this.networkPassphrase,
1943
+ signTransaction
1944
+ );
1945
+ const result = await this.server.sendTransaction(signed);
1946
+ const txHash = await resolveSendResult(this.server, result);
621
1947
  return {
622
1948
  stealthAddress: stealth.stealthAddress,
623
- txHash: result.hash
1949
+ txHash
624
1950
  };
625
1951
  }
626
1952
  /**
627
- * Scan for stealth payments you received.
628
- *
629
- * Uses the view key to detect which announcements are yours,
630
- * then queries the contract for current balances.
631
- *
632
- * @param keys - Your stealth keys (needs viewPrivKey + spendPubKey)
1953
+ * Scan pool announcements starting at the cursor (announcement index). The
1954
+ * cursor advances by the number of announcements returned; a cheap
1955
+ * `get_announcement_count` lets callers skip a full scan when nothing is new.
633
1956
  */
634
- async scan(keys) {
1957
+ async scan(keys, cursor) {
635
1958
  const viewPrivKey = Buffer.from(keys.viewPrivKey, "hex");
636
1959
  const spendPubKey = Buffer.from(keys.spendPubKey, "hex");
637
- const announcements = await fetchAnnouncements(
1960
+ const start = cursor ? Number(cursor) : 0;
1961
+ const total = await fetchAnnouncementCount(
638
1962
  this.contractId,
639
1963
  this.server,
640
1964
  this.networkPassphrase
641
1965
  );
1966
+ if (total <= start) {
1967
+ return { payments: [], cursor: String(start) };
1968
+ }
1969
+ const announcements = [];
1970
+ let offset = start;
1971
+ while (offset < total) {
1972
+ const page = await fetchAnnouncements(
1973
+ this.contractId,
1974
+ this.server,
1975
+ this.networkPassphrase,
1976
+ offset,
1977
+ POOL_PAGE_SIZE
1978
+ );
1979
+ if (page.length === 0) break;
1980
+ announcements.push(...page);
1981
+ offset += page.length;
1982
+ }
1983
+ const nextCursor = String(start + announcements.length);
1984
+ const payments = await this.matchAndPrice(
1985
+ announcements,
1986
+ viewPrivKey,
1987
+ spendPubKey
1988
+ );
1989
+ return { payments, cursor: nextCursor };
1990
+ }
1991
+ async matchAndPrice(announcements, viewPrivKey, spendPubKey) {
642
1992
  if (announcements.length === 0) return [];
643
1993
  const matches = scanAnnouncements(
644
1994
  viewPrivKey,
@@ -666,34 +2016,39 @@ var StealthClient = class {
666
2016
  stealthAddress: ann.stealthAddress,
667
2017
  ephemeralPubKey: Buffer.from(ann.ephemeralPubKey).toString("hex"),
668
2018
  token: ann.token,
669
- amount: Number(balance) / 1e7
2019
+ asset: labelForToken(ann.token, this.networkPassphrase),
2020
+ amount: Number(balance) / 1e7,
2021
+ amountStroops: balance.toString(),
2022
+ method: "pool"
670
2023
  });
671
2024
  }
672
2025
  return payments;
673
2026
  }
674
2027
  /**
675
- * Get balances for all your stealth addresses in the pool.
676
- *
677
- * @param keys - Your stealth keys (needs viewPrivKey + spendPubKey)
2028
+ * Claim (withdraw) a pool payment: recover the stealth key, sign a withdraw
2029
+ * message, and submit — directly or via a relayer fee-bump.
678
2030
  */
679
- async balance(keys) {
680
- const payments = await this.scan(keys);
681
- return payments.map((p) => ({
682
- stealthAddress: p.stealthAddress,
683
- token: p.token,
684
- amount: p.amount
685
- }));
2031
+ async claim(payment, destination, opts) {
2032
+ if (opts.signTransaction && !opts.feePayerAddress) {
2033
+ throw new FeePayerAddressRequiredError();
2034
+ }
2035
+ const receipt = await this.withdraw(payment.stealthAddress, destination, {
2036
+ keys: opts.keys,
2037
+ feePayer: opts.feePayer ?? "",
2038
+ relay: opts.relay,
2039
+ asset: opts.asset,
2040
+ amount: opts.amount,
2041
+ signTransaction: opts.signTransaction,
2042
+ feePayerAddress: opts.feePayerAddress,
2043
+ fundingAccount: opts.fundingAccount,
2044
+ fundingSigner: opts.fundingSigner,
2045
+ confirm: opts.confirm
2046
+ });
2047
+ return { txHash: receipt.txHash, amount: receipt.amount, method: "pool" };
686
2048
  }
687
2049
  /**
688
- * Withdraw tokens from the stealth pool.
689
- *
690
- * Recovers the stealth private key, signs a withdraw message,
691
- * and submits the transaction. Use `opts.relay` for privacy-preserving
692
- * withdrawal via a relayer.
693
- *
694
- * @param stealthAddress - The stealth address to withdraw from
695
- * @param destination - Destination Stellar address (G...)
696
- * @param opts - Withdraw options (keys, feePayer, optional relay/asset/amount)
2050
+ * The original pool withdraw path, preserved for the client's `@deprecated`
2051
+ * `withdraw()` alias and reused by `claim()`.
697
2052
  */
698
2053
  async withdraw(stealthAddress, destination, opts) {
699
2054
  if (!StrKey.isValidEd25519PublicKey(stealthAddress)) {
@@ -702,15 +2057,36 @@ var StealthClient = class {
702
2057
  if (!StrKey.isValidEd25519PublicKey(destination)) {
703
2058
  throw new Error("Invalid destination address");
704
2059
  }
2060
+ if (opts.signTransaction) {
2061
+ if (!opts.feePayerAddress) {
2062
+ throw new FeePayerAddressRequiredError();
2063
+ }
2064
+ } else if (!opts.feePayer) {
2065
+ throw new FeePayerRequiredError();
2066
+ }
705
2067
  const viewPrivKey = Buffer.from(opts.keys.viewPrivKey, "hex");
706
2068
  const spendPrivKey = Buffer.from(opts.keys.spendPrivKey, "hex");
707
2069
  const spendPubKey = Buffer.from(opts.keys.spendPubKey, "hex");
708
2070
  const tokenAddress = resolveTokenAddress(opts.asset, this.networkPassphrase);
709
- const announcements = await fetchAnnouncements(
2071
+ const total = await fetchAnnouncementCount(
710
2072
  this.contractId,
711
2073
  this.server,
712
2074
  this.networkPassphrase
713
2075
  );
2076
+ const announcements = [];
2077
+ let offset = 0;
2078
+ while (offset < total) {
2079
+ const page = await fetchAnnouncements(
2080
+ this.contractId,
2081
+ this.server,
2082
+ this.networkPassphrase,
2083
+ offset,
2084
+ POOL_PAGE_SIZE
2085
+ );
2086
+ if (page.length === 0) break;
2087
+ announcements.push(...page);
2088
+ offset += page.length;
2089
+ }
714
2090
  const allMatches = scanAnnouncements(
715
2091
  viewPrivKey,
716
2092
  spendPubKey,
@@ -725,7 +2101,7 @@ var StealthClient = class {
725
2101
  return allMatches.some((m) => m?.address === stealthAddress);
726
2102
  });
727
2103
  if (!matchedAnn) {
728
- throw new Error("Could not find announcement for this stealth address");
2104
+ throw new AnnouncementNotFoundError();
729
2105
  }
730
2106
  const stealthPrivKey = recoverStealthPrivateKey(
731
2107
  spendPrivKey,
@@ -739,12 +2115,14 @@ var StealthClient = class {
739
2115
  this.server,
740
2116
  this.networkPassphrase
741
2117
  );
742
- if (balance <= 0n) throw new Error("Stealth address has no balance in the pool");
2118
+ if (balance <= 0n) throw new NoBalanceError();
743
2119
  let withdrawAmount;
744
2120
  if (opts.amount !== void 0) {
745
- withdrawAmount = BigInt(Math.round(opts.amount * 1e7));
2121
+ withdrawAmount = numberToStroops(opts.amount);
746
2122
  if (withdrawAmount > balance) {
747
- throw new Error(`Requested ${opts.amount} but balance is ${Number(balance) / 1e7}`);
2123
+ throw new Error(
2124
+ `Requested ${opts.amount} but balance is ${formatStroops(balance)}`
2125
+ );
748
2126
  }
749
2127
  } else {
750
2128
  withdrawAmount = balance;
@@ -761,13 +2139,16 @@ var StealthClient = class {
761
2139
  tokenAddress,
762
2140
  withdrawAmount,
763
2141
  destination,
764
- nonce
2142
+ nonce,
2143
+ this.contractId,
2144
+ this.networkPassphrase
765
2145
  );
766
- const signature = signWithStealthKey(messageHash, stealthPrivKey);
767
- const feePayerKeypair = Keypair.fromSecret(opts.feePayer);
2146
+ const signature = stealthPrivKey.sign(messageHash);
2147
+ stealthPrivKey.zeroize();
2148
+ const feePayerPublicKey = opts.signTransaction ? opts.feePayerAddress : Keypair.fromSecret(opts.feePayer).publicKey();
768
2149
  const contract = new Contract(this.contractId);
769
- const feePayerAccount = await this.server.getAccount(feePayerKeypair.publicKey());
770
- const withdrawTx = new TransactionBuilder(feePayerAccount, {
2150
+ const feePayerAccount = await this.server.getAccount(feePayerPublicKey);
2151
+ const buildWithdrawTx = (source) => new TransactionBuilder(source, {
771
2152
  fee: "100",
772
2153
  networkPassphrase: this.networkPassphrase
773
2154
  }).addOperation(
@@ -781,37 +2162,1490 @@ var StealthClient = class {
781
2162
  nativeToScVal(Buffer.from(signature))
782
2163
  )
783
2164
  ).setTimeout(30).build();
784
- const prepared = await this.server.prepareTransaction(withdrawTx);
785
- prepared.sign(feePayerKeypair);
786
- let txHash;
787
- if (opts.relay) {
788
- const url = opts.relay.endsWith("/relay") ? opts.relay : `${opts.relay}/relay`;
789
- const res = await fetch(url, {
790
- method: "POST",
791
- headers: { "Content-Type": "application/json" },
792
- body: JSON.stringify({ xdr: prepared.toEnvelope().toXDR("base64") })
793
- });
794
- if (!res.ok) {
795
- const err = await res.json();
796
- throw new Error(`Relay error: ${err.error || "unknown"}`);
797
- }
798
- const data = await res.json();
799
- txHash = data.txHash;
800
- } else {
801
- const result = await this.server.sendTransaction(prepared);
802
- if (result.status === "ERROR") throw new Error("Transaction submission failed");
803
- if (result.status === "PENDING") {
804
- await waitForTransaction(this.server, result.hash);
2165
+ const withdrawTx = buildWithdrawTx(feePayerAccount);
2166
+ const signLeg = (tx) => signTx(
2167
+ tx,
2168
+ opts.signTransaction ? feePayerPublicKey : opts.feePayer,
2169
+ this.networkPassphrase,
2170
+ opts.signTransaction
2171
+ );
2172
+ const relayerPool = RelayerPool.from(opts.relay, {
2173
+ network: networkNameForPassphrase(this.networkPassphrase),
2174
+ selection: this.relayerSelection
2175
+ });
2176
+ const submit = async (signed) => {
2177
+ if (relayerPool) {
2178
+ const txHash2 = await relayerPool.withRelayer(
2179
+ async (client) => (await client.relay(signed.toEnvelope().toXDR("base64"), {
2180
+ fundingAccount: opts.fundingAccount,
2181
+ networkPassphrase: this.networkPassphrase
2182
+ })).txHash,
2183
+ {
2184
+ fundingAccount: opts.fundingAccount,
2185
+ fundingSigner: opts.fundingSigner,
2186
+ rpcServer: this.server
2187
+ }
2188
+ );
2189
+ if (opts.confirm) await waitForTransaction(this.server, txHash2);
2190
+ return txHash2;
805
2191
  }
806
- txHash = result.hash;
807
- }
808
- return {
809
- txHash,
810
- amount: Number(withdrawAmount) / 1e7
2192
+ const result = await this.server.sendTransaction(signed);
2193
+ return resolveSendResult(this.server, result);
2194
+ };
2195
+ const prepared = await prepareWithRestore(
2196
+ withdrawTx,
2197
+ buildWithdrawTx,
2198
+ this.server,
2199
+ this.networkPassphrase,
2200
+ signLeg,
2201
+ submit
2202
+ );
2203
+ const signedWithdraw = await signLeg(prepared);
2204
+ const txHash = await submit(signedWithdraw);
2205
+ return { txHash, amount: Number(withdrawAmount) / 1e7 };
2206
+ }
2207
+ };
2208
+ var HORIZON_PAGE_SIZE = 200;
2209
+ var INDEXER_PAGE_SIZE = 200;
2210
+ var DEFAULT_INDEXER_MAX_LAG_SECONDS = 21600;
2211
+ var TOKEN_ACCOUNT_STARTING_BALANCE = "1.5001";
2212
+ var ACCOUNT_RESERVE_STROOPS = 10000000n;
2213
+ var SPONSORED_RESERVE_ESTIMATE_STROOPS = 10000000n;
2214
+ function isNativeAsset(asset) {
2215
+ return !asset || asset === "native" || asset === "XLM";
2216
+ }
2217
+ function parseAsset(asset) {
2218
+ if (isNativeAsset(asset)) return Asset.native();
2219
+ const [code, issuer] = asset.split(":");
2220
+ if (!code || !issuer) {
2221
+ throw new Error(`Invalid asset "${asset}" \u2014 expected CODE:ISSUER`);
2222
+ }
2223
+ return new Asset(code, issuer);
2224
+ }
2225
+ function assetToString(asset) {
2226
+ return asset.isNative() ? "native" : `${asset.getCode()}:${asset.getIssuer()}`;
2227
+ }
2228
+ function normalizeDestination(address) {
2229
+ if (!address) return address;
2230
+ if (!address.startsWith("M")) return address;
2231
+ try {
2232
+ return MuxedAccount.fromAddress(address, "0").baseAccount().accountId();
2233
+ } catch {
2234
+ return address;
2235
+ }
2236
+ }
2237
+ function isPredicateSatisfiable(predicate, nowSeconds) {
2238
+ if (!predicate) return true;
2239
+ if (predicate.unconditional) return true;
2240
+ if (predicate.not) {
2241
+ return !isPredicateSatisfiable(predicate.not, nowSeconds);
2242
+ }
2243
+ if (predicate.and) {
2244
+ return predicate.and.every((p) => isPredicateSatisfiable(p, nowSeconds));
2245
+ }
2246
+ if (predicate.or) {
2247
+ return predicate.or.some((p) => isPredicateSatisfiable(p, nowSeconds));
2248
+ }
2249
+ if (predicate.abs_before_epoch !== void 0) {
2250
+ return nowSeconds < Number(predicate.abs_before_epoch);
2251
+ }
2252
+ if (predicate.abs_before !== void 0) {
2253
+ const before = Math.floor(Date.parse(predicate.abs_before) / 1e3);
2254
+ if (!Number.isNaN(before)) return nowSeconds < before;
2255
+ }
2256
+ return true;
2257
+ }
2258
+ function computeReceiverStealthAddress(viewPrivKey, spendPubKey, ephemeralPubKey) {
2259
+ const S = scalarMult(viewPrivKey, ephemeralPubKey);
2260
+ const s = hashToScalar(S);
2261
+ const sG = scalarMultBase(s);
2262
+ const P = pointAdd(spendPubKey, sG);
2263
+ return encodePublicKey(P);
2264
+ }
2265
+ var AccountAdapter = class {
2266
+ constructor(networkPassphrase, horizon, relayer, opts) {
2267
+ this.networkPassphrase = networkPassphrase;
2268
+ this.horizon = horizon;
2269
+ this.relayer = relayer;
2270
+ this.rpcServer = opts?.rpcServer;
2271
+ this.relayerSelection = opts?.relayerSelection;
2272
+ this.indexer = opts?.indexer;
2273
+ this.indexerMaxLagSeconds = opts?.indexerMaxLagSeconds ?? DEFAULT_INDEXER_MAX_LAG_SECONDS;
2274
+ }
2275
+ networkPassphrase;
2276
+ horizon;
2277
+ relayer;
2278
+ method = "account";
2279
+ rpcServer;
2280
+ relayerSelection;
2281
+ indexer;
2282
+ indexerMaxLagSeconds;
2283
+ /**
2284
+ * Send funds directly to a one-time stealth account. Native XLM uses a plain
2285
+ * CreateAccount/Payment; a non-native asset uses CreateAccount (to open the
2286
+ * stealth account) plus a CreateClaimableBalance carrying the token. Amount
2287
+ * MUST be strictly greater than 1 XLM for native sends.
2288
+ */
2289
+ async send(params) {
2290
+ const { asset } = params;
2291
+ if (!isNativeAsset(asset)) {
2292
+ return this.sendToken(params);
2293
+ }
2294
+ return this.sendNative(params);
2295
+ }
2296
+ /** Native XLM send: CreateAccount (falls back to Payment on retry). */
2297
+ async sendNative(params) {
2298
+ const { metaAddress, amount, senderSecret, signTransaction } = params;
2299
+ if (amount <= 1) {
2300
+ throw new MinimumAmountError(amount);
2301
+ }
2302
+ const { spendPubKey, viewPubKey } = decodeMetaAddress(metaAddress);
2303
+ const ephemeralPrivKey = new Uint8Array(randomBytes(32));
2304
+ const stealth = deriveStealthAddressWithSecret(
2305
+ spendPubKey,
2306
+ viewPubKey,
2307
+ ephemeralPrivKey
2308
+ );
2309
+ const memo = Memo.hash(Buffer.from(stealth.ephemeralPubKey));
2310
+ const startingBalance = formatStroops(numberToStroops(amount));
2311
+ const senderPublicKey = signTransaction ? senderSecret : Keypair.fromSecret(senderSecret).publicKey();
2312
+ const submit = async (useCreate) => {
2313
+ const account = await this.horizon.getAccount(senderPublicKey);
2314
+ if (!account) {
2315
+ throw new Error("Sender account not found on Horizon \u2014 is it funded?");
2316
+ }
2317
+ const source = new Account(account.id, account.sequence);
2318
+ const op = useCreate ? Operation.createAccount({
2319
+ destination: stealth.stealthAddress,
2320
+ startingBalance
2321
+ }) : Operation.payment({
2322
+ destination: stealth.stealthAddress,
2323
+ asset: Asset.native(),
2324
+ amount: startingBalance
2325
+ });
2326
+ const tx = new TransactionBuilder(source, {
2327
+ fee: BASE_FEE,
2328
+ networkPassphrase: this.networkPassphrase
2329
+ }).addOperation(op).addMemo(memo).setTimeout(30).build();
2330
+ const signed = await signTx(
2331
+ tx,
2332
+ senderSecret,
2333
+ this.networkPassphrase,
2334
+ signTransaction
2335
+ );
2336
+ const res = await this.horizon.submitTransaction(
2337
+ signed.toEnvelope().toXDR("base64")
2338
+ );
2339
+ return res.hash;
2340
+ };
2341
+ let txHash;
2342
+ try {
2343
+ txHash = await submit(true);
2344
+ } catch (err) {
2345
+ const msg = err instanceof Error ? err.message : String(err);
2346
+ if (msg.includes("op_already_exists")) {
2347
+ txHash = await submit(false);
2348
+ } else {
2349
+ throw err;
2350
+ }
2351
+ }
2352
+ return { stealthAddress: stealth.stealthAddress, txHash };
2353
+ }
2354
+ /**
2355
+ * Token send: one classic tx carrying two operations —
2356
+ * CreateAccount(stealth, 1.5001 XLM) to open the account with trustline
2357
+ * headroom, then CreateClaimableBalance(asset, amount, claimant: stealth,
2358
+ * unconditional). The 0.5 XLM claimable-balance reserve returns to the sender
2359
+ * when the recipient claims. The ephemeral R rides in the MemoHash exactly as
2360
+ * for native sends.
2361
+ *
2362
+ * Not idempotent on retry: unlike {@link sendNative} (which falls back to a
2363
+ * Payment on `op_already_exists`), a token send has no such fallback. Each
2364
+ * token send uses a fresh ephemeral — hence a fresh, never-before-created
2365
+ * stealth address — so a resubmit targets a new account rather than colliding,
2366
+ * making the retry path unnecessary here.
2367
+ */
2368
+ async sendToken(params) {
2369
+ const { metaAddress, amount, senderSecret, asset } = params;
2370
+ if (!Number.isFinite(amount) || amount <= 0) {
2371
+ throw new InvalidAmountError(amount);
2372
+ }
2373
+ const stellarAsset = parseAsset(asset);
2374
+ const { spendPubKey, viewPubKey } = decodeMetaAddress(metaAddress);
2375
+ const ephemeralPrivKey = new Uint8Array(randomBytes(32));
2376
+ const stealth = deriveStealthAddressWithSecret(
2377
+ spendPubKey,
2378
+ viewPubKey,
2379
+ ephemeralPrivKey
2380
+ );
2381
+ const memo = Memo.hash(Buffer.from(stealth.ephemeralPubKey));
2382
+ const senderPublicKey = params.signTransaction ? senderSecret : Keypair.fromSecret(senderSecret).publicKey();
2383
+ const account = await this.horizon.getAccount(senderPublicKey);
2384
+ if (!account) {
2385
+ throw new Error("Sender account not found on Horizon \u2014 is it funded?");
2386
+ }
2387
+ const source = new Account(account.id, account.sequence);
2388
+ const claimant = new Claimant(
2389
+ stealth.stealthAddress,
2390
+ Claimant.predicateUnconditional()
2391
+ );
2392
+ const tx = new TransactionBuilder(source, {
2393
+ fee: BASE_FEE,
2394
+ networkPassphrase: this.networkPassphrase
2395
+ }).addOperation(
2396
+ Operation.createAccount({
2397
+ destination: stealth.stealthAddress,
2398
+ startingBalance: TOKEN_ACCOUNT_STARTING_BALANCE
2399
+ })
2400
+ ).addOperation(
2401
+ Operation.createClaimableBalance({
2402
+ asset: stellarAsset,
2403
+ amount: formatStroops(numberToStroops(amount)),
2404
+ claimants: [claimant]
2405
+ })
2406
+ ).addMemo(memo).setTimeout(30).build();
2407
+ const signed = await signTx(
2408
+ tx,
2409
+ senderSecret,
2410
+ this.networkPassphrase,
2411
+ params.signTransaction
2412
+ );
2413
+ const res = await this.horizon.submitTransaction(
2414
+ signed.toEnvelope().toXDR("base64")
2415
+ );
2416
+ return { stealthAddress: stealth.stealthAddress, txHash: res.hash };
2417
+ }
2418
+ /**
2419
+ * Discover incoming direct payments over Horizon's ascending transaction
2420
+ * feed. For each tx with a hash memo, the memo decodes to a candidate R;
2421
+ * deriving the stealth address from (viewPrivKey, spendPubKey, R) and
2422
+ * finding an operation whose destination equals that address confirms the
2423
+ * payment is ours. Three op shapes are matched:
2424
+ * - `create_account` / `payment` -> a native XLM send.
2425
+ * - `create_claimable_balance` with our address as a claimant -> a token send;
2426
+ * the claimable balance id is resolved via Horizon's claimable-balances API.
2427
+ *
2428
+ * With an indexer configured (and passing the health guard) the walk is
2429
+ * segmented: a bounded Horizon pre-segment covers anything before the
2430
+ * indexer's coverage window, the covered span consumes pre-extracted
2431
+ * announcements with inlined operations (no per-tx round-trip), and a
2432
+ * Horizon tail ALWAYS runs from the last adopted position so indexer lag —
2433
+ * or an indexer fault mid-segment — can never hide a payment. A cold scan
2434
+ * fast-starts at the indexer's first covered position; payments predating
2435
+ * that coverage need `exhaustive: true`. Without an indexer the behavior is
2436
+ * exactly the original unbounded Horizon walk.
2437
+ *
2438
+ * Every path also returns `meta` — per-segment candidate/match counts plus
2439
+ * the indexer guard's verdict. Strictly observability: the numbers describe
2440
+ * how the scan ran, never which payments it finds.
2441
+ */
2442
+ async scan(keys, cursor, opts) {
2443
+ const ctx = {
2444
+ viewPrivKey: new Uint8Array(Buffer.from(keys.viewPrivKey, "hex")),
2445
+ spendPubKey: new Uint8Array(Buffer.from(keys.spendPubKey, "hex")),
2446
+ suppressClaimedNative: opts?.suppressClaimedNative ?? false
2447
+ };
2448
+ const payments = [];
2449
+ const meta = { indexerUsed: false, segments: [] };
2450
+ const indexer = this.indexer;
2451
+ const coverage = indexer ? await this.indexerCoverage(indexer) : void 0;
2452
+ if (!indexer || !coverage || !coverage.usable) {
2453
+ if (coverage && !coverage.usable) {
2454
+ meta.indexerSkipReason = coverage.reason;
2455
+ if (coverage.lagSeconds !== void 0) {
2456
+ meta.indexerLagSeconds = coverage.lagSeconds;
2457
+ }
2458
+ }
2459
+ const stats = { candidates: 0, matches: 0 };
2460
+ const finalCursor2 = await this.walkHorizon(
2461
+ cursor,
2462
+ void 0,
2463
+ ctx,
2464
+ payments,
2465
+ stats
2466
+ );
2467
+ meta.segments.push({ source: "horizon", role: "full", ...stats });
2468
+ return { payments, cursor: finalCursor2, meta };
2469
+ }
2470
+ meta.indexerLagSeconds = coverage.lagSeconds;
2471
+ const begin = cursor ?? (opts?.exhaustive ? void 0 : coverage.startCursor);
2472
+ if (begin === void 0 || BigInt(begin) < BigInt(coverage.startCursor)) {
2473
+ const preStats = { candidates: 0, matches: 0 };
2474
+ await this.walkHorizon(begin, coverage.startCursor, ctx, payments, preStats);
2475
+ meta.segments.push({ source: "horizon", role: "pre", ...preStats });
2476
+ }
2477
+ let cur = begin !== void 0 && BigInt(begin) > BigInt(coverage.startCursor) ? begin : coverage.startCursor;
2478
+ const segmentStart = cur;
2479
+ const preSegmentPayments = payments.length;
2480
+ const indexerStats = { candidates: 0, matches: 0 };
2481
+ try {
2482
+ for (; ; ) {
2483
+ const page = await indexer.getAnnouncements(cur, INDEXER_PAGE_SIZE);
2484
+ meta.indexerUsed = true;
2485
+ for (const record of page.records) {
2486
+ await this.collectCandidate(
2487
+ record,
2488
+ record.operations,
2489
+ ctx,
2490
+ payments,
2491
+ indexerStats
2492
+ );
2493
+ }
2494
+ const prev = cur;
2495
+ const adopt = (candidate) => {
2496
+ if (typeof candidate === "string" && /^\d+$/.test(candidate) && BigInt(candidate) > BigInt(cur)) {
2497
+ cur = candidate;
2498
+ }
2499
+ };
2500
+ adopt(page.records[page.records.length - 1]?.paging_token);
2501
+ adopt(page.cursor);
2502
+ if (page.records.length < INDEXER_PAGE_SIZE) break;
2503
+ if (cur === prev) break;
2504
+ }
2505
+ } catch (err) {
2506
+ if (!(err instanceof IndexerHttpError) && !(err instanceof IndexerNetworkError)) {
2507
+ throw err;
2508
+ }
2509
+ }
2510
+ meta.segments.push({ source: "indexer", role: "indexer", ...indexerStats });
2511
+ let tailFrom = cur;
2512
+ try {
2513
+ const post = await indexer.health();
2514
+ meta.postCheck = post.status === "ok" ? "ok" : "unhealthy";
2515
+ } catch {
2516
+ meta.postCheck = "unreachable";
2517
+ }
2518
+ if (meta.postCheck !== "ok") {
2519
+ tailFrom = segmentStart;
2520
+ payments.length = preSegmentPayments;
2521
+ }
2522
+ const tailStats = { candidates: 0, matches: 0 };
2523
+ let finalCursor = await this.walkHorizon(
2524
+ tailFrom,
2525
+ void 0,
2526
+ ctx,
2527
+ payments,
2528
+ tailStats
2529
+ );
2530
+ meta.segments.push({ source: "horizon", role: "tail", ...tailStats });
2531
+ if (finalCursor !== void 0 && finalCursor === tailFrom) {
2532
+ const head = await this.horizon.getLatestTransactionToken();
2533
+ if (head !== void 0 && BigInt(head) < BigInt(finalCursor)) {
2534
+ meta.cursorClamped = true;
2535
+ finalCursor = head;
2536
+ }
2537
+ }
2538
+ return { payments, cursor: finalCursor, meta };
2539
+ }
2540
+ /**
2541
+ * Probe the configured indexer and classify whether it is usable for THIS
2542
+ * scan: `status === 'ok'`, same network as this adapter, a non-null
2543
+ * coverage interval, and self-reported lag within the configured cap. On
2544
+ * any guard failure the scan stays on the pure Horizon walk (Horizon is the
2545
+ * source of truth); the returned reason feeds {@link MethodScanMeta} so the
2546
+ * otherwise-silent fallback is observable. A null/absent `lagSeconds` is
2547
+ * NOT stale — lag cannot be measured then, and correctness is tail-bounded
2548
+ * anyway.
2549
+ */
2550
+ async indexerCoverage(indexer) {
2551
+ let h;
2552
+ try {
2553
+ h = await indexer.health();
2554
+ } catch {
2555
+ return { usable: false, reason: "unreachable" };
2556
+ }
2557
+ if (h.status !== "ok") return { usable: false, reason: "unhealthy" };
2558
+ if (h.network !== networkNameForPassphrase(this.networkPassphrase)) {
2559
+ return { usable: false, reason: "network_mismatch" };
2560
+ }
2561
+ if (h.cursor == null || h.startCursor == null) {
2562
+ return { usable: false, reason: "no_coverage" };
2563
+ }
2564
+ const lagSeconds = typeof h.lagSeconds === "number" ? h.lagSeconds : null;
2565
+ if (lagSeconds !== null && lagSeconds > this.indexerMaxLagSeconds) {
2566
+ return { usable: false, reason: "stale", lagSeconds };
2567
+ }
2568
+ return { usable: true, startCursor: h.startCursor, lagSeconds };
2569
+ }
2570
+ /**
2571
+ * Ascending Horizon transaction walk shared by every scan segment,
2572
+ * processing candidates from `from` (exclusive, Horizon cursor semantics).
2573
+ * Unbounded when `stopAtToken` is undefined. When bounded, txs with
2574
+ * `BigInt(paging_token) <= BigInt(stopAtToken)` are processed — INCLUSIVE
2575
+ * of the boundary tx, matching the indexer's open coverage interval
2576
+ * (startCursor, cursor] — and the walk returns `stopAtToken` at the first
2577
+ * tx beyond it. Otherwise returns the last seen paging token (the scan
2578
+ * cursor), or `from` when nothing new was seen. `stats` accumulates the
2579
+ * segment's candidate/match counts (observability only).
2580
+ */
2581
+ async walkHorizon(from, stopAtToken, ctx, payments, stats) {
2582
+ let pageCursor = from;
2583
+ let lastToken = from;
2584
+ for (; ; ) {
2585
+ const txs = await this.horizon.getTransactions(pageCursor, HORIZON_PAGE_SIZE);
2586
+ if (txs.length === 0) break;
2587
+ for (const tx of txs) {
2588
+ if (stopAtToken !== void 0 && BigInt(tx.paging_token) > BigInt(stopAtToken)) {
2589
+ return stopAtToken;
2590
+ }
2591
+ lastToken = tx.paging_token;
2592
+ await this.collectCandidate(
2593
+ tx,
2594
+ () => this.horizon.getTransactionOperations(tx.hash),
2595
+ ctx,
2596
+ payments,
2597
+ stats
2598
+ );
2599
+ }
2600
+ if (txs.length < HORIZON_PAGE_SIZE) break;
2601
+ pageCursor = txs[txs.length - 1].paging_token;
2602
+ }
2603
+ return lastToken;
2604
+ }
2605
+ /**
2606
+ * Run one transaction through the candidate pipeline: verify the memo
2607
+ * shape, decode the ephemeral R, derive the receiver-side stealth address,
2608
+ * and — on an ownership match — collect the native and/or token payment
2609
+ * rows into `payments`.
2610
+ *
2611
+ * `ops` is either the inline operation records (indexer announcements carry
2612
+ * them verbatim — no per-tx round-trip) or a lazy fetch invoked only after
2613
+ * the candidate checks pass (the Horizon walk — preserving the pre-indexer
2614
+ * behavior of never fetching operations for non-candidate txs). The memo
2615
+ * checks run here even for indexer-served records, so a corrupt feed entry
2616
+ * degrades to "not a candidate" rather than a bogus derivation.
2617
+ *
2618
+ * `stats.candidates` counts every tx that passes the memo-shape checks —
2619
+ * BEFORE derivation, so Horizon- and indexer-served sources count
2620
+ * identically — and `stats.matches` counts every payment pushed.
2621
+ */
2622
+ async collectCandidate(tx, ops, ctx, payments, stats) {
2623
+ if (tx.memo_type !== "hash" || !tx.memo) return;
2624
+ if (tx.successful === false) return;
2625
+ const ephemeralPubKey = new Uint8Array(Buffer.from(tx.memo, "base64"));
2626
+ if (ephemeralPubKey.length !== 32) return;
2627
+ stats.candidates++;
2628
+ let derivedAddress;
2629
+ try {
2630
+ derivedAddress = computeReceiverStealthAddress(
2631
+ ctx.viewPrivKey,
2632
+ ctx.spendPubKey,
2633
+ ephemeralPubKey
2634
+ );
2635
+ } catch {
2636
+ return;
2637
+ }
2638
+ const resolvedOps = typeof ops === "function" ? await ops() : ops;
2639
+ const ephHex = Buffer.from(ephemeralPubKey).toString("hex");
2640
+ const cbMatch = resolvedOps.find(
2641
+ (op) => op.type === "create_claimable_balance" && (op.claimants ?? []).some(
2642
+ (c) => normalizeDestination(c.destination) === derivedAddress
2643
+ )
2644
+ );
2645
+ const nativeMatch = resolvedOps.find(
2646
+ (op) => op.type === "create_account" && normalizeDestination(op.account) === derivedAddress || op.type === "payment" && normalizeDestination(op.to) === derivedAddress
2647
+ );
2648
+ if (nativeMatch) {
2649
+ const payment = await this.buildNativePayment(
2650
+ nativeMatch,
2651
+ derivedAddress,
2652
+ ephHex,
2653
+ tx.hash,
2654
+ !!cbMatch,
2655
+ ctx.suppressClaimedNative
2656
+ );
2657
+ if (payment) {
2658
+ payments.push(payment);
2659
+ stats.matches++;
2660
+ }
2661
+ }
2662
+ if (cbMatch) {
2663
+ const payment = await this.buildTokenPayment(
2664
+ cbMatch,
2665
+ derivedAddress,
2666
+ ephHex,
2667
+ tx.hash,
2668
+ tx.source_account
2669
+ );
2670
+ if (payment) {
2671
+ payments.push(payment);
2672
+ stats.matches++;
2673
+ }
2674
+ }
2675
+ }
2676
+ /**
2677
+ * Build the native income row for a matched create_account/payment leg, or
2678
+ * `null` to suppress it.
2679
+ *
2680
+ * Suppression is gated on whether the SAME tx also delivered a matching token
2681
+ * claimable balance (`hasTokenLeg`): the token path fronts a fixed reserve via
2682
+ * a create_account stub, which must NOT surface as a native payment — but this
2683
+ * is decided by the presence of the token leg, NOT by any magic starting
2684
+ * balance, so a genuine native send that merely happens to equal the reserve
2685
+ * constant is still discovered.
2686
+ *
2687
+ * On the balance path (`suppressClaimed`) the row reports the account's LIVE
2688
+ * native balance (what remains after any partial claim), and a fully-swept
2689
+ * (0) account is dropped. The discovery path reports the per-tx op amount so
2690
+ * two sends to the same address each surface independently.
2691
+ */
2692
+ async buildNativePayment(nativeMatch, derivedAddress, ephHex, txHash, hasTokenLeg, suppressClaimed) {
2693
+ if (hasTokenLeg) return null;
2694
+ const opAmountStr = nativeMatch.type === "create_account" ? nativeMatch.starting_balance ?? "0" : nativeMatch.amount ?? "0";
2695
+ let stroops = parseStroops(opAmountStr);
2696
+ if (suppressClaimed) {
2697
+ const live = await this.horizon.getAccount(derivedAddress);
2698
+ if (live) {
2699
+ const nativeBal = live.balances.find((b) => b.asset_type === "native");
2700
+ if (nativeBal !== void 0) stroops = parseStroops(nativeBal.balance);
2701
+ }
2702
+ }
2703
+ if (stroops <= 0n) return null;
2704
+ return {
2705
+ stealthAddress: derivedAddress,
2706
+ ephemeralPubKey: ephHex,
2707
+ token: "native",
2708
+ amount: Number(formatStroops(stroops)),
2709
+ amountStroops: stroops.toString(),
2710
+ method: "account",
2711
+ txHash
2712
+ };
2713
+ }
2714
+ /**
2715
+ * Build the token income row for a matched create_claimable_balance leg, or
2716
+ * `null` when no genuine, currently-claimable CB binds to it.
2717
+ *
2718
+ * The candidate CBs (Horizon lists ALL CBs the derived address can claim) are
2719
+ * bound to THIS specific op — not resolved by first-claimant — so an attacker
2720
+ * who creates their own CreateClaimableBalance naming the public stealth
2721
+ * address cannot mask/misattribute the real payment. Binding matches the CB
2722
+ * whose sponsor is the tx source AND whose asset+amount equal this op's, and
2723
+ * whose claim predicate for the derived address is currently satisfiable.
2724
+ */
2725
+ async buildTokenPayment(cbMatch, derivedAddress, ephHex, txHash, txSource) {
2726
+ const opAsset = cbMatch.asset;
2727
+ const opAmountStroops = cbMatch.amount ? parseStroops(cbMatch.amount) : void 0;
2728
+ const candidates = await this.horizon.getClaimableBalances(derivedAddress);
2729
+ const cb = this.bindClaimableBalance(candidates, derivedAddress, {
2730
+ txSource,
2731
+ opAsset,
2732
+ opAmountStroops
2733
+ });
2734
+ if (!cb) return null;
2735
+ const stroops = parseStroops(cb.amount);
2736
+ return {
2737
+ stealthAddress: derivedAddress,
2738
+ ephemeralPubKey: ephHex,
2739
+ token: opAsset ?? cb.asset ?? "unknown",
2740
+ asset: opAsset ?? cb.asset,
2741
+ claimableBalanceId: cb.id,
2742
+ amount: Number(formatStroops(stroops)),
2743
+ amountStroops: stroops.toString(),
2744
+ method: "account",
2745
+ txHash
2746
+ };
2747
+ }
2748
+ /**
2749
+ * Select the live claimable balance that genuinely corresponds to this tx's
2750
+ * create_claimable_balance op. Filters candidates to those whose claimant is
2751
+ * the derived address with a currently-satisfiable predicate, then binds by
2752
+ * sponsor === tx source AND asset === op asset AND amount === op amount. Only
2753
+ * when that precise binding is ambiguous or the tx source is unknown does it
2754
+ * fall back to an asset+amount match — never a bare first-claimant pick, so an
2755
+ * attacker CB cannot shadow the real one.
2756
+ */
2757
+ bindClaimableBalance(candidates, derivedAddress, expected) {
2758
+ const nowSeconds = Math.floor(Date.now() / 1e3);
2759
+ const claimableNow = candidates.filter(
2760
+ (cb) => (cb.claimants ?? []).some((c) => {
2761
+ if (normalizeDestination(c.destination) !== derivedAddress) return false;
2762
+ return isPredicateSatisfiable(c.predicate, nowSeconds);
2763
+ })
2764
+ );
2765
+ const assetAmountMatches = claimableNow.filter((cb) => {
2766
+ const assetOk = expected.opAsset === void 0 || cb.asset === expected.opAsset;
2767
+ const amountOk = expected.opAmountStroops === void 0 || parseStroops(cb.amount) === expected.opAmountStroops;
2768
+ return assetOk && amountOk;
2769
+ });
2770
+ if (expected.txSource !== void 0) {
2771
+ const sponsorBound = assetAmountMatches.find(
2772
+ (cb) => cb.sponsor === expected.txSource
2773
+ );
2774
+ if (sponsorBound) return sponsorBound;
2775
+ }
2776
+ return assetAmountMatches[0];
2777
+ }
2778
+ /**
2779
+ * Claim a direct-account payment. Branches on the payment shape: a claimable
2780
+ * balance (token send) runs the trustline + claim recipe; a plain XLM send
2781
+ * sweeps or partially pays out the stealth account.
2782
+ */
2783
+ async claim(payment, destination, opts) {
2784
+ const isToken = !!payment.claimableBalanceId || !isNativeAsset(payment.asset);
2785
+ if (isToken) {
2786
+ return this.claimToken(payment, destination, opts);
2787
+ }
2788
+ return this.claimNative(payment, destination, opts);
2789
+ }
2790
+ /**
2791
+ * Claim a native XLM direct send. The stealth account is a real funded Stellar
2792
+ * account with a sequence number. Full sweep (default) uses AccountMerge; a
2793
+ * partial claim uses Payment. Signing uses the raw stealth scalar (verifies as
2794
+ * standard ed25519). Optionally fee-bumped via a relayer.
2795
+ *
2796
+ * Passing `opts.amount` without `merge: false` is rejected up front
2797
+ * ({@link ClaimAmountRequiresNoMergeError}): the default merge sweeps the
2798
+ * ENTIRE balance via AccountMerge, so silently ignoring `amount` would move
2799
+ * more funds than the caller asked for.
2800
+ */
2801
+ async claimNative(payment, destination, opts) {
2802
+ if (opts.amount !== void 0 && opts.merge !== false) {
2803
+ throw new ClaimAmountRequiresNoMergeError("native-merge");
2804
+ }
2805
+ const stealthPrivKey = this.recoverKey(payment, opts);
2806
+ const stealthAddress = payment.stealthAddress;
2807
+ const account = await this.horizon.getAccount(stealthAddress);
2808
+ if (!account) {
2809
+ throw new StealthAccountNotFoundError();
2810
+ }
2811
+ const source = new Account(account.id, account.sequence);
2812
+ const merge = opts.merge !== false;
2813
+ const relayed = !!(normalizeRelayList(opts.relay) ?? normalizeRelayList(this.relayer));
2814
+ const feeStroops = BigInt(BASE_FEE);
2815
+ const nativeBal = account.balances.find((b) => b.asset_type === "native");
2816
+ const balanceStroops = nativeBal ? parseStroops(nativeBal.balance) : 0n;
2817
+ const builder = new TransactionBuilder(source, {
2818
+ fee: BASE_FEE,
2819
+ networkPassphrase: this.networkPassphrase
2820
+ });
2821
+ let amount;
2822
+ if (merge) {
2823
+ builder.addOperation(Operation.accountMerge({ destination }));
2824
+ const delivered = relayed ? balanceStroops : balanceStroops - feeStroops;
2825
+ amount = Number(formatStroops(delivered));
2826
+ } else {
2827
+ if (opts.amount === void 0) {
2828
+ throw new Error("Partial account claim requires opts.amount");
2829
+ }
2830
+ const requestedStroops = numberToStroops(opts.amount);
2831
+ const maxClaimableStroops = balanceStroops - ACCOUNT_RESERVE_STROOPS - feeStroops;
2832
+ if (requestedStroops > maxClaimableStroops) {
2833
+ throw new ClaimAmountError(
2834
+ opts.amount,
2835
+ Number(formatStroops(maxClaimableStroops))
2836
+ );
2837
+ }
2838
+ builder.addOperation(
2839
+ Operation.payment({
2840
+ destination,
2841
+ asset: Asset.native(),
2842
+ amount: formatStroops(requestedStroops)
2843
+ })
2844
+ );
2845
+ amount = opts.amount;
2846
+ }
2847
+ const tx = builder.setTimeout(30).build();
2848
+ const sig = stealthPrivKey.sign(tx.hash());
2849
+ stealthPrivKey.zeroize();
2850
+ tx.addSignature(stealthAddress, Buffer.from(sig).toString("base64"));
2851
+ const txHash = await this.submit(tx.toEnvelope().toXDR("base64"), opts);
2852
+ return { txHash, amount, method: "account" };
2853
+ }
2854
+ /**
2855
+ * Claim a token direct send delivered as a claimable balance. Requires the
2856
+ * destination to already trust the asset (probed first with an actionable
2857
+ * error otherwise).
2858
+ *
2859
+ * Self-funded path (stealth account exists): ChangeTrust(asset) ->
2860
+ * ClaimClaimableBalance(id) -> optional full exit
2861
+ * [Payment(asset, destination) -> ChangeTrust(limit '0') ->
2862
+ * AccountMerge(destination)].
2863
+ *
2864
+ * Sponsored path (`opts.sponsored`, or account stub missing): delegate to the
2865
+ * relayer's sponsor-claim pair — prepare returns XDR, we attach the stealth
2866
+ * signature and submit.
2867
+ */
2868
+ async claimToken(payment, destination, opts) {
2869
+ if (opts.amount !== void 0) {
2870
+ throw new ClaimAmountRequiresNoMergeError("token");
2871
+ }
2872
+ if (!payment.claimableBalanceId) {
2873
+ throw new Error("Token claim requires a claimableBalanceId on the payment");
2874
+ }
2875
+ const asset = payment.asset ?? payment.token;
2876
+ const stellarAsset = parseAsset(asset);
2877
+ const amount = payment.amount;
2878
+ const payoutAmount = this.resolveExactPayout(payment);
2879
+ await this.assertDestinationTrusts(destination, stellarAsset);
2880
+ const stealthAddress = payment.stealthAddress;
2881
+ const account = await this.horizon.getAccount(stealthAddress);
2882
+ const relays = normalizeRelayList(opts.relay) ?? normalizeRelayList(this.relayer);
2883
+ if (opts.sponsored || !account) {
2884
+ if (!relays) {
2885
+ throw new Error(
2886
+ "Sponsored token claim requires a relayer URL (opts.relay or client relayer)."
2887
+ );
2888
+ }
2889
+ return this.claimTokenSponsored(
2890
+ payment,
2891
+ opts,
2892
+ relays,
2893
+ amount,
2894
+ destination,
2895
+ payoutAmount
2896
+ );
2897
+ }
2898
+ const stealthPrivKey = this.recoverKey(payment, opts);
2899
+ const source = new Account(account.id, account.sequence);
2900
+ const merge = opts.merge !== false;
2901
+ const builder = new TransactionBuilder(source, {
2902
+ fee: BASE_FEE,
2903
+ networkPassphrase: this.networkPassphrase
2904
+ }).addOperation(Operation.changeTrust({ asset: stellarAsset })).addOperation(
2905
+ Operation.claimClaimableBalance({
2906
+ balanceId: payment.claimableBalanceId
2907
+ })
2908
+ );
2909
+ if (merge) {
2910
+ builder.addOperation(
2911
+ Operation.payment({
2912
+ destination,
2913
+ asset: stellarAsset,
2914
+ amount: payoutAmount
2915
+ })
2916
+ ).addOperation(Operation.changeTrust({ asset: stellarAsset, limit: "0" })).addOperation(Operation.accountMerge({ destination }));
2917
+ }
2918
+ const tx = builder.setTimeout(30).build();
2919
+ const sig = stealthPrivKey.sign(tx.hash());
2920
+ stealthPrivKey.zeroize();
2921
+ tx.addSignature(stealthAddress, Buffer.from(sig).toString("base64"));
2922
+ const txHash = await this.submit(tx.toEnvelope().toXDR("base64"), opts);
2923
+ return { txHash, amount, method: "account" };
2924
+ }
2925
+ /**
2926
+ * Sponsored claim: relayer prepares the XDR (BeginSponsoring -> [CreateAccount]
2927
+ * -> ChangeTrust -> EndSponsoring -> ClaimClaimableBalance -> Payment to the
2928
+ * destination), we co-sign with the stealth key, relayer submits and pays the
2929
+ * fee. The claimed token is delivered to `destination` in the same tx — the
2930
+ * stealth account never needs its own fee balance. The receipt's amount is the
2931
+ * amount that reached the destination.
2932
+ */
2933
+ async claimTokenSponsored(payment, opts, relays, amount, destination, payoutAmount) {
2934
+ const pool = new RelayerPool(relays, {
2935
+ network: networkNameForPassphrase(this.networkPassphrase),
2936
+ selection: this.relayerSelection
2937
+ });
2938
+ const relay = await pool.select({
2939
+ fundingAccount: opts.fundingAccount,
2940
+ fundingSigner: opts.fundingSigner,
2941
+ rpcServer: this.rpcServer
2942
+ });
2943
+ const client = new RelayerClient(relay, void 0, {
2944
+ fundingSigner: opts.fundingSigner,
2945
+ rpcServer: this.rpcServer
2946
+ });
2947
+ const asset = payment.asset ?? payment.token;
2948
+ const balanceId = payment.claimableBalanceId;
2949
+ if (!balanceId) {
2950
+ throw new Error("Sponsored token claim requires a claimableBalanceId");
2951
+ }
2952
+ const { xdr } = await client.sponsorClaimPrepare({
2953
+ stealthAddress: payment.stealthAddress,
2954
+ asset,
2955
+ balanceId,
2956
+ destination,
2957
+ amount: payoutAmount
2958
+ });
2959
+ const tx = this.verifySponsoredClaimXdr(xdr, {
2960
+ stealthAddress: payment.stealthAddress,
2961
+ asset,
2962
+ balanceId,
2963
+ destination,
2964
+ payoutAmount
2965
+ });
2966
+ const stealthPrivKey = this.recoverKey(payment, opts);
2967
+ const sig = stealthPrivKey.sign(tx.hash());
2968
+ stealthPrivKey.zeroize();
2969
+ tx.addSignature(payment.stealthAddress, Buffer.from(sig).toString("base64"));
2970
+ const reserveStroops = await client.sponsoredReserveEstimateStroops() ?? SPONSORED_RESERVE_ESTIMATE_STROOPS;
2971
+ const authAmount = formatStroops(BigInt(tx.fee) + reserveStroops);
2972
+ const { txHash } = await client.sponsorClaimSubmit(
2973
+ tx.toEnvelope().toXDR("base64"),
2974
+ {
2975
+ stealthAddress: payment.stealthAddress,
2976
+ asset,
2977
+ balanceId,
2978
+ destination,
2979
+ amount: payoutAmount,
2980
+ fundingAccount: opts.fundingAccount,
2981
+ fundingSigner: opts.fundingSigner,
2982
+ authAmount,
2983
+ confirm: opts.confirm
2984
+ }
2985
+ );
2986
+ return { txHash, amount, method: "account" };
2987
+ }
2988
+ /**
2989
+ * Parse and verify a relayer-prepared sponsored-claim XDR against the client's
2990
+ * OWN trusted inputs BEFORE signing. This is the client-side security control:
2991
+ * the relayer-side `opsMatch` protects the relayer, not us, so a malicious
2992
+ * relayer could otherwise redirect the payout Payment or append an
2993
+ * AccountMerge to steal the just-claimed token.
2994
+ *
2995
+ * Verifies, throwing {@link SponsoredClaimMismatchError} on any mismatch:
2996
+ * - the tx is sourced by the relayer (never the stealth account);
2997
+ * - the tx carries no memo;
2998
+ * - every operation is one of the allowed sponsor-claim shapes, in the exact
2999
+ * order the relayer builds — BeginSponsoring(stealth) -> optional
3000
+ * CreateAccount(stealth, '0') -> ChangeTrust(asset, source stealth) ->
3001
+ * EndSponsoring(source stealth) -> ClaimClaimableBalance(balanceId, source
3002
+ * stealth) -> Payment(destination, asset, amount, source stealth);
3003
+ * - every value-moving op is sourced by the stealth account;
3004
+ * - the payout Payment's destination/asset/amount equal the caller's intent;
3005
+ * - no extra, missing, or reordered operations.
3006
+ *
3007
+ * Returns the parsed {@link Transaction} (ready to co-sign) on success.
3008
+ */
3009
+ verifySponsoredClaimXdr(xdr, expected) {
3010
+ let tx;
3011
+ try {
3012
+ tx = new Transaction(xdr, this.networkPassphrase);
3013
+ } catch {
3014
+ throw new SponsoredClaimMismatchError("prepared XDR is not a valid transaction");
3015
+ }
3016
+ if (tx.memo && tx.memo.type !== "none") {
3017
+ throw new SponsoredClaimMismatchError("unexpected memo on the prepared transaction");
3018
+ }
3019
+ const relayer = tx.source;
3020
+ const { stealthAddress, balanceId, destination, payoutAmount } = expected;
3021
+ const wantAsset = parseAsset(expected.asset);
3022
+ const wantAssetStr = wantAsset.isNative() ? "native" : `${wantAsset.getCode()}:${wantAsset.getIssuer()}`;
3023
+ const amt = (value) => parseStroops(value).toString();
3024
+ const opStr = (op) => {
3025
+ const src = op.source ?? relayer;
3026
+ switch (op.type) {
3027
+ case "beginSponsoringFutureReserves":
3028
+ return `begin|${src}|${op.sponsoredId}`;
3029
+ case "createAccount": {
3030
+ const o = op;
3031
+ return `create|${src}|${o.destination}|${amt(o.startingBalance)}`;
3032
+ }
3033
+ case "changeTrust": {
3034
+ const o = op;
3035
+ return `trust|${src}|${assetToString(o.line)}`;
3036
+ }
3037
+ case "endSponsoringFutureReserves":
3038
+ return `end|${src}`;
3039
+ case "claimClaimableBalance":
3040
+ return `claim|${src}|${op.balanceId}`;
3041
+ case "payment": {
3042
+ const o = op;
3043
+ return `pay|${src}|${o.destination}|${assetToString(o.asset)}|${amt(o.amount)}`;
3044
+ }
3045
+ default:
3046
+ return `UNSUPPORTED:${op.type}|${src}`;
3047
+ }
3048
+ };
3049
+ const buildExpected = (withCreate2) => {
3050
+ const ops = [];
3051
+ ops.push(`begin|${relayer}|${stealthAddress}`);
3052
+ if (withCreate2) ops.push(`create|${relayer}|${stealthAddress}|${amt("0")}`);
3053
+ ops.push(`trust|${stealthAddress}|${wantAssetStr}`);
3054
+ ops.push(`end|${stealthAddress}`);
3055
+ ops.push(`claim|${stealthAddress}|${balanceId}`);
3056
+ ops.push(`pay|${stealthAddress}|${destination}|${wantAssetStr}|${amt(payoutAmount)}`);
3057
+ return ops;
3058
+ };
3059
+ const submitted = tx.operations.map(opStr);
3060
+ const withCreate = buildExpected(true).join("\n");
3061
+ const withoutCreate = buildExpected(false).join("\n");
3062
+ const actual = submitted.join("\n");
3063
+ if (actual !== withCreate && actual !== withoutCreate) {
3064
+ throw new SponsoredClaimMismatchError(
3065
+ `operations do not match the expected sponsor-claim sequence (got: ${actual})`
3066
+ );
3067
+ }
3068
+ return tx;
3069
+ }
3070
+ /**
3071
+ * Resolve the EXACT 7-dp payout string for a token claim from the precise
3072
+ * stroop count, never the lossy `payment.amount` double. Prefers the exact
3073
+ * `payment.amountStroops` string; when it is absent, falls back to the 7-dp
3074
+ * `payment.amount` double with NO extra Horizon read (the amount is already in
3075
+ * hand from the scan). Above 2^53 stroops the double in `payment.amount`
3076
+ * cannot represent every stroop, so an exact stroop count should always be
3077
+ * threaded through when available (SDK-PREC-1).
3078
+ */
3079
+ resolveExactPayout(payment) {
3080
+ if (payment.amountStroops) {
3081
+ return formatStroops(BigInt(payment.amountStroops));
3082
+ }
3083
+ return payment.amount.toFixed(7);
3084
+ }
3085
+ /** Recover the stealth scalar for signing from the payment's ephemeral R. */
3086
+ recoverKey(payment, opts) {
3087
+ const viewPrivKey = new Uint8Array(Buffer.from(opts.keys.viewPrivKey, "hex"));
3088
+ const spendPrivKey = new Uint8Array(
3089
+ Buffer.from(opts.keys.spendPrivKey, "hex")
3090
+ );
3091
+ const ephemeralPubKey = new Uint8Array(
3092
+ Buffer.from(payment.ephemeralPubKey, "hex")
3093
+ );
3094
+ return recoverStealthPrivateKey(spendPrivKey, viewPrivKey, ephemeralPubKey);
3095
+ }
3096
+ /** Fail with an actionable error unless the destination trusts the asset. */
3097
+ async assertDestinationTrusts(destination, asset) {
3098
+ if (asset.isNative()) return;
3099
+ const dest = await this.horizon.getAccount(destination);
3100
+ if (!dest) {
3101
+ throw new DestinationTrustlineError(
3102
+ `Destination ${destination} not found on Horizon \u2014 fund it and add a ${asset.getCode()} trustline before claiming.`
3103
+ );
3104
+ }
3105
+ const code = asset.getCode();
3106
+ const issuer = asset.getIssuer();
3107
+ const trusts = dest.balances.some(
3108
+ (b) => b.asset_code === code && b.asset_issuer === issuer
3109
+ );
3110
+ if (!trusts) {
3111
+ throw new DestinationTrustlineError(
3112
+ `Destination ${destination} does not trust ${assetToString(asset)}. Add the trustline on the destination account before claiming.`
3113
+ );
3114
+ }
3115
+ }
3116
+ /** Submit an XDR directly to Horizon, or via a relayer fee-bump when set. */
3117
+ async submit(xdr, opts) {
3118
+ const relays = normalizeRelayList(opts.relay) ?? normalizeRelayList(this.relayer);
3119
+ if (relays) {
3120
+ const pool = new RelayerPool(relays, {
3121
+ network: networkNameForPassphrase(this.networkPassphrase),
3122
+ selection: this.relayerSelection
3123
+ });
3124
+ const txHash = await pool.withRelayer(
3125
+ async (client) => (await client.relay(xdr, {
3126
+ fundingAccount: opts.fundingAccount,
3127
+ networkPassphrase: this.networkPassphrase
3128
+ })).txHash,
3129
+ {
3130
+ fundingAccount: opts.fundingAccount,
3131
+ fundingSigner: opts.fundingSigner,
3132
+ rpcServer: this.rpcServer
3133
+ }
3134
+ );
3135
+ if (opts.confirm) {
3136
+ if (!this.rpcServer) {
3137
+ throw new Error(
3138
+ "confirm: true requires an RPC handle: construct the AccountAdapter with opts.rpcServer (an rpc.Server for the same network)."
3139
+ );
3140
+ }
3141
+ await waitForTransaction(this.rpcServer, txHash);
3142
+ }
3143
+ return txHash;
3144
+ }
3145
+ const res = await this.horizon.submitTransaction(xdr);
3146
+ return res.hash;
3147
+ }
3148
+ };
3149
+
3150
+ // src/methods/spp.ts
3151
+ var SPP_MESSAGE = "the 'spp' method is reserved for future Stellar Private Payments integration";
3152
+ var SppAdapter = class {
3153
+ method = "spp";
3154
+ /**
3155
+ * @throws {MethodNotAvailableError} Always — SPP is not yet implemented. The
3156
+ * declared return type is {@link SendReceipt} (never actually returned) so the
3157
+ * adapter's surface matches the {@link DeliveryAdapter} interface exactly.
3158
+ */
3159
+ async send(_params) {
3160
+ throw new MethodNotAvailableError(SPP_MESSAGE);
3161
+ }
3162
+ /** @throws {MethodNotAvailableError} Always — SPP is not yet implemented. */
3163
+ async scan(_keys, _cursor) {
3164
+ throw new MethodNotAvailableError(SPP_MESSAGE);
3165
+ }
3166
+ /** @throws {MethodNotAvailableError} Always — SPP is not yet implemented. */
3167
+ async claim(_payment, _destination, _opts) {
3168
+ throw new MethodNotAvailableError(SPP_MESSAGE);
3169
+ }
3170
+ };
3171
+
3172
+ // src/client.ts
3173
+ var StealthClient = class {
3174
+ contractId;
3175
+ networkPassphrase;
3176
+ server;
3177
+ enabledMethods;
3178
+ adapters;
3179
+ relayer;
3180
+ relayerSelection;
3181
+ constructor(config) {
3182
+ this.contractId = config.contractId || "";
3183
+ const netConfig = getNetworkConfig(config.network);
3184
+ this.networkPassphrase = netConfig.networkPassphrase;
3185
+ this.server = netConfig.server;
3186
+ this.relayer = config.relayer;
3187
+ this.relayerSelection = config.relayerSelection;
3188
+ this.enabledMethods = config.methods && config.methods.length > 0 ? config.methods : ["pool"];
3189
+ if (this.enabledMethods.includes("pool") && !this.contractId) {
3190
+ throw new ContractIdRequiredError(config.network);
3191
+ }
3192
+ const horizonUrl = config.horizonUrl || netConfig.horizonUrl;
3193
+ const horizon = new HorizonClient(horizonUrl);
3194
+ const indexer = config.indexerUrl ? new IndexerClient(config.indexerUrl) : void 0;
3195
+ this.adapters = /* @__PURE__ */ new Map();
3196
+ for (const method of this.enabledMethods) {
3197
+ switch (method) {
3198
+ case "pool":
3199
+ this.adapters.set(
3200
+ "pool",
3201
+ new PoolAdapter(this.contractId, this.networkPassphrase, this.server, {
3202
+ relayerSelection: this.relayerSelection
3203
+ })
3204
+ );
3205
+ break;
3206
+ case "account":
3207
+ this.adapters.set(
3208
+ "account",
3209
+ new AccountAdapter(this.networkPassphrase, horizon, this.relayer, {
3210
+ // Confirm-poll handle for relayed claims (`confirm: true`): the
3211
+ // Soroban RPC's getTransaction resolves classic tx hashes too.
3212
+ rpcServer: this.server,
3213
+ relayerSelection: this.relayerSelection,
3214
+ indexer,
3215
+ indexerMaxLagSeconds: config.indexerMaxLagSeconds
3216
+ })
3217
+ );
3218
+ break;
3219
+ case "spp":
3220
+ this.adapters.set("spp", new SppAdapter());
3221
+ break;
3222
+ }
3223
+ }
3224
+ }
3225
+ /**
3226
+ * Generate a new random stealth key pair.
3227
+ * No network connection needed.
3228
+ */
3229
+ static keygen() {
3230
+ return stealthKeysFromRaw(generateMetaAddress());
3231
+ }
3232
+ /**
3233
+ * Generate stealth keys from a BIP-39 mnemonic.
3234
+ * Returns the mnemonic alongside the keys for backup.
3235
+ * No network connection needed.
3236
+ */
3237
+ static fromMnemonic(mnemonic) {
3238
+ const phrase = mnemonic || generateMnemonic();
3239
+ return {
3240
+ mnemonic: phrase,
3241
+ ...stealthKeysFromRaw(mnemonicToStealthKeys(phrase))
3242
+ };
3243
+ }
3244
+ /**
3245
+ * Resolve `'auto'` to a concrete method: native asset AND amount > 1 AND
3246
+ * 'account' enabled -> 'account'; otherwise 'pool'.
3247
+ */
3248
+ resolveMethod(requested, amount, asset) {
3249
+ if (requested === "auto") {
3250
+ const isNative = !asset || asset === "native" || asset === "XLM";
3251
+ if (isNative && amount > 1 && this.adapters.has("account")) {
3252
+ return "account";
3253
+ }
3254
+ return "pool";
3255
+ }
3256
+ return requested;
3257
+ }
3258
+ getAdapter(method) {
3259
+ const adapter = this.adapters.get(method);
3260
+ if (!adapter) throw new MethodNotEnabledError(method);
3261
+ return adapter;
3262
+ }
3263
+ /**
3264
+ * Send tokens to a stealth address via the chosen delivery method.
3265
+ *
3266
+ * A method is REQUIRED on every call (`opts.method`). Pass `'auto'` to let the
3267
+ * client resolve one; there is deliberately no implicit default.
3268
+ *
3269
+ * @param metaAddress - Recipient's meta-address (shade:stellar:... format)
3270
+ * @param amount - Amount in whole units (e.g. 100 = 100 XLM)
3271
+ * @param senderSecret - Sender's Stellar secret key
3272
+ * @param opts - Delivery method (required) and optional asset
3273
+ * @throws {MethodRequiredError} If no method is provided.
3274
+ * @throws {MethodNotEnabledError} If the resolved method is not enabled.
3275
+ */
3276
+ async send(metaAddress, amount, senderSecret, opts) {
3277
+ if (!opts || !opts.method) {
3278
+ throw new MethodRequiredError();
3279
+ }
3280
+ const method = this.resolveMethod(opts.method, amount, opts.asset);
3281
+ const adapter = this.getAdapter(method);
3282
+ return adapter.send({
3283
+ metaAddress,
3284
+ amount,
3285
+ senderSecret,
3286
+ asset: opts.asset,
3287
+ signTransaction: opts.signTransaction
3288
+ });
3289
+ }
3290
+ /**
3291
+ * Scan for stealth payments across every enabled delivery method.
3292
+ *
3293
+ * @param keys - Your stealth keys (needs viewPrivKey + spendPubKey)
3294
+ */
3295
+ async scan(keys) {
3296
+ const { payments } = await this.scanWithCursor(keys);
3297
+ return payments;
3298
+ }
3299
+ /**
3300
+ * Cursor-aware scan across enabled (or explicitly requested) methods. Returns
3301
+ * the merged payments plus an updated per-method cursor to persist and pass to
3302
+ * the next scan for incremental discovery.
3303
+ *
3304
+ * @param keys - Your stealth keys (needs viewPrivKey + spendPubKey)
3305
+ * @param opts - Optional method filter and resume cursor
3306
+ */
3307
+ async scanWithCursor(keys, opts) {
3308
+ return this.scanInternal(keys, opts, false);
3309
+ }
3310
+ /**
3311
+ * Shared scan implementation. When `suppressClaimed` is set (the balance
3312
+ * path), the account adapter drops fully-swept/merged native accounts (live
3313
+ * balance 0) so a spent stealth account is not reported as spendable — while
3314
+ * the plain discovery scan keeps returning still-claimable rows.
3315
+ */
3316
+ async scanInternal(keys, opts, suppressClaimed) {
3317
+ const methods = (opts?.methods ?? this.enabledMethods).filter(
3318
+ (m) => this.adapters.has(m)
3319
+ );
3320
+ const cursor = { ...opts?.cursor ?? {} };
3321
+ const payments = [];
3322
+ const meta = {};
3323
+ for (const method of methods) {
3324
+ const adapter = this.getAdapter(method);
3325
+ const prev = cursor[method];
3326
+ const result = await adapter.scan(keys, prev, {
3327
+ suppressClaimedNative: suppressClaimed,
3328
+ exhaustive: opts?.exhaustive
3329
+ });
3330
+ for (const p of result.payments) {
3331
+ payments.push({ ...p, method });
3332
+ }
3333
+ cursor[method] = result.cursor;
3334
+ if (result.meta) meta[method] = result.meta;
3335
+ }
3336
+ return { payments, cursor, meta };
3337
+ }
3338
+ /**
3339
+ * Get balances for all your stealth payments across enabled methods.
3340
+ *
3341
+ * @param keys - Your stealth keys (needs viewPrivKey + spendPubKey)
3342
+ * @param opts - Optional method filter and resume cursor (a cursor returned
3343
+ * by a previous {@link scanWithCursor}/{@link balanceWithCursor}). With no
3344
+ * opts the whole history is scanned, exactly as before. Passing the
3345
+ * persisted cursor turns the account phase from a full Horizon re-walk
3346
+ * into O(new transactions); use {@link balanceWithCursor} when you also
3347
+ * need the advanced cursor back to persist it.
3348
+ */
3349
+ async balance(keys, opts) {
3350
+ const { payments } = await this.balanceWithCursor(keys, opts);
3351
+ return payments.map((p) => ({
3352
+ stealthAddress: p.stealthAddress,
3353
+ token: p.token,
3354
+ amount: p.amount,
3355
+ amountStroops: p.amountStroops
3356
+ }));
3357
+ }
3358
+ /**
3359
+ * Cursor-aware balance check: like {@link balance}, but resumes the
3360
+ * underlying scan from `opts.cursor` and returns the full live payments
3361
+ * (ephemeral key, txHash, claimable-balance id) PLUS the advanced cursor so
3362
+ * callers can persist it for the next call — the exact counterpart of
3363
+ * {@link scanWithCursor} for the balance path.
3364
+ *
3365
+ * The rows are the balance view: claimed/fully-swept payments are dropped
3366
+ * and native rows report the LIVE remaining account balance (not the
3367
+ * original per-transaction amount). Callers that persist discovered
3368
+ * payments should merge these rows into their cache BEFORE persisting the
3369
+ * advanced cursor, since the next resumed scan will skip everything behind
3370
+ * it.
3371
+ *
3372
+ * @param keys - Your stealth keys (needs viewPrivKey + spendPubKey)
3373
+ * @param opts - Optional method filter and resume cursor
3374
+ */
3375
+ async balanceWithCursor(keys, opts) {
3376
+ return this.scanInternal(keys, opts, true);
3377
+ }
3378
+ /**
3379
+ * Claim a detected payment to a destination, branching on the payment's
3380
+ * delivery method: `'pool'` uses the withdraw path, `'account'` sweeps or
3381
+ * partially pays out the stealth account.
3382
+ *
3383
+ * @param payment - A payment returned from {@link scan}/{@link scanWithCursor}.
3384
+ * @param destination - Destination Stellar G-address.
3385
+ * @param opts - Claim options (keys required; relay/merge/feePayer optional).
3386
+ */
3387
+ async claim(payment, destination, opts) {
3388
+ const adapter = this.getAdapter(payment.method);
3389
+ const relay = normalizeRelayList(opts.relay) ?? normalizeRelayList(this.relayer);
3390
+ return adapter.claim(payment, destination, { ...opts, relay });
3391
+ }
3392
+ /**
3393
+ * Withdraw tokens from the stealth pool.
3394
+ *
3395
+ * @deprecated Use {@link claim} with a pool payment instead. Retained for
3396
+ * backwards compatibility; behaves exactly like the original pool withdraw.
3397
+ *
3398
+ * @param stealthAddress - The stealth address to withdraw from
3399
+ * @param destination - Destination Stellar address (G...)
3400
+ * @param opts - Withdraw options (keys, feePayer, optional relay/asset/amount)
3401
+ */
3402
+ async withdraw(stealthAddress, destination, opts) {
3403
+ const adapter = this.adapters.get("pool");
3404
+ if (!(adapter instanceof PoolAdapter)) {
3405
+ throw new MethodNotAvailableError(
3406
+ "The 'pool' method must be enabled to use withdraw()"
3407
+ );
3408
+ }
3409
+ const result = await adapter.withdraw(stealthAddress, destination, {
3410
+ keys: opts.keys,
3411
+ feePayer: opts.feePayer,
3412
+ relay: normalizeRelayList(opts.relay) ?? normalizeRelayList(this.relayer),
3413
+ asset: opts.asset,
3414
+ amount: opts.amount,
3415
+ fundingAccount: opts.fundingAccount,
3416
+ fundingSigner: opts.fundingSigner,
3417
+ confirm: opts.confirm
3418
+ });
3419
+ return { txHash: result.txHash, amount: result.amount };
3420
+ }
3421
+ };
3422
+
3423
+ // src/session.ts
3424
+ var PBKDF2_ITERATIONS = 6e5;
3425
+ var SALT_BYTES = 16;
3426
+ var IV_BYTES = 12;
3427
+ function toBase64(bytes) {
3428
+ let binary = "";
3429
+ for (const b of bytes) binary += String.fromCharCode(b);
3430
+ return btoa(binary);
3431
+ }
3432
+ function fromBase64(b64) {
3433
+ const binary = atob(b64);
3434
+ const out = new Uint8Array(binary.length);
3435
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
3436
+ return out;
3437
+ }
3438
+ function fromHex(hex) {
3439
+ if (typeof hex !== "string" || hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) {
3440
+ return null;
3441
+ }
3442
+ const out = new Uint8Array(hex.length / 2);
3443
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
3444
+ return out;
3445
+ }
3446
+ function bytesEqual(a, b) {
3447
+ if (a.length !== b.length) return false;
3448
+ let diff = 0;
3449
+ for (let i = 0; i < a.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
3450
+ return diff === 0;
3451
+ }
3452
+ function assertPubKeyMatchesPriv(which, storedPubHex, privHex) {
3453
+ const priv = fromHex(privHex);
3454
+ const storedPub = fromHex(storedPubHex);
3455
+ if (!priv || !storedPub) throw new SessionIntegrityError(which);
3456
+ let derived;
3457
+ try {
3458
+ derived = scalarMultBase(priv);
3459
+ } catch {
3460
+ throw new SessionIntegrityError(which);
3461
+ }
3462
+ if (!bytesEqual(derived, storedPub)) throw new SessionIntegrityError(which);
3463
+ }
3464
+ var subtle = () => {
3465
+ const c = globalThis.crypto?.subtle;
3466
+ if (!c) {
3467
+ throw new Error(
3468
+ "WebCrypto SubtleCrypto is unavailable \u2014 StealthSession needs a browser or Node 18+."
3469
+ );
3470
+ }
3471
+ return c;
3472
+ };
3473
+ async function deriveAesKey(password, salt, iterations) {
3474
+ const material = await subtle().importKey(
3475
+ "raw",
3476
+ new TextEncoder().encode(password),
3477
+ "PBKDF2",
3478
+ false,
3479
+ ["deriveKey"]
3480
+ );
3481
+ return subtle().deriveKey(
3482
+ { name: "PBKDF2", salt, iterations, hash: "SHA-256" },
3483
+ material,
3484
+ { name: "AES-GCM", length: 256 },
3485
+ false,
3486
+ ["encrypt", "decrypt"]
3487
+ );
3488
+ }
3489
+ async function encryptJson(value, password) {
3490
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
3491
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
3492
+ const key = await deriveAesKey(password, salt, PBKDF2_ITERATIONS);
3493
+ const plaintext = new TextEncoder().encode(JSON.stringify(value));
3494
+ const cipher = new Uint8Array(
3495
+ await subtle().encrypt(
3496
+ { name: "AES-GCM", iv },
3497
+ key,
3498
+ plaintext
3499
+ )
3500
+ );
3501
+ return {
3502
+ data: toBase64(cipher),
3503
+ salt: toBase64(salt),
3504
+ iv: toBase64(iv)
3505
+ };
3506
+ }
3507
+ async function decryptJson(encrypted, password, iterations) {
3508
+ const salt = fromBase64(encrypted.salt);
3509
+ const iv = fromBase64(encrypted.iv);
3510
+ const key = await deriveAesKey(password, salt, iterations);
3511
+ let plaintext;
3512
+ try {
3513
+ plaintext = await subtle().decrypt(
3514
+ { name: "AES-GCM", iv },
3515
+ key,
3516
+ fromBase64(encrypted.data)
3517
+ );
3518
+ } catch {
3519
+ throw new WrongPasswordError();
3520
+ }
3521
+ return JSON.parse(new TextDecoder().decode(plaintext));
3522
+ }
3523
+ var StealthSession = class {
3524
+ storage;
3525
+ namespace;
3526
+ unlockedKeys = null;
3527
+ password = null;
3528
+ constructor(opts) {
3529
+ this.storage = opts.storage;
3530
+ this.namespace = opts.namespace ?? "stealth";
3531
+ }
3532
+ get keysKey() {
3533
+ return `${this.namespace}:keys`;
3534
+ }
3535
+ get scanKey() {
3536
+ return `${this.namespace}:scan-state`;
3537
+ }
3538
+ /**
3539
+ * Encrypt and persist the stealth keys under the given password. The public
3540
+ * keys are stored in the clear (they are shareable); only the private key
3541
+ * material is encrypted.
3542
+ */
3543
+ async saveKeys(keys, password) {
3544
+ const encrypted = await encryptJson(
3545
+ {
3546
+ metaAddress: keys.metaAddress,
3547
+ spendPrivKey: keys.spendPrivKey,
3548
+ viewPrivKey: keys.viewPrivKey
3549
+ },
3550
+ password
3551
+ );
3552
+ const envelope = {
3553
+ version: 2,
3554
+ kdf: "pbkdf2",
3555
+ iterations: PBKDF2_ITERATIONS,
3556
+ spendPublicKey: keys.spendPubKey,
3557
+ viewPublicKey: keys.viewPubKey,
3558
+ encrypted
3559
+ };
3560
+ await this.storage.setItem(this.keysKey, JSON.stringify(envelope));
3561
+ this.unlockedKeys = keys;
3562
+ this.password = password;
3563
+ }
3564
+ /**
3565
+ * Decrypt the stored keys into memory using the password.
3566
+ *
3567
+ * @throws {WrongPasswordError} If the password fails the AES-GCM auth check.
3568
+ * @throws If no keys are stored.
3569
+ */
3570
+ async unlock(password) {
3571
+ const raw = await this.storage.getItem(this.keysKey);
3572
+ if (!raw) {
3573
+ throw new Error("No stored keys \u2014 call saveKeys() first.");
3574
+ }
3575
+ const envelope = JSON.parse(raw);
3576
+ const secret = await decryptJson(envelope.encrypted, password, envelope.iterations);
3577
+ assertPubKeyMatchesPriv("spend", envelope.spendPublicKey, secret.spendPrivKey);
3578
+ assertPubKeyMatchesPriv("view", envelope.viewPublicKey, secret.viewPrivKey);
3579
+ const keys = {
3580
+ metaAddress: secret.metaAddress,
3581
+ spendPubKey: envelope.spendPublicKey,
3582
+ spendPrivKey: secret.spendPrivKey,
3583
+ viewPubKey: envelope.viewPublicKey,
3584
+ viewPrivKey: secret.viewPrivKey
3585
+ };
3586
+ this.unlockedKeys = keys;
3587
+ this.password = password;
3588
+ return keys;
3589
+ }
3590
+ /** The in-memory keys after {@link unlock}/{@link saveKeys}. */
3591
+ get keys() {
3592
+ if (!this.unlockedKeys) {
3593
+ throw new Error("Session is locked \u2014 call unlock() first.");
3594
+ }
3595
+ return this.unlockedKeys;
3596
+ }
3597
+ /** Wipe in-memory key material (storage is untouched). */
3598
+ lock() {
3599
+ this.unlockedKeys = null;
3600
+ this.password = null;
3601
+ }
3602
+ /** Whether keys are present in storage (does not require unlock). */
3603
+ async hasKeys() {
3604
+ return await this.storage.getItem(this.keysKey) !== null;
3605
+ }
3606
+ /** Remove all session data (keys + scan state) and lock in memory. */
3607
+ async clear() {
3608
+ await this.storage.removeItem(this.keysKey);
3609
+ await this.storage.removeItem(this.scanKey);
3610
+ this.lock();
3611
+ }
3612
+ /**
3613
+ * Load the encrypted scan-state cache. Requires an unlocked session (the same
3614
+ * derived key protects it, since payment history is private). Returns null
3615
+ * when nothing is cached.
3616
+ */
3617
+ async loadScanState() {
3618
+ if (!this.password) {
3619
+ throw new Error("Session is locked \u2014 call unlock() before loadScanState().");
3620
+ }
3621
+ const raw = await this.storage.getItem(this.scanKey);
3622
+ if (!raw) return null;
3623
+ const envelope = JSON.parse(raw);
3624
+ return decryptJson(
3625
+ envelope.encrypted,
3626
+ this.password,
3627
+ envelope.iterations
3628
+ );
3629
+ }
3630
+ /**
3631
+ * Encrypt and persist the scan-state cache under the session password.
3632
+ * Requires an unlocked session.
3633
+ */
3634
+ async saveScanState(state) {
3635
+ if (!this.password) {
3636
+ throw new Error("Session is locked \u2014 call unlock() before saveScanState().");
3637
+ }
3638
+ const encrypted = await encryptJson(state, this.password);
3639
+ const envelope = {
3640
+ version: 2,
3641
+ kdf: "pbkdf2",
3642
+ iterations: PBKDF2_ITERATIONS,
3643
+ encrypted
811
3644
  };
3645
+ await this.storage.setItem(this.scanKey, JSON.stringify(envelope));
812
3646
  }
813
3647
  };
814
3648
 
815
- export { StealthClient };
3649
+ export { AccountAdapter, AnnouncementNotFoundError, ClaimAmountError, ClaimAmountRequiresNoMergeError, ContractIdRequiredError, DEFAULT_APP_ID, DEFAULT_KEY_SCOPE, DestinationTrustlineError, EntryArchivedRestoringError, FeePayerAddressRequiredError, FeePayerRequiredError, HorizonClient, IndexerClient, IndexerHttpError, IndexerNetworkError, InvalidAmountError, MethodNotAvailableError, MethodNotEnabledError, MethodRequiredError, MinimumAmountError, NETWORKS, NoBalanceError, NoHealthyRelayerError, PoolAdapter, RelayerClient, RelayerHttpError, RelayerNetworkError, RelayerPool, SessionIntegrityError, ShadeError, SponsoredClaimMismatchError, SppAdapter, StealthAccountNotFoundError, StealthClient, StealthSession, TransactionRetryableError, TransactionTimeoutError, UnsupportedNetworkError, WrongPasswordError, buildWithdrawMessage, challengeMessage, createSimulationTx, formatStroops, getNetworkConfig, keysFromWalletSignature, labelForToken, networkNameForPassphrase, normalizeRelayList, numberToStroops, parseStroops, prepareWithRestore, resolveTokenAddress, simulateReadOnly, stealthKeysFromRaw, waitForTransaction };
816
3650
  //# sourceMappingURL=index.js.map
817
3651
  //# sourceMappingURL=index.js.map