@stellar/typescript-wallet-sdk 3.0.0 → 3.0.1

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.
@@ -133,6 +133,9 @@ export declare class SigningKeypairMissingSecretError extends Error {
133
133
  export declare class DefaultSignerDomainAccountError extends Error {
134
134
  constructor();
135
135
  }
136
+ export declare class DomainSigningModifiedError extends Error {
137
+ constructor();
138
+ }
136
139
  export declare class AuthHeaderSigningKeypairRequiredError extends Error {
137
140
  constructor();
138
141
  }
@@ -44,11 +44,22 @@ export declare class Stellar {
44
44
  */
45
45
  makeFeeBump({ feeAddress, transaction, baseFee, }: FeeBumpTransactionParams): FeeBumpTransaction;
46
46
  /**
47
- * Submits a signed transaction to the server. If the submission fails with status
48
- * 504 indicating a timeout error, it will automatically retry.
47
+ * Submits a signed transaction to Horizon.
48
+ *
49
+ * On HTTP 504 (timeout), retries with exponential backoff up to
50
+ * {@link SUBMIT_504_MAX_RETRIES} times. Any non-504 error is rethrown
51
+ * immediately without retrying.
52
+ *
49
53
  * @param {Transaction|FeeBumpTransaction} signedTransaction - The signed transaction to submit.
50
- * @returns {boolean} `true` if the transaction was successfully submitted.
51
- * @throws {TransactionSubmitFailedError} If the transaction submission fails.
54
+ * @returns {boolean} `true` if Horizon confirmed the submission as successful.
55
+ * @throws {TransactionSubmitFailedError} If Horizon responded with a non-successful
56
+ * submission result (the transaction reached Horizon but was rejected).
57
+ * @throws The underlying 504 error if every retry attempt timed out. In this
58
+ * case the transaction's on-chain status is **indeterminate** — Horizon may
59
+ * have ingested it on the final attempt without responding in time. Callers
60
+ * should poll the transaction hash to determine the actual outcome rather
61
+ * than resubmit blindly; resubmitting a signed transaction with the same
62
+ * sequence number will fail with `tx_bad_seq` once the original lands.
52
63
  */
53
64
  submitTransaction(signedTransaction: Transaction | FeeBumpTransaction): Promise<boolean>;
54
65
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stellar/typescript-wallet-sdk",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "engines": {
5
5
  "node": ">=20"
6
6
  },
@@ -12,6 +12,7 @@ import {
12
12
  ExpiredTokenError,
13
13
  ChallengeValidationFailedError,
14
14
  NetworkPassphraseMismatchError,
15
+ DomainSigningModifiedError,
15
16
  } from "../Exceptions";
16
17
  import {
17
18
  AuthenticateParams,
@@ -189,15 +190,27 @@ export class Sep10 {
189
190
  networkPassphrase,
190
191
  ) as Transaction;
191
192
 
192
- // check if verifying client domain as well
193
- for (const op of transaction.operations) {
194
- if (op.type === "manageData" && op.name === "client_domain") {
195
- transaction = await walletSigner.signWithDomainAccount({
196
- transactionXDR: challengeResponse.transaction,
197
- networkPassphrase,
198
- accountKp,
199
- });
193
+ const hasClientDomain = transaction.operations.some(
194
+ (op) => op.type === "manageData" && op.name === "client_domain",
195
+ );
196
+
197
+ if (hasClientDomain) {
198
+ const originalHash = transaction.hash();
199
+ const returned = await walletSigner.signWithDomainAccount({
200
+ transactionXDR: challengeResponse.transaction,
201
+ networkPassphrase,
202
+ accountKp,
203
+ });
204
+
205
+ // Transaction.hash() covers the envelope body but excludes signatures, so
206
+ // a domain signer that only appends its signature preserves the hash. Any
207
+ // change to the operations, source account, memo, or other body fields
208
+ // changes the hash and is rejected here.
209
+ if (!returned.hash().equals(originalHash)) {
210
+ throw new DomainSigningModifiedError();
200
211
  }
212
+
213
+ transaction = returned;
201
214
  }
202
215
 
203
216
  walletSigner.signWithClientAccount({ transaction, accountKp });
@@ -354,6 +354,15 @@ export class DefaultSignerDomainAccountError extends Error {
354
354
  }
355
355
  }
356
356
 
357
+ export class DomainSigningModifiedError extends Error {
358
+ constructor() {
359
+ super(
360
+ "Domain signer returned a transaction whose body differs from the original challenge. Only the client_domain signature should be added.",
361
+ );
362
+ Object.setPrototypeOf(this, DomainSigningModifiedError.prototype);
363
+ }
364
+ }
365
+
357
366
  export class AuthHeaderSigningKeypairRequiredError extends Error {
358
367
  constructor() {
359
368
  super("Must be SigningKeypair to sign auth header");
@@ -24,6 +24,13 @@ import {
24
24
  import { getResultCode } from "../Utils/getResultCode";
25
25
  import { SigningKeypair } from "./Account";
26
26
 
27
+ const SUBMIT_504_MAX_RETRIES = 5;
28
+ const SUBMIT_504_BASE_DELAY_MS = 1000;
29
+ const SUBMIT_504_MAX_DELAY_MS = 30000;
30
+
31
+ const sleep = (ms: number) =>
32
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
33
+
27
34
  /**
28
35
  * Interaction with the Stellar Network.
29
36
  * Do not create this object directly, use the Wallet class.
@@ -118,30 +125,58 @@ export class Stellar {
118
125
  }
119
126
 
120
127
  /**
121
- * Submits a signed transaction to the server. If the submission fails with status
122
- * 504 indicating a timeout error, it will automatically retry.
128
+ * Submits a signed transaction to Horizon.
129
+ *
130
+ * On HTTP 504 (timeout), retries with exponential backoff up to
131
+ * {@link SUBMIT_504_MAX_RETRIES} times. Any non-504 error is rethrown
132
+ * immediately without retrying.
133
+ *
123
134
  * @param {Transaction|FeeBumpTransaction} signedTransaction - The signed transaction to submit.
124
- * @returns {boolean} `true` if the transaction was successfully submitted.
125
- * @throws {TransactionSubmitFailedError} If the transaction submission fails.
135
+ * @returns {boolean} `true` if Horizon confirmed the submission as successful.
136
+ * @throws {TransactionSubmitFailedError} If Horizon responded with a non-successful
137
+ * submission result (the transaction reached Horizon but was rejected).
138
+ * @throws The underlying 504 error if every retry attempt timed out. In this
139
+ * case the transaction's on-chain status is **indeterminate** — Horizon may
140
+ * have ingested it on the final attempt without responding in time. Callers
141
+ * should poll the transaction hash to determine the actual outcome rather
142
+ * than resubmit blindly; resubmitting a signed transaction with the same
143
+ * sequence number will fail with `tx_bad_seq` once the original lands.
126
144
  */
127
145
  async submitTransaction(
128
146
  signedTransaction: Transaction | FeeBumpTransaction,
129
147
  ): Promise<boolean> {
130
- try {
131
- const response = await this.server.submitTransaction(signedTransaction);
132
- if (!response.successful) {
133
- throw new TransactionSubmitFailedError(response);
134
- }
135
- return true;
136
- } catch (e) {
137
- if (e.response.status === 504) {
138
- // in case of 504, keep retrying this tx until submission succeeds or we get a different error
148
+ let lastError: unknown;
149
+ for (let attempt = 0; attempt <= SUBMIT_504_MAX_RETRIES; attempt++) {
150
+ try {
151
+ const response = await this.server.submitTransaction(signedTransaction);
152
+ if (!response.successful) {
153
+ throw new TransactionSubmitFailedError(response);
154
+ }
155
+ return true;
156
+ } catch (e) {
157
+ if (e?.response?.status !== 504) {
158
+ throw e;
159
+ }
160
+ lastError = e;
161
+ if (attempt === SUBMIT_504_MAX_RETRIES) {
162
+ break;
163
+ }
139
164
  // https://developers.stellar.org/api/errors/http-status-codes/horizon-specific/timeout
140
165
  // https://developers.stellar.org/docs/encyclopedia/error-handling#timeouts
141
- return await this.submitTransaction(signedTransaction);
166
+ // Equal-jitter backoff: each attempt waits at least half the capped
167
+ // exponential delay (a deterministic, progressive floor) plus a random
168
+ // amount up to the other half. Keeps the schedule predictable while
169
+ // smoothing correlated retries across many clients during a Horizon
170
+ // outage. Total sleep stays within SUBMIT_504_MAX_DELAY_MS.
171
+ const cappedDelay = Math.min(
172
+ SUBMIT_504_BASE_DELAY_MS * 2 ** attempt,
173
+ SUBMIT_504_MAX_DELAY_MS,
174
+ );
175
+ const half = cappedDelay / 2;
176
+ await sleep(half + Math.random() * half);
142
177
  }
143
- throw e;
144
178
  }
179
+ throw lastError;
145
180
  }
146
181
 
147
182
  /**
@@ -211,6 +246,7 @@ export class Stellar {
211
246
  signerFunction,
212
247
  baseFee: newFee,
213
248
  memo,
249
+ maxFee,
214
250
  });
215
251
  }
216
252
  throw e;
package/test/auth.test.ts CHANGED
@@ -16,7 +16,7 @@ import { randomBytes } from "crypto";
16
16
  import axios from "axios";
17
17
  import sinon from "sinon";
18
18
 
19
- import { validateToken, Sep10 } from "../src/walletSdk/Auth";
19
+ import { validateToken, Sep10, type WalletSigner } from "../src/walletSdk/Auth";
20
20
  import {
21
21
  Config,
22
22
  StellarConfiguration,
@@ -30,6 +30,7 @@ import {
30
30
  ChallengeValidationFailedError,
31
31
  NetworkPassphraseMismatchError,
32
32
  MissingSigningKeyError,
33
+ DomainSigningModifiedError,
33
34
  } from "../src/walletSdk/Exceptions";
34
35
 
35
36
  const createToken = (payload: Record<string, unknown>): string => {
@@ -618,6 +619,108 @@ describe("Sep10 challenge validation", () => {
618
619
  });
619
620
  });
620
621
 
622
+ describe("client_domain signing integrity", () => {
623
+ const buildClientDomainChallenge = (clientKeypair: Keypair) => {
624
+ const clientDomainKeypair = Keypair.random();
625
+ return buildChallenge({
626
+ clientKeypair,
627
+ additionalOps: [
628
+ Operation.manageData({
629
+ name: "client_domain",
630
+ value: "wallet.example.com",
631
+ source: clientDomainKeypair.publicKey(),
632
+ }),
633
+ ],
634
+ });
635
+ };
636
+
637
+ it("should accept when domain signer returns the same transaction with an appended signature", async () => {
638
+ const clientKeypair = Keypair.random();
639
+ const { xdr, serverKeypair } = buildClientDomainChallenge(clientKeypair);
640
+
641
+ const accountKp = SigningKeypair.fromSecret(clientKeypair.secret());
642
+ const token = createJwt(clientKeypair);
643
+ const { sep10 } = setupSep10({
644
+ serverSigningKey: serverKeypair.publicKey(),
645
+ challengeXdr: xdr,
646
+ token,
647
+ });
648
+
649
+ const walletSigner: WalletSigner = {
650
+ signWithClientAccount: ({ transaction, accountKp: kp }) => {
651
+ transaction.sign(kp.keypair);
652
+ return transaction;
653
+ },
654
+ // eslint-disable-next-line @typescript-eslint/require-await
655
+ signWithDomainAccount: async ({ transactionXDR }) => {
656
+ const tx = SdkTransactionBuilder.fromXDR(
657
+ transactionXDR,
658
+ networkPassphrase,
659
+ ) as Transaction;
660
+ tx.sign(Keypair.random());
661
+ return tx;
662
+ },
663
+ };
664
+
665
+ const authToken = await sep10.authenticate({
666
+ accountKp,
667
+ clientDomain: "wallet.example.com",
668
+ walletSigner,
669
+ });
670
+ expect(authToken.account).toBe(clientKeypair.publicKey());
671
+ });
672
+
673
+ it("should reject when domain signer returns a transaction with a different body", async () => {
674
+ const clientKeypair = Keypair.random();
675
+ const { xdr, serverKeypair } = buildClientDomainChallenge(clientKeypair);
676
+
677
+ const accountKp = SigningKeypair.fromSecret(clientKeypair.secret());
678
+ const token = createJwt(clientKeypair);
679
+ const { sep10, postStub } = setupSep10({
680
+ serverSigningKey: serverKeypair.publicKey(),
681
+ challengeXdr: xdr,
682
+ token,
683
+ });
684
+
685
+ const differentSource = new Account(clientKeypair.publicKey(), "0");
686
+ const differentTx = new SdkTransactionBuilder(differentSource, {
687
+ fee: BASE_FEE,
688
+ networkPassphrase,
689
+ })
690
+ .addOperation(
691
+ Operation.payment({
692
+ destination: Keypair.random().publicKey(),
693
+ asset: Asset.native(),
694
+ amount: "100",
695
+ }),
696
+ )
697
+ .setTimeout(300)
698
+ .build();
699
+
700
+ const signWithClientAccountSpy = sinon.spy(
701
+ ({ transaction, accountKp: kp }) => {
702
+ transaction.sign(kp.keypair);
703
+ return transaction;
704
+ },
705
+ );
706
+ const walletSigner: WalletSigner = {
707
+ signWithClientAccount: signWithClientAccountSpy,
708
+ // eslint-disable-next-line @typescript-eslint/require-await
709
+ signWithDomainAccount: async () => differentTx,
710
+ };
711
+
712
+ await expect(
713
+ sep10.authenticate({
714
+ accountKp,
715
+ clientDomain: "wallet.example.com",
716
+ walletSigner,
717
+ }),
718
+ ).rejects.toThrow(DomainSigningModifiedError);
719
+ expect(signWithClientAccountSpy.notCalled).toBe(true);
720
+ expect(postStub.notCalled).toBe(true);
721
+ });
722
+ });
723
+
621
724
  describe("network passphrase mismatch", () => {
622
725
  it("should reject when server returns a different network passphrase", async () => {
623
726
  const { xdr, serverKeypair, clientKeypair } = buildChallenge();
@@ -1,5 +1,12 @@
1
- import { Keypair, Memo, MemoText, Horizon } from "@stellar/stellar-sdk";
1
+ import {
2
+ Keypair,
3
+ Memo,
4
+ MemoText,
5
+ Horizon,
6
+ Transaction,
7
+ } from "@stellar/stellar-sdk";
2
8
  import axios from "axios";
9
+ import sinon from "sinon";
3
10
 
4
11
  import { Stellar, Wallet } from "../src";
5
12
  import {
@@ -14,6 +21,7 @@ import {
14
21
  } from "../src/walletSdk/Asset";
15
22
  import { TransactionStatus, WithdrawTransaction } from "../src/walletSdk/Types";
16
23
  import {
24
+ TransactionSubmitWithFeeIncreaseFailedError,
17
25
  WithdrawalTxMissingDestinationError,
18
26
  WithdrawalTxMissingMemoError,
19
27
  WithdrawalTxNotPendingUserTransferStartError,
@@ -158,6 +166,51 @@ describe("Stellar", () => {
158
166
  expect(txn.fee).toBe("200");
159
167
  });
160
168
 
169
+ it("should enforce maxFee across multiple submitWithFeeIncrease retries", async () => {
170
+ const txTooLateRejection = {
171
+ response: {
172
+ status: 400,
173
+ statusText: "Bad Request",
174
+ data: {
175
+ extras: {
176
+ result_codes: { transaction: "tx_too_late" },
177
+ },
178
+ },
179
+ },
180
+ };
181
+
182
+ // Always reject with tx_too_late so the fee climbs on every retry. With the
183
+ // fix the maxFee cap is honored across all retries and submitWithFeeIncrease
184
+ // eventually throws; without it the cap is dropped after the first retry and
185
+ // the recursion never stops. Restore the spy in a finally block: a leaked
186
+ // mock on submitTransaction would turn the next test's real submission into
187
+ // a no-op.
188
+ const submitStub = jest
189
+ .spyOn(stellar, "submitTransaction")
190
+ .mockRejectedValue(txTooLateRejection);
191
+
192
+ const buildingFunction = (builder) =>
193
+ builder.transfer(kp.publicKey, new NativeAssetId(), "2");
194
+
195
+ try {
196
+ await expect(
197
+ stellar.submitWithFeeIncrease({
198
+ sourceAddress: kp,
199
+ timeout: 180,
200
+ baseFeeIncrease: 100,
201
+ buildingFunction,
202
+ baseFee: 100,
203
+ maxFee: 250,
204
+ }),
205
+ ).rejects.toThrow(TransactionSubmitWithFeeIncreaseFailedError);
206
+ // The cap is enforced only after several retries, proving it survives
207
+ // past the first retry (the bug being fixed).
208
+ expect(submitStub.mock.calls.length).toBeGreaterThan(1);
209
+ } finally {
210
+ submitStub.mockRestore();
211
+ }
212
+ });
213
+
161
214
  it("should add and remove asset support", async () => {
162
215
  const asset = new IssuedAssetId(
163
216
  "USDC",
@@ -202,6 +255,74 @@ describe("Stellar", () => {
202
255
  expect(fee).toBeTruthy();
203
256
  });
204
257
 
258
+ describe("submitTransaction 504 handling", () => {
259
+ let clock: sinon.SinonFakeTimers;
260
+ let randomStub: sinon.SinonStub;
261
+
262
+ beforeEach(() => {
263
+ clock = sinon.useFakeTimers();
264
+ // Zero jitter for deterministic test timing.
265
+ randomStub = sinon.stub(Math, "random").returns(0);
266
+ });
267
+
268
+ afterEach(() => {
269
+ clock.restore();
270
+ randomStub.restore();
271
+ jest.restoreAllMocks();
272
+ });
273
+
274
+ const make504 = () => ({ response: { status: 504 } });
275
+
276
+ it("retries once on 504 then returns on success", async () => {
277
+ const serverStub = jest
278
+ .spyOn(stellar.server, "submitTransaction")
279
+ .mockRejectedValueOnce(make504())
280
+ .mockResolvedValueOnce({ successful: true } as any);
281
+
282
+ const tx = {} as Transaction;
283
+ const promise = stellar.submitTransaction(tx);
284
+
285
+ // Equal-jitter sleep at attempt 0 with Math.random stubbed to 0 is
286
+ // cappedDelay/2 = 1000/2 = 500ms.
287
+ await clock.tickAsync(500);
288
+
289
+ await expect(promise).resolves.toBe(true);
290
+ expect(serverStub).toHaveBeenCalledTimes(2);
291
+ });
292
+
293
+ it("rethrows immediately on a non-504 error", async () => {
294
+ const serverStub = jest
295
+ .spyOn(stellar.server, "submitTransaction")
296
+ .mockRejectedValueOnce({ response: { status: 500 } });
297
+
298
+ await expect(
299
+ stellar.submitTransaction({} as Transaction),
300
+ ).rejects.toMatchObject({ response: { status: 500 } });
301
+ expect(serverStub).toHaveBeenCalledTimes(1);
302
+ });
303
+
304
+ it("gives up after the bounded number of retries and rethrows the last 504", async () => {
305
+ // SUBMIT_504_MAX_RETRIES = 5 → 1 initial + 5 retries = 6 total attempts.
306
+ const serverStub = jest
307
+ .spyOn(stellar.server, "submitTransaction")
308
+ .mockRejectedValue(make504());
309
+
310
+ const promise = stellar.submitTransaction({} as Transaction);
311
+ // Surface the rejection eagerly so an unhandled rejection doesn't crash
312
+ // the test runner mid-tick.
313
+ promise.catch(() => undefined);
314
+
315
+ // Sum of equal-jitter sleeps (cappedDelay/2) with Math.random stubbed
316
+ // to 0: 500 + 1000 + 2000 + 4000 + 8000 = 15500ms.
317
+ await clock.tickAsync(15500);
318
+
319
+ await expect(promise).rejects.toMatchObject({
320
+ response: { status: 504 },
321
+ });
322
+ expect(serverStub).toHaveBeenCalledTimes(6);
323
+ });
324
+ });
325
+
205
326
  describe("TransactionBuilder/transferWithdrawalTransaction", () => {
206
327
  it("should transfer withdrawal transaction", async () => {
207
328
  const memoExamples = [