@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.
- package/CHANGELOG.MD +7 -0
- package/lib/bundle.js +83 -38
- package/lib/bundle.js.map +1 -1
- package/lib/bundle_browser.js +83 -38
- package/lib/bundle_browser.js.map +1 -1
- package/lib/walletSdk/Exceptions/index.d.ts +3 -0
- package/lib/walletSdk/Horizon/Stellar.d.ts +15 -4
- package/package.json +1 -1
- package/src/walletSdk/Auth/index.ts +21 -8
- package/src/walletSdk/Exceptions/index.ts +9 -0
- package/src/walletSdk/Horizon/Stellar.ts +51 -15
- package/test/auth.test.ts +104 -1
- package/test/stellar.test.ts +122 -1
|
@@ -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
|
|
48
|
-
*
|
|
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
|
|
51
|
-
* @throws {TransactionSubmitFailedError} If
|
|
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
|
@@ -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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
122
|
-
*
|
|
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
|
|
125
|
-
* @throws {TransactionSubmitFailedError} If
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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();
|
package/test/stellar.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
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 = [
|