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