@stellar/typescript-wallet-sdk 2.0.0 → 3.0.0-beta.1779920488639

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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stellar/typescript-wallet-sdk",
3
- "version": "2.0.0",
3
+ "version": "3.0.0-beta.1779920488639",
4
4
  "engines": {
5
5
  "node": ">=20"
6
6
  },
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@stablelib/base64": "^2.0.0",
49
49
  "@stablelib/utf8": "^2.0.0",
50
- "@stellar/stellar-sdk": "14.5.0",
50
+ "@stellar/stellar-sdk": "15.0.1",
51
51
  "axios": "^1.4.0",
52
52
  "base64url": "^3.0.1",
53
53
  "https-browserify": "^1.0.0",
@@ -75,4 +75,4 @@
75
75
  "example:sep24": "ts-node examples/sep24/sep24.ts",
76
76
  "example:sep12": "ts-node examples/sep12/sep12.ts"
77
77
  }
78
- }
78
+ }
@@ -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.
@@ -127,21 +134,33 @@ export class Stellar {
127
134
  async submitTransaction(
128
135
  signedTransaction: Transaction | FeeBumpTransaction,
129
136
  ): 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
137
+ let lastError: unknown;
138
+ for (let attempt = 0; attempt <= SUBMIT_504_MAX_RETRIES; attempt++) {
139
+ try {
140
+ const response = await this.server.submitTransaction(signedTransaction);
141
+ if (!response.successful) {
142
+ throw new TransactionSubmitFailedError(response);
143
+ }
144
+ return true;
145
+ } catch (e) {
146
+ if (e?.response?.status !== 504) {
147
+ throw e;
148
+ }
149
+ lastError = e;
150
+ if (attempt === SUBMIT_504_MAX_RETRIES) {
151
+ break;
152
+ }
139
153
  // https://developers.stellar.org/api/errors/http-status-codes/horizon-specific/timeout
140
154
  // https://developers.stellar.org/docs/encyclopedia/error-handling#timeouts
141
- return await this.submitTransaction(signedTransaction);
155
+ const cappedDelay = Math.min(
156
+ SUBMIT_504_BASE_DELAY_MS * 2 ** attempt,
157
+ SUBMIT_504_MAX_DELAY_MS,
158
+ );
159
+ const jitter = Math.random() * (cappedDelay / 2);
160
+ await sleep(cappedDelay + jitter);
142
161
  }
143
- throw e;
144
162
  }
163
+ throw lastError;
145
164
  }
146
165
 
147
166
  /**
@@ -199,7 +218,7 @@ export class Stellar {
199
218
  if (resultCode === "tx_too_late") {
200
219
  const newFee = parseInt(transaction.fee) + baseFeeIncrease;
201
220
 
202
- if (maxFee && newFee > maxFee) {
221
+ if (maxFee !== undefined && newFee > maxFee) {
203
222
  throw new TransactionSubmitWithFeeIncreaseFailedError(maxFee, e);
204
223
  }
205
224
 
@@ -211,6 +230,7 @@ export class Stellar {
211
230
  signerFunction,
212
231
  baseFee: newFee,
213
232
  memo,
233
+ maxFee,
214
234
  });
215
235
  }
216
236
  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,73 @@ 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
+ // First retry waits SUBMIT_504_BASE_DELAY_MS = 1000ms (jitter stubbed to 0).
286
+ await clock.tickAsync(1000);
287
+
288
+ await expect(promise).resolves.toBe(true);
289
+ expect(serverStub).toHaveBeenCalledTimes(2);
290
+ });
291
+
292
+ it("rethrows immediately on a non-504 error", async () => {
293
+ const serverStub = jest
294
+ .spyOn(stellar.server, "submitTransaction")
295
+ .mockRejectedValueOnce({ response: { status: 500 } });
296
+
297
+ await expect(
298
+ stellar.submitTransaction({} as Transaction),
299
+ ).rejects.toMatchObject({ response: { status: 500 } });
300
+ expect(serverStub).toHaveBeenCalledTimes(1);
301
+ });
302
+
303
+ it("gives up after the bounded number of retries and rethrows the last 504", async () => {
304
+ // SUBMIT_504_MAX_RETRIES = 5 → 1 initial + 5 retries = 6 total attempts.
305
+ const serverStub = jest
306
+ .spyOn(stellar.server, "submitTransaction")
307
+ .mockRejectedValue(make504());
308
+
309
+ const promise = stellar.submitTransaction({} as Transaction);
310
+ // Surface the rejection eagerly so an unhandled rejection doesn't crash
311
+ // the test runner mid-tick.
312
+ promise.catch(() => undefined);
313
+
314
+ // Sum of capped exponential delays with zero jitter:
315
+ // 1000 + 2000 + 4000 + 8000 + 16000 = 31000ms.
316
+ await clock.tickAsync(31000);
317
+
318
+ await expect(promise).rejects.toMatchObject({
319
+ response: { status: 504 },
320
+ });
321
+ expect(serverStub).toHaveBeenCalledTimes(6);
322
+ });
323
+ });
324
+
205
325
  describe("TransactionBuilder/transferWithdrawalTransaction", () => {
206
326
  it("should transfer withdrawal transaction", async () => {
207
327
  const memoExamples = [
@@ -339,14 +339,15 @@ describe("Anchor", () => {
339
339
  expect(transactions.length === 2).toBeTruthy();
340
340
  });
341
341
 
342
- it("should accept any paging id when fetching transactions", async () => {
343
- const transactions = await anchor.sep24().getTransactionsForAsset({
344
- authToken,
345
- assetCode: "SRT",
346
- lang: "en-US",
347
- pagingId: "randomPagingId",
348
- });
349
- expect(transactions).toBeDefined();
342
+ it("should pass through paging id to the server", async () => {
343
+ await expect(
344
+ anchor.sep24().getTransactionsForAsset({
345
+ authToken,
346
+ assetCode: "SRT",
347
+ lang: "en-US",
348
+ pagingId: "randomPagingId",
349
+ }),
350
+ ).rejects.toThrow(ServerRequestFailedError);
350
351
  });
351
352
 
352
353
  describe("watchAllTransactions", () => {