@stellar/typescript-wallet-sdk 3.0.2 → 3.0.3-beta.1786398758713

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.
@@ -1,4 +1,4 @@
1
- import { Account as StellarAccount, Transaction, Horizon, Memo, xdr } from "@stellar/stellar-sdk";
1
+ import { TransactionSource, Transaction, Horizon, Memo, xdr } from "@stellar/stellar-sdk";
2
2
  import { Config } from "../../";
3
3
  import { AccountKeypair } from "../Account";
4
4
  import { StellarAssetId } from "../../Asset";
@@ -17,12 +17,12 @@ export declare class TransactionBuilder extends CommonTransactionBuilder<Transac
17
17
  * Creates a new instance of the TransactionBuilder class for constructing Stellar transactions.
18
18
  * @constructor
19
19
  * @param {Config} cfg - Configuration object for Stellar operations.
20
- * @param {StellarAccount} sourceAccount - The source account for the transaction.
20
+ * @param {TransactionSource} sourceAccount - The source account for the transaction.
21
21
  * @param {number} [baseFee] - The base fee for the transaction. If not given will use the config base fee.
22
22
  * @param {Memo} [memo] - The memo for the transaction.
23
23
  * @param {Horizon.Server.Timebounds} [timebounds] - The timebounds for the transaction. If not given will use the config timebounds.
24
24
  */
25
- constructor(cfg: Config, sourceAccount: StellarAccount, baseFee?: number, memo?: Memo, timebounds?: Horizon.Server.Timebounds);
25
+ constructor(cfg: Config, sourceAccount: TransactionSource, baseFee?: number, memo?: Memo, timebounds?: Horizon.Server.Timebounds);
26
26
  /**
27
27
  * Sponsoring a transaction.
28
28
  * @param {AccountKeypair} sponsorAccount - The account doing the sponsoring.
@@ -14,3 +14,21 @@ export type IsValidSep7UriResult = {
14
14
  result: boolean;
15
15
  reason?: string;
16
16
  };
17
+ /**
18
+ * Matches a fully qualified domain name (e.g. "example.com", "sub.example.co.uk").
19
+ *
20
+ * Rejects userinfo ('@'), ports (':'), paths/queries ('/', '?'), scheme
21
+ * prefixes ('//'), bare hostnames like 'localhost', and raw IPv4/IPv6
22
+ * addresses (the all-numeric final label fails the alphabetic TLD rule).
23
+ *
24
+ * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#verifying-the-signature step 3.
25
+ */
26
+ export declare const FULLY_QUALIFIED_DOMAIN_NAME_REGEX: RegExp;
27
+ /**
28
+ * Returns true if the given string is a valid fully qualified domain name.
29
+ *
30
+ * @param {string} domain the domain string to validate.
31
+ *
32
+ * @returns {boolean} true if 'domain' is a valid fully qualified domain name.
33
+ */
34
+ export declare const isValidFullyQualifiedDomainName: (domain: string) => boolean;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@stellar/typescript-wallet-sdk",
3
- "version": "3.0.2",
3
+ "version": "3.0.3-beta.1786398758713",
4
4
  "engines": {
5
- "node": ">=20"
5
+ "node": ">=22"
6
6
  },
7
7
  "browser": "./lib/bundle_browser.js",
8
8
  "main": "./lib/bundle.js",
@@ -35,6 +35,7 @@
35
35
  "pretty-quick": "^2.0.1",
36
36
  "process": "^0.11.10",
37
37
  "sinon": "^15.1.0",
38
+ "smol-toml": "^1.7.1",
38
39
  "stream-browserify": "^3.0.0",
39
40
  "ts-jest": "^29.0.5",
40
41
  "ts-loader": "^9.4.2",
@@ -47,7 +48,7 @@
47
48
  "dependencies": {
48
49
  "@stablelib/base64": "^2.0.0",
49
50
  "@stablelib/utf8": "^2.0.0",
50
- "@stellar/stellar-sdk": "15.0.1",
51
+ "@stellar/stellar-sdk": "16.2.0",
51
52
  "axios": "^1.4.0",
52
53
  "base64url": "^3.0.1",
53
54
  "https-browserify": "^1.0.0",
@@ -75,4 +76,4 @@
75
76
  "example:sep24": "ts-node examples/sep24/sep24.ts",
76
77
  "example:sep12": "ts-node examples/sep12/sep12.ts"
77
78
  }
78
- }
79
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Lazy `eventsource` facade.
3
+ *
4
+ * eventsource v4 subclasses the DOM globals `Event`/`EventTarget` at module
5
+ * scope, which React Native's Hermes does not provide. Horizon's CallBuilder
6
+ * requires this module eagerly but only reads it inside `stream()`, so deferring
7
+ * evaluation to first property access keeps imports working everywhere while
8
+ * leaving streaming intact wherever those globals exist.
9
+ *
10
+ * Aliased in webpack.config.js: `eventsource$` resolves here, `eventsource-real$`
11
+ * to the real package.
12
+ */
13
+ let realModule;
14
+
15
+ const load = () => {
16
+ if (!realModule) {
17
+ try {
18
+ // eslint-disable-next-line global-require
19
+ realModule = require("eventsource-real");
20
+ } catch (cause) {
21
+ // CallBuilder.stream() catches this and retries on a timer, so make the
22
+ // error it reports to onerror self-explanatory.
23
+ throw new Error(
24
+ "Horizon streaming needs the DOM Event/EventTarget globals and a " +
25
+ "streaming fetch body, which React Native does not provide. Alias " +
26
+ '"eventsource" to a native SSE client (e.g. react-native-sse) to use ' +
27
+ `CallBuilder.stream(). Original error: ${cause.message}`,
28
+ );
29
+ }
30
+ }
31
+
32
+ return realModule;
33
+ };
34
+
35
+ Object.defineProperty(exports, "EventSource", {
36
+ enumerable: true,
37
+ configurable: true,
38
+ get: () => load().EventSource,
39
+ });
40
+
41
+ Object.defineProperty(exports, "ErrorEvent", {
42
+ enumerable: true,
43
+ configurable: true,
44
+ get: () => load().ErrorEvent,
45
+ });
@@ -96,13 +96,20 @@ export class AccountService {
96
96
  throw new OperationsLimitExceededError(HORIZON_LIMIT_MAX);
97
97
  }
98
98
 
99
- return this.server
99
+ let builder = this.server
100
100
  .operations()
101
101
  .forAccount(accountAddress)
102
102
  .limit(limit)
103
103
  .order(order)
104
- .cursor(cursor)
105
- .includeFailed(includeFailed)
106
- .call();
104
+ .includeFailed(includeFailed);
105
+
106
+ // Only set the cursor when provided. In stellar-sdk v16 the call builder
107
+ // serializes query params via URLSearchParams, so passing an undefined
108
+ // cursor would send the literal "cursor=undefined" and Horizon returns 400.
109
+ if (cursor) {
110
+ builder = builder.cursor(cursor);
111
+ }
112
+
113
+ return builder.call();
107
114
  }
108
115
  }
@@ -1,5 +1,5 @@
1
1
  import {
2
- Account as StellarAccount,
2
+ TransactionSource,
3
3
  Horizon,
4
4
  Transaction,
5
5
  TransactionBuilder as StellarTransactionBuilder,
@@ -75,7 +75,7 @@ export class Stellar {
75
75
  memo,
76
76
  timebounds,
77
77
  }: TransactionParams): Promise<TransactionBuilder> {
78
- let sourceAccount: StellarAccount;
78
+ let sourceAccount: TransactionSource;
79
79
  try {
80
80
  sourceAccount = await this.cfg.stellar.server.loadAccount(
81
81
  sourceAddress.publicKey,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Operation,
3
3
  TransactionBuilder as StellarTransactionBuilder,
4
- Account as StellarAccount,
4
+ TransactionSource,
5
5
  Transaction,
6
6
  Horizon,
7
7
  Memo,
@@ -40,14 +40,14 @@ export class TransactionBuilder extends CommonTransactionBuilder<TransactionBuil
40
40
  * Creates a new instance of the TransactionBuilder class for constructing Stellar transactions.
41
41
  * @constructor
42
42
  * @param {Config} cfg - Configuration object for Stellar operations.
43
- * @param {StellarAccount} sourceAccount - The source account for the transaction.
43
+ * @param {TransactionSource} sourceAccount - The source account for the transaction.
44
44
  * @param {number} [baseFee] - The base fee for the transaction. If not given will use the config base fee.
45
45
  * @param {Memo} [memo] - The memo for the transaction.
46
46
  * @param {Horizon.Server.Timebounds} [timebounds] - The timebounds for the transaction. If not given will use the config timebounds.
47
47
  */
48
48
  constructor(
49
49
  cfg: Config,
50
- sourceAccount: StellarAccount,
50
+ sourceAccount: TransactionSource,
51
51
  baseFee?: number,
52
52
  memo?: Memo,
53
53
  timebounds?: Horizon.Server.Timebounds,
@@ -266,18 +266,21 @@ export class TransactionBuilder extends CommonTransactionBuilder<TransactionBuil
266
266
  throw new WithdrawalTxMissingMemoError();
267
267
  }
268
268
 
269
- if (transaction.withdraw_memo_type === "hash") {
270
- try {
269
+ // The memo type/value come from the anchor (untrusted input). stellar-sdk
270
+ // validates them when the Memo is constructed and throws on anything
271
+ // malformed, so map any failure to a domain WithdrawalTxMemoError for both
272
+ // the hash and non-hash paths rather than leaking the raw SDK error.
273
+ try {
274
+ if (transaction.withdraw_memo_type === "hash") {
271
275
  const buffer = Buffer.from(transaction.withdraw_memo, "base64");
272
- const memo = Memo.hash(buffer.toString("hex"));
273
- this.setMemo(memo);
274
- } catch {
275
- throw new WithdrawalTxMemoError();
276
+ this.setMemo(Memo.hash(buffer.toString("hex")));
277
+ } else {
278
+ this.setMemo(
279
+ new Memo(transaction.withdraw_memo_type, transaction.withdraw_memo),
280
+ );
276
281
  }
277
- } else {
278
- this.setMemo(
279
- new Memo(transaction.withdraw_memo_type, transaction.withdraw_memo),
280
- );
282
+ } catch {
283
+ throw new WithdrawalTxMemoError();
281
284
  }
282
285
 
283
286
  return this.transfer(
@@ -18,3 +18,25 @@ export type IsValidSep7UriResult = {
18
18
  result: boolean;
19
19
  reason?: string;
20
20
  };
21
+
22
+ /**
23
+ * Matches a fully qualified domain name (e.g. "example.com", "sub.example.co.uk").
24
+ *
25
+ * Rejects userinfo ('@'), ports (':'), paths/queries ('/', '?'), scheme
26
+ * prefixes ('//'), bare hostnames like 'localhost', and raw IPv4/IPv6
27
+ * addresses (the all-numeric final label fails the alphabetic TLD rule).
28
+ *
29
+ * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#verifying-the-signature step 3.
30
+ */
31
+ export const FULLY_QUALIFIED_DOMAIN_NAME_REGEX =
32
+ /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
33
+
34
+ /**
35
+ * Returns true if the given string is a valid fully qualified domain name.
36
+ *
37
+ * @param {string} domain the domain string to validate.
38
+ *
39
+ * @returns {boolean} true if 'domain' is a valid fully qualified domain name.
40
+ */
41
+ export const isValidFullyQualifiedDomainName = (domain: string): boolean =>
42
+ FULLY_QUALIFIED_DOMAIN_NAME_REGEX.test(domain);
@@ -1,5 +1,9 @@
1
1
  import { Keypair, Networks, StellarToml } from "@stellar/stellar-sdk";
2
- import { Sep7OperationType, URI_MSG_MAX_LENGTH } from "../Types";
2
+ import {
3
+ Sep7OperationType,
4
+ URI_MSG_MAX_LENGTH,
5
+ isValidFullyQualifiedDomainName,
6
+ } from "../Types";
3
7
  import { Sep7LongMsgError } from "../Exceptions";
4
8
 
5
9
  /**
@@ -234,6 +238,15 @@ export abstract class Sep7Base {
234
238
  return false;
235
239
  }
236
240
 
241
+ // SEP-7 step 3: "If the origin_domain is not a valid fully qualified
242
+ // domain name, do not allow the user to sign the transaction." This must
243
+ // be enforced before resolving the stellar.toml, otherwise a malformed
244
+ // origin_domain (e.g. containing userinfo, a port, or a path) could
245
+ // redirect the trust-anchoring TOML fetch to an attacker-controlled host.
246
+ if (!isValidFullyQualifiedDomainName(originDomain)) {
247
+ return false;
248
+ }
249
+
237
250
  try {
238
251
  const toml = await StellarToml.Resolver.resolve(originDomain);
239
252
  const signingKey = toml.URI_REQUEST_SIGNING_KEY;
@@ -8,6 +8,7 @@ import {
8
8
  WEB_STELLAR_SCHEME,
9
9
  URI_MSG_MAX_LENGTH,
10
10
  URI_REPLACE_MAX_LENGTH,
11
+ isValidFullyQualifiedDomainName,
11
12
  } from "../Types";
12
13
  import {
13
14
  Sep7InvalidUriError,
@@ -42,6 +43,7 @@ export const isValidSep7Uri = (uri: string): IsValidSep7UriResult => {
42
43
  url.searchParams.get("network_passphrase") || Networks.PUBLIC;
43
44
  const destination = url.searchParams.get("destination");
44
45
  const msg = url.searchParams.get("msg");
46
+ const originDomain = url.searchParams.get("origin_domain");
45
47
 
46
48
  if (![Sep7OperationType.tx, Sep7OperationType.pay].includes(type)) {
47
49
  return {
@@ -98,6 +100,18 @@ export const isValidSep7Uri = (uri: string): IsValidSep7UriResult => {
98
100
  };
99
101
  }
100
102
 
103
+ // 'origin_domain' is optional (SEP-7), but when present it must be a valid
104
+ // fully qualified domain name (SEP-7 step 3), otherwise a malformed value
105
+ // could later redirect the trust-anchoring stellar.toml fetch in
106
+ // Sep7Base.verifySignature() to an attacker-controlled host.
107
+ if (originDomain && !isValidFullyQualifiedDomainName(originDomain)) {
108
+ return {
109
+ result: false,
110
+ reason:
111
+ "the provided 'origin_domain' parameter is not a valid fully qualified domain name",
112
+ };
113
+ }
114
+
101
115
  return {
102
116
  result: true,
103
117
  };
@@ -1,8 +1,9 @@
1
1
  import axios from "axios";
2
2
  import { Horizon } from "@stellar/stellar-sdk";
3
+ import sinon from "sinon";
3
4
 
4
5
  import { AccountService, SigningKeypair, Wallet } from "../src";
5
- import { HORIZON_ORDER } from "../src/walletSdk/Types";
6
+ import { HORIZON_ORDER, HORIZON_LIMIT_DEFAULT } from "../src/walletSdk/Types";
6
7
  import { IssuedAssetId } from "../src/walletSdk/Asset";
7
8
 
8
9
  let accountService: AccountService;
@@ -45,7 +46,7 @@ describe("Horizon", () => {
45
46
  await stellar.submitTransaction(addUsdcTx);
46
47
 
47
48
  accountAddress = testingAccountKp.publicKey;
48
- }, 30000);
49
+ }, 120000);
49
50
 
50
51
  it("should return stellar account details", async () => {
51
52
  const response = await accountService.getInfo({ accountAddress });
@@ -97,3 +98,58 @@ describe("Horizon", () => {
97
98
  ).toBeTruthy();
98
99
  });
99
100
  });
101
+
102
+ // Hermetic (no network) — kept out of the "Horizon" describe so its
103
+ // friendbot/loadAccount beforeAll does not run.
104
+ describe("AccountService.getHistory cursor handling", () => {
105
+ const accountAddress =
106
+ "GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB";
107
+ let service: AccountService;
108
+ let fakeBuilder: Record<string, sinon.SinonStub>;
109
+
110
+ beforeEach(() => {
111
+ service = Wallet.TestNet().stellar().account();
112
+ fakeBuilder = {
113
+ forAccount: sinon.stub(),
114
+ limit: sinon.stub(),
115
+ order: sinon.stub(),
116
+ includeFailed: sinon.stub(),
117
+ cursor: sinon.stub(),
118
+ call: sinon.stub().resolves({ records: [] }),
119
+ };
120
+ // Every chained method returns the builder itself.
121
+ ["forAccount", "limit", "order", "includeFailed", "cursor"].forEach((m) =>
122
+ fakeBuilder[m].returns(fakeBuilder),
123
+ );
124
+ sinon
125
+ .stub(
126
+ service["server"] as unknown as { operations: () => unknown },
127
+ "operations",
128
+ )
129
+ .returns(fakeBuilder);
130
+ });
131
+
132
+ afterEach(() => {
133
+ sinon.restore();
134
+ });
135
+
136
+ // Regression guard for the v16 URLSearchParams change: passing an undefined
137
+ // cursor into the call builder would serialize "cursor=undefined" and Horizon
138
+ // responds 400, so getHistory must omit .cursor() when none is provided.
139
+ it("omits .cursor() when no cursor is provided", async () => {
140
+ await service.getHistory({ accountAddress });
141
+
142
+ expect(fakeBuilder.cursor.called).toBe(false);
143
+ expect(fakeBuilder.call.calledOnce).toBe(true);
144
+ expect(fakeBuilder.limit.calledWith(HORIZON_LIMIT_DEFAULT)).toBe(true);
145
+ expect(fakeBuilder.order.calledWith(HORIZON_ORDER.DESC)).toBe(true);
146
+ expect(fakeBuilder.includeFailed.calledWith(false)).toBe(true);
147
+ });
148
+
149
+ it("applies .cursor(value) when a cursor is provided", async () => {
150
+ await service.getHistory({ accountAddress, cursor: "abc123" });
151
+
152
+ expect(fakeBuilder.cursor.calledOnceWithExactly("abc123")).toBe(true);
153
+ expect(fakeBuilder.call.calledOnce).toBe(true);
154
+ });
155
+ });
@@ -14,7 +14,7 @@ describe("Customer", () => {
14
14
  await axios.get(
15
15
  "https://friendbot.stellar.org/?addr=" + accountKp.publicKey,
16
16
  );
17
- }, 10000);
17
+ }, 120000);
18
18
 
19
19
  // skipped: the testanchor changed its SEP-12 API behavior
20
20
  test.skip("Sep-12 methods work", async () => {
@@ -89,5 +89,5 @@ describe("Customer", () => {
89
89
 
90
90
  // Delete
91
91
  await sep12.delete();
92
- }, 20000);
92
+ }, 120000);
93
93
  });
@@ -38,7 +38,7 @@ describe("Recovery / Register Signers", () => {
38
38
  "https://friendbot.stellar.org/?addr=" + sponsorAccountKp.publicKey,
39
39
  );
40
40
  }
41
- }, 60000);
41
+ }, 120000);
42
42
 
43
43
  it("defaults work", async () => {
44
44
  const transaction = await wallet
@@ -11,7 +11,7 @@ describe("SEP-38", () => {
11
11
  anchor = wallet.anchor({ homeDomain: "testanchor.stellar.org" });
12
12
  sep38 = anchor.sep38();
13
13
  accountKp = accountKp = wallet.stellar().account().createKeypair();
14
- }, 10000);
14
+ }, 120000);
15
15
 
16
16
  it("should get Sep-38 anchor info", async () => {
17
17
  const resp = await sep38.info();
package/test/sep6.test.ts CHANGED
@@ -16,7 +16,7 @@ describe("SEP-6", () => {
16
16
  await axios.get(
17
17
  "https://friendbot.stellar.org/?addr=" + accountKp.publicKey,
18
18
  );
19
- }, 10000);
19
+ }, 120000);
20
20
 
21
21
  it("should get anchor info", async () => {
22
22
  const resp = await sep6.info();
@@ -172,7 +172,7 @@ describe("SEP-6", () => {
172
172
  resp = await anchor.sep6().getTransactionBy({ authToken, id });
173
173
 
174
174
  expect(resp.id).toEqual(id);
175
- }, 10000);
175
+ }, 120000);
176
176
 
177
177
  let txId;
178
178
 
package/test/sep7.test.ts CHANGED
@@ -226,6 +226,115 @@ describe("Sep7Base", () => {
226
226
 
227
227
  expect(await uri.verifySignature()).toBe(true);
228
228
  });
229
+
230
+ it("verifySignature() returns false and never resolves the toml when origin_domain has userinfo (RFC 3986 confusion)", async () => {
231
+ const uriStr =
232
+ "web+stellar:tx?xdr=test&origin_domain=stellar.org%40evil.com&signature=sig";
233
+ const uri = new Sep7Tx(uriStr);
234
+
235
+ const resolveSpy = jest
236
+ .spyOn(StellarToml.Resolver, "resolve")
237
+ .mockResolvedValue({
238
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
239
+ });
240
+ // jest.spyOn() re-spying on an already-mocked function preserves the
241
+ // existing call history, so clear it to accurately assert non-invocation.
242
+ resolveSpy.mockClear();
243
+
244
+ expect(await uri.verifySignature()).toBe(false);
245
+ expect(resolveSpy).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it("verifySignature() returns false and never resolves the toml when origin_domain has a port", async () => {
249
+ const uriStr =
250
+ "web+stellar:tx?xdr=test&origin_domain=example.com%3A8443&signature=sig";
251
+ const uri = new Sep7Tx(uriStr);
252
+
253
+ const resolveSpy = jest
254
+ .spyOn(StellarToml.Resolver, "resolve")
255
+ .mockResolvedValue({
256
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
257
+ });
258
+ // jest.spyOn() re-spying on an already-mocked function preserves the
259
+ // existing call history, so clear it to accurately assert non-invocation.
260
+ resolveSpy.mockClear();
261
+
262
+ expect(await uri.verifySignature()).toBe(false);
263
+ expect(resolveSpy).not.toHaveBeenCalled();
264
+ });
265
+
266
+ it("verifySignature() returns false and never resolves the toml when origin_domain has a path", async () => {
267
+ const uriStr =
268
+ "web+stellar:tx?xdr=test&origin_domain=example.com%2Fpath&signature=sig";
269
+ const uri = new Sep7Tx(uriStr);
270
+
271
+ const resolveSpy = jest
272
+ .spyOn(StellarToml.Resolver, "resolve")
273
+ .mockResolvedValue({
274
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
275
+ });
276
+ // jest.spyOn() re-spying on an already-mocked function preserves the
277
+ // existing call history, so clear it to accurately assert non-invocation.
278
+ resolveSpy.mockClear();
279
+
280
+ expect(await uri.verifySignature()).toBe(false);
281
+ expect(resolveSpy).not.toHaveBeenCalled();
282
+ });
283
+
284
+ it("verifySignature() returns false and never resolves the toml when origin_domain is a raw IPv4 address", async () => {
285
+ const uriStr =
286
+ "web+stellar:tx?xdr=test&origin_domain=127.0.0.1&signature=sig";
287
+ const uri = new Sep7Tx(uriStr);
288
+
289
+ const resolveSpy = jest
290
+ .spyOn(StellarToml.Resolver, "resolve")
291
+ .mockResolvedValue({
292
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
293
+ });
294
+ // jest.spyOn() re-spying on an already-mocked function preserves the
295
+ // existing call history, so clear it to accurately assert non-invocation.
296
+ resolveSpy.mockClear();
297
+
298
+ expect(await uri.verifySignature()).toBe(false);
299
+ expect(resolveSpy).not.toHaveBeenCalled();
300
+ });
301
+
302
+ it("verifySignature() returns false and never resolves the toml when origin_domain is 'localhost'", async () => {
303
+ const uriStr =
304
+ "web+stellar:tx?xdr=test&origin_domain=localhost&signature=sig";
305
+ const uri = new Sep7Tx(uriStr);
306
+
307
+ const resolveSpy = jest
308
+ .spyOn(StellarToml.Resolver, "resolve")
309
+ .mockResolvedValue({
310
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
311
+ });
312
+ // jest.spyOn() re-spying on an already-mocked function preserves the
313
+ // existing call history, so clear it to accurately assert non-invocation.
314
+ resolveSpy.mockClear();
315
+
316
+ expect(await uri.verifySignature()).toBe(false);
317
+ expect(resolveSpy).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it("verifySignature() returns false and never resolves the toml when origin_domain has an injected CRLF", async () => {
321
+ // 'stellar.org%0d%0aevil.com' URL-decodes to 'stellar.org\r\nevil.com'.
322
+ const uriStr =
323
+ "web+stellar:tx?xdr=test&origin_domain=stellar.org%0d%0aevil.com&signature=sig";
324
+ const uri = new Sep7Tx(uriStr);
325
+
326
+ const resolveSpy = jest
327
+ .spyOn(StellarToml.Resolver, "resolve")
328
+ .mockResolvedValue({
329
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
330
+ });
331
+ // jest.spyOn() re-spying on an already-mocked function preserves the
332
+ // existing call history, so clear it to accurately assert non-invocation.
333
+ resolveSpy.mockClear();
334
+
335
+ expect(await uri.verifySignature()).toBe(false);
336
+ expect(resolveSpy).not.toHaveBeenCalled();
337
+ });
229
338
  });
230
339
 
231
340
  describe("Sep7Tx", () => {
@@ -240,7 +349,7 @@ describe("Sep7Tx", () => {
240
349
  await stellar.fundTestnetAccount(testKp1.publicKey);
241
350
  await stellar.fundTestnetAccount(testKp2.publicKey);
242
351
  }
243
- }, 30000);
352
+ }, 120000);
244
353
 
245
354
  it("forTransaction sets the tx parameter", async () => {
246
355
  const txBuilder = await stellar.transaction({
@@ -589,6 +698,32 @@ describe("sep7Parser", () => {
589
698
  ).toBe(true);
590
699
  });
591
700
 
701
+ it("isValidSep7Uri(uri) returns true when 'origin_domain' is absent", () => {
702
+ expect(
703
+ isValidSep7Uri(
704
+ "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens",
705
+ ).result,
706
+ ).toBe(true);
707
+ });
708
+
709
+ it("isValidSep7Uri(uri) returns true when 'origin_domain' is a valid fully qualified domain name", () => {
710
+ expect(
711
+ isValidSep7Uri(
712
+ "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens&origin_domain=someDomain.com",
713
+ ).result,
714
+ ).toBe(true);
715
+ });
716
+
717
+ it("isValidSep7Uri(uri) returns 'false' with 'reason' when 'origin_domain' is not a valid fully qualified domain name", () => {
718
+ const validation = isValidSep7Uri(
719
+ "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens&origin_domain=stellar.org%40evil.com",
720
+ );
721
+ expect(validation.result).toBe(false);
722
+ expect(validation.reason).toBe(
723
+ "the provided 'origin_domain' parameter is not a valid fully qualified domain name",
724
+ );
725
+ });
726
+
592
727
  it("isValidSep7Uri(uri) returns 'false' with 'reason' when it is not valid in some way", () => {
593
728
  const validation1 = isValidSep7Uri(
594
729
  "not-a-stellar-uri:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024",
@@ -834,7 +969,9 @@ describe("sep7Parser", () => {
834
969
 
835
970
  it("Sep7Tx.getReplacements() throws on replace param missing hints", () => {
836
971
  const uri = new Sep7Tx(
837
- `web+stellar:tx?xdr=test&replace=${encodeURIComponent("sourceAccount:X")}`,
972
+ `web+stellar:tx?xdr=test&replace=${encodeURIComponent(
973
+ "sourceAccount:X",
974
+ )}`,
838
975
  );
839
976
 
840
977
  expect(() => uri.getReplacements()).toThrow(Sep7InvalidUriError);