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/README.md +135 -39
- package/dist/index.cjs +3069 -223
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2234 -124
- package/dist/index.d.ts +2234 -124
- package/dist/index.js +3017 -225
- package/dist/index.js.map +1 -1
- package/package.json +23 -12
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/
|
|
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)
|
|
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)
|
|
72
|
-
|
|
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)
|
|
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)
|
|
97
|
-
|
|
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)
|
|
115
|
-
|
|
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/
|
|
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 `
|
|
186
|
+
return `shade:stellar:${hex}`;
|
|
168
187
|
}
|
|
169
188
|
function decodeMetaAddress(encoded) {
|
|
170
|
-
if (!encoded.startsWith("
|
|
189
|
+
if (!encoded.startsWith("shade:stellar:")) {
|
|
171
190
|
throw new InvalidMetaAddress("Invalid meta-address prefix");
|
|
172
191
|
}
|
|
173
|
-
const hex = encoded.slice(
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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("
|
|
398
|
-
const viewTag2 = textEncoder.encode("
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
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
|
-
[
|
|
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
|
|
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/
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
*
|
|
563
|
-
*
|
|
1112
|
+
* @param baseUrl - Horizon root URL (no trailing slash required).
|
|
1113
|
+
* @param fetchFn - Injectable fetch (defaults to the global `fetch`).
|
|
564
1114
|
*/
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
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
|
-
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
606
|
-
* @
|
|
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
|
|
611
|
-
|
|
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 =
|
|
614
|
-
const tokenAddress = resolveTokenAddress(
|
|
615
|
-
const stroops =
|
|
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
|
|
1960
|
+
const signed = await signTx(
|
|
640
1961
|
prepared,
|
|
641
1962
|
senderSecret,
|
|
642
|
-
|
|
643
|
-
|
|
1963
|
+
this.networkPassphrase,
|
|
1964
|
+
signTransaction
|
|
644
1965
|
);
|
|
645
1966
|
const result = await this.server.sendTransaction(signed);
|
|
646
|
-
|
|
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
|
|
1970
|
+
txHash
|
|
655
1971
|
};
|
|
656
1972
|
}
|
|
657
1973
|
/**
|
|
658
|
-
*
|
|
659
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
2139
|
+
if (balance <= 0n) throw new NoBalanceError();
|
|
795
2140
|
let withdrawAmount;
|
|
796
2141
|
if (opts.amount !== void 0) {
|
|
797
|
-
withdrawAmount =
|
|
2142
|
+
withdrawAmount = numberToStroops(opts.amount);
|
|
798
2143
|
if (withdrawAmount > balance) {
|
|
799
|
-
throw new Error(
|
|
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 =
|
|
819
|
-
|
|
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
|
|
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
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
opts.
|
|
846
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|