From 60bf9787b271736ec8a77bea4aad7e1386964812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 2 Nov 2026 16:32:35 -0800 Subject: [PATCH 01/13] feat: support Protocol 30 muxed contract (W) addresses Bump @stellar/stellar-sdk from 17.0.1 to 17.4.0 in all three packages. 17.4.0 decodes and encodes CAP-84 muxed contract (W...) addresses, so a transaction that contains one decodes in Stellar.decodeTransaction, in the key manager's Albedo and Freighter handlers, and in the soroban helpers. On 17.0.1 decoding it threw "unknown enum value 5". Tests: - core: decodeTransaction decodes a SAC transfer to a W address, and the classic transfer, pathPay and accountMerge builders keep refusing a W destination - soroban: scValByType renders all six ScAddress variants, and the exact W for mux IDs 0 and 2^64 - 1; getTokenInvocationArgs returns a W as `to` for a transfer and for a mint; scValByType throws on a map whose keys collide, as scValToNative does since stellar-sdk 17.3 - km: the Albedo and Freighter handlers return the signed transaction with its W recipient intact Docs: the soroban README and ArgsForTokenInvocation say `to` can be a W address, and scValByType documents the collision TypeError. Co-Authored-By: Claude Opus 5.5 --- .../typescript-wallet-sdk-km/package.json | 2 +- .../test/albedo.test.ts | 21 +++++ .../test/fixtures/muxedContract.ts | 54 ++++++++++++ .../test/freighter.test.ts | 36 ++++++++ .../typescript-wallet-sdk-soroban/README.md | 5 ++ .../package.json | 2 +- .../src/Helpers/scValByType.ts | 8 +- .../src/Types/index.ts | 2 + .../test/helpers.test.ts | 87 ++++++++++++++++++- @stellar/typescript-wallet-sdk/package.json | 2 +- .../test/fixtures/muxedContract.ts | 47 ++++++++++ .../test/stellar.test.ts | 16 ++++ .../test/transaction.test.ts | 43 +++++++++ yarn.lock | 41 +++++---- 14 files changed, 343 insertions(+), 23 deletions(-) create mode 100644 @stellar/typescript-wallet-sdk-km/test/fixtures/muxedContract.ts create mode 100644 @stellar/typescript-wallet-sdk/test/fixtures/muxedContract.ts diff --git a/@stellar/typescript-wallet-sdk-km/package.json b/@stellar/typescript-wallet-sdk-km/package.json index 886cda8b..33fecc4a 100644 --- a/@stellar/typescript-wallet-sdk-km/package.json +++ b/@stellar/typescript-wallet-sdk-km/package.json @@ -40,7 +40,7 @@ "@stablelib/base64": "^2.0.0", "@stablelib/utf8": "^2.0.0", "@stellar/freighter-api": "6.0.1", - "@stellar/stellar-sdk": "17.0.1", + "@stellar/stellar-sdk": "17.4.0", "@trezor/connect-plugin-stellar": "^9.0.2", "bignumber.js": "^9.1.2", "scrypt-async": "^2.0.1", diff --git a/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts b/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts index c3b62fd3..b14520a6 100644 --- a/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts +++ b/@stellar/typescript-wallet-sdk-km/test/albedo.test.ts @@ -12,6 +12,11 @@ import { import albedo from "@albedo-link/intent"; import { albedoSigningWith } from "./fixtures/albedo"; +import { + MUXED_CONTRACT_MAX_ID, + buildTransferToMuxedContract, + transferRecipient, +} from "./fixtures/muxedContract"; import { albedoHandler } from "../src/Handlers/albedo"; import { Key, KeyType } from "../src/Types"; @@ -154,6 +159,22 @@ describe("albedoHandler", () => { }, ); + it("returns the signed transaction with its muxed contract (W) recipient intact", async () => { + const tx = buildTransferToMuxedContract( + signer.publicKey(), + Networks.TESTNET, + ); + const key = makeAlbedoKey(signer, { network: Networks.TESTNET }); + + const result = await albedoHandler.signTransaction({ + transaction: tx, + key, + }); + + expect(transferRecipient(result)).toBe(MUXED_CONTRACT_MAX_ID); + expect(WebAuth.verifyTxSignedBy(result, signer.publicKey())).toBe(true); + }); + it("refuses a key that holds a private key without opening Albedo", async () => { const tx = buildTestTransaction(signer, Networks.TESTNET); const key = makeAlbedoKey(signer, { privateKey: signer.secret() }); diff --git a/@stellar/typescript-wallet-sdk-km/test/fixtures/muxedContract.ts b/@stellar/typescript-wallet-sdk-km/test/fixtures/muxedContract.ts new file mode 100644 index 00000000..ca28e5cb --- /dev/null +++ b/@stellar/typescript-wallet-sdk-km/test/fixtures/muxedContract.ts @@ -0,0 +1,54 @@ +import { + Account, + Address, + Asset, + BASE_FEE, + Operation, + Transaction, + TransactionBuilder, + nativeToScVal, +} from "@stellar/stellar-sdk"; + +// A Protocol 30 (CAP-84) muxed contract address: SEP-23's test vector +// contract with the largest mux ID, 2^64 - 1. +export const MUXED_CONTRACT_MAX_ID = + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJV7777777777777GMO"; + +// A native XLM Stellar Asset Contract transfer from `source` to +// MUXED_CONTRACT_MAX_ID. +export function buildTransferToMuxedContract( + source: string, + networkPassphrase: string, +): Transaction { + return new TransactionBuilder(new Account(source, "100"), { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: Asset.native().contractId(networkPassphrase), + function: "transfer", + args: [ + new Address(source).toScVal(), + new Address(MUXED_CONTRACT_MAX_ID).toScVal(), + nativeToScVal(BigInt(12345), { type: "i128" }), + ], + }), + ) + .setTimeout(30) + .build(); +} + +// Reads the `to` argument of a transaction built by +// buildTransferToMuxedContract, as a strkey. +export function transferRecipient(tx: Transaction): string { + const { func } = tx.operations[0] as Operation.InvokeHostFunction; + if (func.type !== "hostFunctionTypeInvokeContract") { + throw new Error(`expected a contract invocation, not ${func.type}`); + } + const to = func.invokeContract.args[1]; + if (to.type !== "scvAddress") { + throw new Error(`expected an address, not ${to.type}`); + } + return Address.fromScAddress(to.address).toString(); +} diff --git a/@stellar/typescript-wallet-sdk-km/test/freighter.test.ts b/@stellar/typescript-wallet-sdk-km/test/freighter.test.ts index 3302ee22..72d3b396 100644 --- a/@stellar/typescript-wallet-sdk-km/test/freighter.test.ts +++ b/@stellar/typescript-wallet-sdk-km/test/freighter.test.ts @@ -7,9 +7,15 @@ import { Transaction, Asset, BASE_FEE, + WebAuth, } from "@stellar/stellar-sdk"; import freighterApi from "@stellar/freighter-api"; +import { + MUXED_CONTRACT_MAX_ID, + buildTransferToMuxedContract, + transferRecipient, +} from "./fixtures/muxedContract"; import { freighterHandler } from "../src/Handlers/freighter"; import { Key, KeyType } from "../src/Types"; @@ -88,6 +94,36 @@ describe("freighterHandler", () => { }); }); + it("returns the signed transaction with its muxed contract (W) recipient intact", async () => { + const networkPassphrase = Networks.TESTNET; + const signer = Keypair.random(); + const tx = buildTransferToMuxedContract( + signer.publicKey(), + networkPassphrase, + ); + const key = makeFreighterKey({ publicKey: signer.publicKey() }); + + // Freighter answers with the envelope it signed + const signed = TransactionBuilder.fromXdr( + tx.toXdr(), + networkPassphrase, + ) as Transaction; + signed.sign(signer); + mockSignTransaction.mockResolvedValue({ + signedTxXdr: signed.toXdr(), + signerAddress: signer.publicKey(), + }); + + const result = await freighterHandler.signTransaction({ + transaction: tx, + key, + custom: { networkPassphrase }, + }); + + expect(transferRecipient(result)).toBe(MUXED_CONTRACT_MAX_ID); + expect(WebAuth.verifyTxSignedBy(result, signer.publicKey())).toBe(true); + }); + it("passes custom.address through to freighter", async () => { const networkPassphrase = Networks.TESTNET; const tx = buildTestTransaction(networkPassphrase); diff --git a/@stellar/typescript-wallet-sdk-soroban/README.md b/@stellar/typescript-wallet-sdk-soroban/README.md index b2e6b19d..1322f90e 100644 --- a/@stellar/typescript-wallet-sdk-soroban/README.md +++ b/@stellar/typescript-wallet-sdk-soroban/README.md @@ -52,6 +52,9 @@ const args = getTokenInvocationArgs(op); to: "GDUBMXMABE7UOZSGYJ5ONE7UYAEHKK3JOX7HZQGNZ7NYTZPPP4AJ2GQJ", amount: 5 } + + `to` is the recipient as the invocation names it, so it can also be a muxed + account (M...) or, since Protocol 30, a muxed contract (W...) address. */ const formattedAmount = formatTokenAmount("10000123", 3); @@ -77,4 +80,6 @@ const accountAddress = xdr.ScVal.scvAddress( const addressString = scValByType(accountAddress); // converts smart contract complex value into a simple string // addressString = "GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB" +// Every address type renders as its strkey, including Protocol 30 muxed +// contract (W...) addresses. ``` diff --git a/@stellar/typescript-wallet-sdk-soroban/package.json b/@stellar/typescript-wallet-sdk-soroban/package.json index 3020d54b..45180897 100644 --- a/@stellar/typescript-wallet-sdk-soroban/package.json +++ b/@stellar/typescript-wallet-sdk-soroban/package.json @@ -30,7 +30,7 @@ "webpack-cli": "^5.1.4" }, "dependencies": { - "@stellar/stellar-sdk": "17.0.1" + "@stellar/stellar-sdk": "17.4.0" }, "scripts": { "prepare": "husky install", diff --git a/@stellar/typescript-wallet-sdk-soroban/src/Helpers/scValByType.ts b/@stellar/typescript-wallet-sdk-soroban/src/Helpers/scValByType.ts index 09aad81d..322b6880 100644 --- a/@stellar/typescript-wallet-sdk-soroban/src/Helpers/scValByType.ts +++ b/@stellar/typescript-wallet-sdk-soroban/src/Helpers/scValByType.ts @@ -11,6 +11,10 @@ import { Address, scValToNative, xdr } from "@stellar/stellar-sdk"; * @returns the smart contract value converted to a common/simpler * value like string, array, hex string, JSON string, etc. * + * @throws {TypeError} when a map, at any depth, has two keys that convert to + * the same JSON key, such as u32 1 and symbol "1", instead of rendering it + * without one of them. + * * @example * const accountAddress = xdr.ScVal.scvAddress( * new Address("GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB").toScAddress(), @@ -21,8 +25,8 @@ import { Address, scValToNative, xdr } from "@stellar/stellar-sdk"; export const scValByType = (scVal: xdr.ScVal) => { switch (scVal.type) { case "scvAddress": - // Address.fromScAddress covers all five ScAddress variants, so no - // per-variant branching is needed here. + // Address.fromScAddress covers every ScAddress variant, muxed contract + // (W...) addresses included, so no per-variant branching is needed here. return Address.fromScAddress(scVal.address).toString(); case "scvBool": diff --git a/@stellar/typescript-wallet-sdk-soroban/src/Types/index.ts b/@stellar/typescript-wallet-sdk-soroban/src/Types/index.ts index 780cf513..3374ad4d 100644 --- a/@stellar/typescript-wallet-sdk-soroban/src/Types/index.ts +++ b/@stellar/typescript-wallet-sdk-soroban/src/Types/index.ts @@ -8,6 +8,8 @@ export enum SorobanTokenInterface { export type ArgsForTokenInvocation = { from: string; + // The recipient as the invocation names it, so it can also be a muxed + // account (M...) or, since Protocol 30, a muxed contract (W...) address. to: string; amount: bigint | number; }; diff --git a/@stellar/typescript-wallet-sdk-soroban/test/helpers.test.ts b/@stellar/typescript-wallet-sdk-soroban/test/helpers.test.ts index 4cabec71..6bd03586 100644 --- a/@stellar/typescript-wallet-sdk-soroban/test/helpers.test.ts +++ b/@stellar/typescript-wallet-sdk-soroban/test/helpers.test.ts @@ -22,6 +22,16 @@ import { } from "../src"; import { makeInvocation, randomContracts, randomKey } from "./utils"; +// Protocol 30 (CAP-84) muxed contract addresses. SEP-23's test vector is this +// contract with mux ID 0. The other is the same contract with the largest mux +// ID, 2^64 - 1, which a JS number can't hold exactly. +const SEP23_CONTRACT = + "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; +const SEP23_MUXED_CONTRACT = + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC"; +const MUXED_CONTRACT_MAX_ID = + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJV7777777777777GMO"; + const transactions = { classic: "AAAAAgAAAACCMXQVfkjpO2gAJQzKsUsPfdBCyfrvy7sr8+35cOxOSwAAAGQABqQMAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAACCMXQVfkjpO2gAJQzKsUsPfdBCyfrvy7sr8+35cOxOSwAAAAAAmJaAAAAAAAAAAAFw7E5LAAAAQBu4V+/lttEONNM6KFwdSf5TEEogyEBy0jTOHJKuUzKScpLHyvDJGY+xH9Ri4cIuA7AaB8aL+VdlucCfsNYpKAY=", @@ -29,6 +39,9 @@ const transactions = { "AAAAAgAAAACM6IR9GHiRoVVAO78JJNksy2fKDQNs2jBn8bacsRLcrDucaFsAAAWIAAAAMQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABHkEVdJ+UfDnWpBr/qF582IEoDQ0iW0WPzO9CEUdvvh8AAAAIdHJhbnNmZXIAAAADAAAAEgAAAAAAAAAAjOiEfRh4kaFVQDu/CSTZLMtnyg0DbNowZ/G2nLES3KwAAAASAAAAAAAAAADoFl2ACT9HZkbCeuaT9MAIdStpdf58wM3P24nl738AnQAAAAoAAAAAAAAAAAAAAAAAAAAFAAAAAQAAAAAAAAAAAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAACHRyYW5zZmVyAAAAAwAAABIAAAAAAAAAAIzohH0YeJGhVUA7vwkk2SzLZ8oNA2zaMGfxtpyxEtysAAAAEgAAAAAAAAAA6BZdgAk/R2ZGwnrmk/TACHUraXX+fMDNz9uJ5e9/AJ0AAAAKAAAAAAAAAAAAAAAAAAAABQAAAAAAAAABAAAAAAAAAAIAAAAGAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAAFAAAAAEAAAAHa35L+/RxV6EuJOVk78H5rCN+eubXBWtsKrRxeLnnpRAAAAACAAAABgAAAAEeQRV0n5R8OdakGv+oXnzYgSgNDSJbRY/M70IRR2++HwAAABAAAAABAAAAAgAAAA8AAAAHQmFsYW5jZQAAAAASAAAAAAAAAACM6IR9GHiRoVVAO78JJNksy2fKDQNs2jBn8bacsRLcrAAAAAEAAAAGAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAAAAAAAAOgWXYAJP0dmRsJ65pP0wAh1K2l1/nzAzc/bieXvfwCdAAAAAQBkcwsAACBwAAABKAAAAAAAAB1kAAAAAA==", sorobanMint: "AAAAAgAAAACM6IR9GHiRoVVAO78JJNksy2fKDQNs2jBn8bacsRLcrDucQIQAAAWIAAAAMQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABHkEVdJ+UfDnWpBr/qF582IEoDQ0iW0WPzO9CEUdvvh8AAAAEbWludAAAAAIAAAASAAAAAAAAAADoFl2ACT9HZkbCeuaT9MAIdStpdf58wM3P24nl738AnQAAAAoAAAAAAAAAAAAAAAAAAAAFAAAAAQAAAAAAAAAAAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAABG1pbnQAAAACAAAAEgAAAAAAAAAA6BZdgAk/R2ZGwnrmk/TACHUraXX+fMDNz9uJ5e9/AJ0AAAAKAAAAAAAAAAAAAAAAAAAABQAAAAAAAAABAAAAAAAAAAIAAAAGAAAAAR5BFXSflHw51qQa/6hefNiBKA0NIltFj8zvQhFHb74fAAAAFAAAAAEAAAAHa35L+/RxV6EuJOVk78H5rCN+eubXBWtsKrRxeLnnpRAAAAABAAAABgAAAAEeQRV0n5R8OdakGv+oXnzYgSgNDSJbRY/M70IRR2++HwAAABAAAAABAAAAAgAAAA8AAAAHQmFsYW5jZQAAAAASAAAAAAAAAADoFl2ACT9HZkbCeuaT9MAIdStpdf58wM3P24nl738AnQAAAAEAYpBIAAAfrAAAAJQAAAAAAAAdYwAAAAA=", + // Testnet native XLM SAC transfer(GDVEU3..., MUXED_CONTRACT_MAX_ID, 12345) + sorobanTransferToMuxedContract: + "AAAAAgAAAADqSmxj4pxSCr71UHsTLsX5lUd2rr6+e5JCHuppFEbSLAAAAGQAAAAAAAAAZAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAIdHJhbnNmZXIAAAADAAAAEgAAAAAAAAAA6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iwAAAASAAAABf//////////Pww0v5OtDZlx0EzMkPcFURyDiq2XNKSi+w16A/x/6JoAAAAKAAAAAAAAAAAAAAAAAAAwOQAAAAAAAAAAAAAAAA==", }; describe("getTokenInvocationArgs for different function names", () => { @@ -85,6 +98,22 @@ describe("getTokenInvocationArgs for different function names", () => { expect(args).toBe(null); }); + + it("get token invocation args for a Soroban transfer to a muxed contract (W) address", () => { + const transaction = TransactionBuilder.fromXDR( + transactions.sorobanTransferToMuxedContract, + Networks.TESTNET, + ) as Transaction; + const op = transaction.operations[0] as Operation.InvokeHostFunction; + + expect(getTokenInvocationArgs(op)).toEqual({ + fnName: SorobanTokenInterface.transfer, + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + from: "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57", + to: MUXED_CONTRACT_MAX_ID, + amount: BigInt(12345), + }); + }); }); describe("Token formatting and parsing functions", () => { @@ -300,6 +329,22 @@ describe("scValByType should render expected common types", () => { ); }); + it("should throw, not drop an entry, when two map keys render as the same JSON key", () => { + // u32 1 and symbol "1" both become the JSON key "1" + const xdrMap = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvU32(1), + val: xdr.ScVal.scvU32(10), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("1"), + val: xdr.ScVal.scvU32(20), + }), + ]); + + expect(() => scValByType(xdrMap)).toThrow(TypeError); + }); + it("should possibly render strings and symbols as strings", () => { const scvString = xdr.ScVal.scvString("any string"); const parsedString = scValByType(scvString); @@ -995,7 +1040,7 @@ describe("scValByType Protocol 28 and address coverage", () => { expect(renderedA).not.toEqual(renderedB); }); - it("renders all five ScAddress variants", () => { + it("renders all six ScAddress variants", () => { const account = Keypair.random().publicKey(); const contract = StrKey.encodeContract(new Uint8Array(32).fill(3)); @@ -1026,6 +1071,16 @@ describe("scValByType Protocol 28 and address coverage", () => { ), "L", ], + [ + xdr.ScAddress.scAddressTypeMuxedContract( + new xdr.MuxedContract({ + id: BigInt(1), + contractId: new xdr.ContractId(StrKey.decodeContract(contract)), + }), + ), + // Muxed contracts encode to a W-address. + "W", + ], ]; for (const [scAddress, expected] of cases) { @@ -1035,6 +1090,23 @@ describe("scValByType Protocol 28 and address coverage", () => { } }); + it.each([ + ["0", SEP23_MUXED_CONTRACT], + ["18446744073709551615", MUXED_CONTRACT_MAX_ID], + ])( + "renders a muxed contract address with mux ID %s as its exact W-address", + (id, expected) => { + const scAddress = xdr.ScAddress.scAddressTypeMuxedContract( + new xdr.MuxedContract({ + id: BigInt(id), + contractId: new xdr.ContractId(StrKey.decodeContract(SEP23_CONTRACT)), + }), + ); + + expect(scValByType(xdr.ScVal.scvAddress(scAddress))).toBe(expected); + }, + ); + it("returns null for an unhandled ScVal type", () => { expect(scValByType(xdr.ScVal.scvVoid())).toBeNull(); }); @@ -1104,4 +1176,17 @@ describe("getTokenInvocationArgs address handling", () => { expect(args.to).toEqual(account); expect(args.amount).toEqual(BigInt(5)); }); + + it("resolves a muxed contract (W) recipient in a mint", () => { + const args = getArgsForTokenInvocation(SorobanTokenInterface.mint, [ + xdr.ScVal.scvAddress(new Address(SEP23_MUXED_CONTRACT).toScAddress()), + xdr.ScVal.scvI128(new xdr.Int128Parts({ hi: BigInt(0), lo: BigInt(5) })), + ]); + + expect(args).toEqual({ + from: "", + to: SEP23_MUXED_CONTRACT, + amount: BigInt(5), + }); + }); }); diff --git a/@stellar/typescript-wallet-sdk/package.json b/@stellar/typescript-wallet-sdk/package.json index 433ee578..52f51aa0 100644 --- a/@stellar/typescript-wallet-sdk/package.json +++ b/@stellar/typescript-wallet-sdk/package.json @@ -49,7 +49,7 @@ "dependencies": { "@stablelib/base64": "^2.0.0", "@stablelib/utf8": "^2.0.0", - "@stellar/stellar-sdk": "17.0.1", + "@stellar/stellar-sdk": "17.4.0", "axios": "^1.4.0", "https-browserify": "^1.0.0", "jws": "^4.0.0", diff --git a/@stellar/typescript-wallet-sdk/test/fixtures/muxedContract.ts b/@stellar/typescript-wallet-sdk/test/fixtures/muxedContract.ts new file mode 100644 index 00000000..baa37dbf --- /dev/null +++ b/@stellar/typescript-wallet-sdk/test/fixtures/muxedContract.ts @@ -0,0 +1,47 @@ +import { Address, Operation, Transaction } from "@stellar/stellar-sdk"; + +// Protocol 30 (CAP-84) muxed contract addresses: a contract ID plus a uint64 +// mux ID, encoded as a "W..." strkey. + +// SEP-23's test vector: this contract with mux ID 0. +export const SEP23_CONTRACT = + "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; +export const SEP23_MUXED_CONTRACT = + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC"; + +// The same contract with the largest mux ID, 2^64 - 1, which a JS number +// can't hold exactly. +export const MUXED_CONTRACT_MAX_ID = + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJV7777777777777GMO"; + +// SEP23_MUXED_CONTRACT with its last character changed, so the checksum +// doesn't match. +export const MUXED_CONTRACT_BAD_CHECKSUM = + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWA"; + +export const TRANSFER_SOURCE = + "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"; + +// The native XLM Stellar Asset Contract on testnet. +export const TESTNET_NATIVE_SAC = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + +// An unsigned testnet transaction with one operation: TESTNET_NATIVE_SAC's +// transfer(TRANSFER_SOURCE, MUXED_CONTRACT_MAX_ID, 12345). Its only Protocol 30 +// part is the `to` argument, an ScAddress of type SC_ADDRESS_TYPE_MUXED_CONTRACT. +export const TRANSFER_TO_MUXED_CONTRACT_XDR = + "AAAAAgAAAADqSmxj4pxSCr71UHsTLsX5lUd2rr6+e5JCHuppFEbSLAAAAGQAAAAAAAAAZAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAIdHJhbnNmZXIAAAADAAAAEgAAAAAAAAAA6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iwAAAASAAAABf//////////Pww0v5OtDZlx0EzMkPcFURyDiq2XNKSi+w16A/x/6JoAAAAKAAAAAAAAAAAAAAAAAAAwOQAAAAAAAAAAAAAAAA=="; + +// Reads the `to` argument of the transfer in a transaction like +// TRANSFER_TO_MUXED_CONTRACT_XDR, as a strkey. +export const transferRecipient = (tx: Transaction): string => { + const { func } = tx.operations[0] as Operation.InvokeHostFunction; + if (func.type !== "hostFunctionTypeInvokeContract") { + throw new Error(`expected a contract invocation, not ${func.type}`); + } + const to = func.invokeContract.args[1]; + if (to.type !== "scvAddress") { + throw new Error(`expected an address, not ${to.type}`); + } + return Address.fromScAddress(to.address).toString(); +}; diff --git a/@stellar/typescript-wallet-sdk/test/stellar.test.ts b/@stellar/typescript-wallet-sdk/test/stellar.test.ts index 8b98411d..f7df9cca 100644 --- a/@stellar/typescript-wallet-sdk/test/stellar.test.ts +++ b/@stellar/typescript-wallet-sdk/test/stellar.test.ts @@ -21,6 +21,11 @@ import { Assets, } from "../src/walletSdk/Asset"; import { TransactionStatus, WithdrawTransaction } from "../src/walletSdk/Types"; +import { + MUXED_CONTRACT_MAX_ID, + TRANSFER_TO_MUXED_CONTRACT_XDR, + transferRecipient, +} from "./fixtures/muxedContract"; import { TransactionSubmitWithFeeIncreaseFailedError, WithdrawalTxMemoError, @@ -818,3 +823,14 @@ describe("transferWithdrawalTransaction memo validation", () => { ).toThrow(WithdrawalTxMemoError); }); }); + +describe("decodeTransaction with a muxed contract (W) address", () => { + it("decodes a transfer to a W address and re-encodes it unchanged", () => { + const tx = Wallet.TestNet() + .stellar() + .decodeTransaction(TRANSFER_TO_MUXED_CONTRACT_XDR) as Transaction; + + expect(transferRecipient(tx)).toBe(MUXED_CONTRACT_MAX_ID); + expect(tx.toXdr()).toBe(TRANSFER_TO_MUXED_CONTRACT_XDR); + }); +}); diff --git a/@stellar/typescript-wallet-sdk/test/transaction.test.ts b/@stellar/typescript-wallet-sdk/test/transaction.test.ts index f5fb2e19..65e69408 100644 --- a/@stellar/typescript-wallet-sdk/test/transaction.test.ts +++ b/@stellar/typescript-wallet-sdk/test/transaction.test.ts @@ -19,6 +19,7 @@ import { } from "../src"; import { IssuedAssetId, NativeAssetId } from "../src/walletSdk/Asset"; import { PathPayBoundRequiredError } from "../src/walletSdk/Exceptions"; +import { MUXED_CONTRACT_MAX_ID } from "./fixtures/muxedContract"; describe("Muxed Transactions", () => { let wallet: Wallet; @@ -424,3 +425,45 @@ describe("Path Payment bounds", () => { expect(op.destMin).toBe("0.9000000"); }); }); + +// A muxed contract (W) address can only receive a Stellar Asset Contract +// transfer, so no classic operation may take one as its destination. +describe("classic operations to a muxed contract (W) address", () => { + const newBuilder = () => + new TransactionBuilder( + new Config({ + stellarConfiguration: StellarConfiguration.TestNet(), + applicationConfiguration: new ApplicationConfiguration(), + }), + new Account(Keypair.random().publicKey(), "0"), + ); + + it.each([ + [ + "transfer", + (builder: TransactionBuilder) => + builder.transfer(MUXED_CONTRACT_MAX_ID, new NativeAssetId(), "1"), + ], + [ + "pathPay", + (builder: TransactionBuilder) => + builder.pathPay({ + destinationAddress: MUXED_CONTRACT_MAX_ID, + sendAsset: new NativeAssetId(), + destAsset: new IssuedAssetId( + "USDC", + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + ), + sendAmount: "5", + destMin: "4.9", + }), + ], + [ + "accountMerge", + (builder: TransactionBuilder) => + builder.accountMerge(MUXED_CONTRACT_MAX_ID), + ], + ])("%s refuses a W destination", (_, addOperation) => { + expect(() => addOperation(newBuilder())).toThrow("destination is invalid"); + }); +}); diff --git a/yarn.lock b/yarn.lock index c76de3fd..ef00fddf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2564,17 +2564,17 @@ resolved "https://registry.yarnpkg.com/@stellar/prettier-config/-/prettier-config-1.0.1.tgz#498a66dc13c66859e3787dabdf958233ddbe9253" integrity sha512-w9OPycQp1XGfmHC2VUHe5shpZjNFRlmsRBaK7IHvOvVpglzV2QNJsVFh8RdLREWA0mzF59AWvQbyUCCJLPfdWw== -"@stellar/stellar-sdk@17.0.1": - version "17.0.1" - resolved "https://registry.yarnpkg.com/@stellar/stellar-sdk/-/stellar-sdk-17.0.1.tgz#4ea091bce35a011ed2715f88ad9831a50a828b96" - integrity sha512-fsHHbzJ14N5Ttq5Qpz4AX0ytmPSLCzcy4XC3uIgSQz0cBkEgv0Zb4KE6tWEd3nDQUk/1qP8ZX9cRonIaUXO3Sw== +"@stellar/stellar-sdk@17.4.0": + version "17.4.0" + resolved "https://registry.yarnpkg.com/@stellar/stellar-sdk/-/stellar-sdk-17.4.0.tgz#13d8db3553b906a0ea4778a29c8b676f44b6acec" + integrity sha512-IX6gi62yWQ+6Oz56LsVEEeSViSOWJL3BkttftwVmVsu+62nLTWNVsCmTWEzwgNLT/q5HGP25OnlU+hhy2E4wwA== dependencies: "@exodus/bytes" "^1.15.1" "@noble/ed25519" "^3.1.0" "@noble/hashes" "^2.2.0" "@stellar/js-xdr" "^5.0.0" "@types/json-schema" "^7.0.15" - axios "1.18.0" + axios "1.20.0" bignumber.js "^11.1.4" commander "^14.0.3" eventsource "^4.1.0" @@ -3297,13 +3297,13 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" -axios@1.18.0: - version "1.18.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.18.0.tgz#8a7f8854af280fcaae063272df2ed9f3837d2398" - integrity sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw== +axios@1.20.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.20.0.tgz#515513445aa60e71d04b6521ca6210829ccb4786" + integrity sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg== dependencies: follow-redirects "^1.16.0" - form-data "^4.0.5" + form-data "^4.0.6" https-proxy-agent "^5.0.1" proxy-from-env "^2.1.0" @@ -4810,16 +4810,16 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -form-data@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" - integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== +form-data@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" es-set-tostringtag "^2.1.0" - hasown "^2.0.2" - mime-types "^2.1.12" + hasown "^2.0.4" + mime-types "^2.1.35" forwarded@0.2.0: version "0.2.0" @@ -5131,6 +5131,13 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -6590,7 +6597,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== From a74bfeef1faf4ba87a0a8d41b0d3eff060189532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 2 Nov 2026 16:32:54 -0800 Subject: [PATCH 02/13] feat(sep7): accept W pay destinations, and refuse a memo for C and W isValidSep7Uri and parseSep7Uri accept a muxed contract (W...) address as a `pay` destination, and a `tx` uri whose transaction contains one. A contract (C...) or W destination can only be paid with a Stellar Asset Contract transfer. That is a Soroban transaction, and the network rejects a Soroban transaction that has a memo, so a wallet could only drop the memo. A `pay` uri with a C or W destination and a memo is now invalid. Before, a C destination with a memo was accepted. G and M destinations keep accepting a memo. Co-Authored-By: Claude Opus 5.5 --- .../src/walletSdk/Uri/Sep7Pay.ts | 4 + .../src/walletSdk/Uri/sep7Parser.ts | 20 +++- .../typescript-wallet-sdk/test/sep7.test.ts | 97 ++++++++++++++++++- 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts index 43ea2a75..07232bd8 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts @@ -45,6 +45,10 @@ export class Sep7Pay extends Sep7Base { * Gets the destination of the payment request, which should be a valid * Stellar address. * + * A contract ("C...") or muxed contract ("W...") destination can only be + * paid with a Stellar Asset Contract transfer, not a classic payment, and a + * uri with one can't have a memo. + * * @returns {string | undefined} the 'destination' uri param if present. */ get destination(): string | undefined { diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/sep7Parser.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/sep7Parser.ts index 8e35a831..6ae93054 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/sep7Parser.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/sep7Parser.ts @@ -78,11 +78,15 @@ export const isValidSep7Uri = (uri: string): IsValidSep7UriResult => { } if (type === Sep7OperationType.pay && destination) { - // Checks if it's a valid "G", "M" or "C" Stellar address + const isContract = + StrKey.isValidContract(destination) || + StrKey.isValidMuxedContract(destination); + + // Checks if it's a valid "G", "M", "C" or "W" Stellar address const isValidStellarAddress = StrKey.isValidEd25519PublicKey(destination) || StrKey.isValidMed25519PublicKey(destination) || - StrKey.isValidContract(destination); + isContract; if (!isValidStellarAddress) { return { @@ -91,6 +95,18 @@ export const isValidSep7Uri = (uri: string): IsValidSep7UriResult => { "the provided 'destination' parameter is not a valid Stellar address", }; } + + // A "C" (contract) or "W" (muxed contract) address can only be paid with + // a Stellar Asset Contract transfer. That's a Soroban transaction, which + // can't have a memo, so a wallet could only drop it. A "W" address's mux + // ID already identifies the payee. + if (isContract && url.searchParams.get("memo")) { + return { + result: false, + reason: + "the 'memo' parameter is not supported for a contract ('C') or muxed contract ('W') 'destination'", + }; + } } if (msg?.length > URI_MSG_MAX_LENGTH) { diff --git a/@stellar/typescript-wallet-sdk/test/sep7.test.ts b/@stellar/typescript-wallet-sdk/test/sep7.test.ts index 27fa0ecf..a0414131 100644 --- a/@stellar/typescript-wallet-sdk/test/sep7.test.ts +++ b/@stellar/typescript-wallet-sdk/test/sep7.test.ts @@ -28,6 +28,14 @@ import { Sep7InvalidUriError, Sep7LongMsgError, } from "../src/walletSdk/Exceptions"; +import { + MUXED_CONTRACT_BAD_CHECKSUM, + MUXED_CONTRACT_MAX_ID, + SEP23_CONTRACT, + SEP23_MUXED_CONTRACT, + TRANSFER_TO_MUXED_CONTRACT_XDR, + transferRecipient, +} from "./fixtures/muxedContract"; const testKp1 = SigningKeypair.fromSecret( "SBKQDF56C5VY2YQTNQFGY7HM6R3V6QKDUEDXZQUCPQOP2EBZWG2QJ2JL", @@ -700,7 +708,14 @@ describe("sep7Parser", () => { // With Soroban Contract destination expect( isValidSep7Uri( - "web+stellar:pay?destination=CAPECFLUT6KHYOOWUQNP7KC6PTMICKANBURFWRMPZTXUEEKHN67B7UI2&amount=120.1234567&memo=skdjfasf&memo_type=MEMO_TEXT&msg=pay%20me%20with%20lumens", + "web+stellar:pay?destination=CAPECFLUT6KHYOOWUQNP7KC6PTMICKANBURFWRMPZTXUEEKHN67B7UI2&amount=120.1234567&msg=pay%20me%20with%20lumens", + ).result, + ).toBe(true); + + // With Muxed Contract destination + expect( + isValidSep7Uri( + `web+stellar:pay?destination=${SEP23_MUXED_CONTRACT}&amount=120.1234567&msg=pay%20me%20with%20lumens`, ).result, ).toBe(true); }); @@ -857,7 +872,14 @@ describe("sep7Parser", () => { // With Soroban Contract destination expect( parseSep7Uri( - "web+stellar:pay?destination=CAPECFLUT6KHYOOWUQNP7KC6PTMICKANBURFWRMPZTXUEEKHN67B7UI2&amount=120.123&asset_code=USD&asset_issuer=GCRCUE2C5TBNIPYHMEP7NK5RWTT2WBSZ75CMARH7GDOHDDCQH3XANFOB&memo=hasysda987fs&memo_type=MEMO_TEXT&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fhasysda987fs%3Fasset%3DUSD", + "web+stellar:pay?destination=CAPECFLUT6KHYOOWUQNP7KC6PTMICKANBURFWRMPZTXUEEKHN67B7UI2&amount=120.123&asset_code=USD&asset_issuer=GCRCUE2C5TBNIPYHMEP7NK5RWTT2WBSZ75CMARH7GDOHDDCQH3XANFOB&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fhasysda987fs%3Fasset%3DUSD", + ), + ).toBeInstanceOf(Sep7Pay); + + // With Muxed Contract destination + expect( + parseSep7Uri( + `web+stellar:pay?destination=${SEP23_MUXED_CONTRACT}&amount=120.123&asset_code=USD&asset_issuer=GCRCUE2C5TBNIPYHMEP7NK5RWTT2WBSZ75CMARH7GDOHDDCQH3XANFOB&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fhasysda987fs%3Fasset%3DUSD`, ), ).toBeInstanceOf(Sep7Pay); }); @@ -1067,3 +1089,74 @@ describe("SEP-7 byte encoding regressions", () => { expect(uri.getTransaction().toXdr()).toEqual(tx.toXdr()); }); }); + +describe("SEP-7 with muxed contract (W) addresses", () => { + it.each([ + ["mux ID 0", SEP23_MUXED_CONTRACT], + ["the largest mux ID", MUXED_CONTRACT_MAX_ID], + ])( + "parses a 'pay' uri to a W address with %s and keeps the address as given", + (_, destination) => { + const uri = parseSep7Uri( + `web+stellar:pay?destination=${destination}&amount=10&asset_code=USDC&asset_issuer=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5`, + ); + + expect(uri).toBeInstanceOf(Sep7Pay); + expect((uri as Sep7Pay).destination).toBe(destination); + }, + ); + + it("rejects a 'pay' uri to a W address whose checksum doesn't match", () => { + expect( + isValidSep7Uri( + `web+stellar:pay?destination=${MUXED_CONTRACT_BAD_CHECKSUM}&amount=10`, + ), + ).toEqual({ + result: false, + reason: + "the provided 'destination' parameter is not a valid Stellar address", + }); + }); + + it("parses a 'tx' uri whose transaction pays a W address", () => { + const uri = `web+stellar:tx?xdr=${encodeURIComponent( + TRANSFER_TO_MUXED_CONTRACT_XDR, + )}&network_passphrase=${encodeURIComponent(Networks.TESTNET)}`; + + expect(isValidSep7Uri(uri)).toEqual({ result: true }); + + const tx = (parseSep7Uri(uri) as Sep7Tx).getTransaction(); + expect(transferRecipient(tx)).toBe(MUXED_CONTRACT_MAX_ID); + expect(tx.toXdr()).toBe(TRANSFER_TO_MUXED_CONTRACT_XDR); + }); +}); + +// A C or W address can only be paid with a Stellar Asset Contract transfer, and +// that's a Soroban transaction, which can't carry a memo. +describe("SEP-7 'pay' to a contract (C or W) address with a memo", () => { + it.each([ + ["C", "a MEMO_TEXT", SEP23_CONTRACT, "&memo=skdjfasf&memo_type=MEMO_TEXT"], + ["C", "a MEMO_ID", SEP23_CONTRACT, "&memo=1234&memo_type=MEMO_ID"], + ["C", "an untyped", SEP23_CONTRACT, "&memo=skdjfasf"], + [ + "W", + "a MEMO_TEXT", + SEP23_MUXED_CONTRACT, + "&memo=skdjfasf&memo_type=MEMO_TEXT", + ], + ["W", "a MEMO_ID", SEP23_MUXED_CONTRACT, "&memo=1234&memo_type=MEMO_ID"], + ["W", "an untyped", SEP23_MUXED_CONTRACT, "&memo=skdjfasf"], + ])( + "rejects a 'pay' uri to a %s address that has %s memo", + (_, __, destination, memoParams) => { + const uri = `web+stellar:pay?destination=${destination}&amount=10${memoParams}`; + + expect(isValidSep7Uri(uri)).toEqual({ + result: false, + reason: + "the 'memo' parameter is not supported for a contract ('C') or muxed contract ('W') 'destination'", + }); + expect(() => parseSep7Uri(uri)).toThrow(Sep7InvalidUriError); + }, + ); +}); From 9bd8bcfdd03d15d43d195c3dbb965dae7218646e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 2 Nov 2026 16:33:08 -0800 Subject: [PATCH 03/13] test(e2e): check stellar-sdk's strict TextDecoder on React Native Since stellar-sdk 17.3, stellar.toml and federation responses are read with TextDecoder("utf-8", { fatal: true }), which some React Native TextDecoders, such as fast-text-encoding, can't build. The Hermes-like sandbox now checks that every bundle still loads with such a decoder, and that verifying a SEP-7 signature needs one that can decode strictly. Co-Authored-By: Claude Opus 5.5 --- .../test/e2e/reactNative.test.ts | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/@stellar/typescript-wallet-sdk/test/e2e/reactNative.test.ts b/@stellar/typescript-wallet-sdk/test/e2e/reactNative.test.ts index bf8f1986..12526c11 100644 --- a/@stellar/typescript-wallet-sdk/test/e2e/reactNative.test.ts +++ b/@stellar/typescript-wallet-sdk/test/e2e/reactNative.test.ts @@ -19,9 +19,22 @@ import * as vm from "vm"; */ const BANNED = ["Buffer", "Event", "EventTarget", "process"]; +// Behaves like fast-text-encoding 1.0.6, a TextDecoder that React Native apps +// commonly install on Hermes: its constructor throws for `{ fatal: true }`. +const NonFatalTextDecoder = function ( + label?: string, + options?: TextDecoderOptions, +) { + if (options?.fatal) { + throw new RangeError("the 'fatal' option is unsupported"); + } + return new TextDecoder(label, options); +} as unknown as typeof TextDecoder; + const makeHermesLikeContext = ({ includeBase64 = true, includeTextCodec = true, + textDecoder = TextDecoder, } = {}) => { const sandbox: Record = { console, @@ -56,7 +69,7 @@ const makeHermesLikeContext = ({ // "TypeError: globalThis.TextDecoder is not a constructor" at load — // before any of our own code runs. See the test below that enforces this. sandbox.TextEncoder = TextEncoder; - sandbox.TextDecoder = TextDecoder; + sandbox.TextDecoder = textDecoder; } if (includeBase64) { @@ -126,6 +139,25 @@ describe("React Native (Hermes-like) bundle compatibility", () => { }); } + for (const [name, relativePath] of bundles) { + it(`loads the ${name} bundle with a TextDecoder that can't decode strictly`, () => { + // stellar-sdk 17.3+ builds a strict (`{ fatal: true }`) TextDecoder only + // when it decodes something: a stellar.toml or federation response, or + // an XdrString read with toStringStrict(). Loading a bundle must not + // need one, or an app whose TextDecoder can't decode strictly would + // crash on startup instead of only on those calls. + const bundlePath = path.resolve(__dirname, relativePath); + const context = makeHermesLikeContext({ + textDecoder: NonFatalTextDecoder, + }); + const code = fs.readFileSync(bundlePath, "utf8"); + + expect(() => + vm.runInContext(code, context, { filename: `${name}.js` }), + ).not.toThrow(); + }); + } + it("exercises the core SDK public API in the sandbox", () => { const context = makeHermesLikeContext(); const code = fs.readFileSync( @@ -154,6 +186,46 @@ describe("React Native (Hermes-like) bundle compatibility", () => { expect(result.signature).toHaveLength(88); }); + it("verifies a SEP-7 signature only with a TextDecoder that decodes strictly", async () => { + // Verifying reads the origin domain's stellar.toml, and stellar-sdk 17.3+ + // decodes stellar.toml and federation responses with a strict + // (`{ fatal: true }`) TextDecoder. With a TextDecoder that can't build + // one, verifySignature() fails closed and returns false for a valid + // signature, and Anchor.sep1() rejects. + const verifyWith = (textDecoder: typeof TextDecoder) => { + const context = makeHermesLikeContext({ textDecoder }); + // React Native provides these, and the SDK's stellar.toml fetch uses + // them. The fetch stub serves the toml that the code below writes. + Object.assign(context, { + Headers, + AbortController, + AbortSignal, + fetch: () => Promise.resolve(new Response(context.stellarToml)), + }); + const code = fs.readFileSync( + path.resolve(__dirname, "../../lib/bundle_browser.js"), + "utf8", + ); + vm.runInContext(code, context, { filename: "bundle_browser.js" }); + + return vm.runInContext( + `(() => { + const { Wallet, Sep7Pay } = module.exports; + const kp = Wallet.TestNet().stellar().account().createKeypair(); + globalThis.stellarToml = 'URI_REQUEST_SIGNING_KEY="' + kp.publicKey + '"'; + const uri = Sep7Pay.forDestination(kp.publicKey); + uri.originDomain = "example.com"; + uri.addSignature(kp.keypair); + return uri.verifySignature(); + })()`, + context, + ) as Promise; + }; + + expect(await verifyWith(TextDecoder)).toBe(true); + expect(await verifyWith(NonFatalTextDecoder)).toBe(false); + }); + it("requires btoa/atob for base64 paths, while base32/StrKey keypair creation stays pure JS", () => { const context = makeHermesLikeContext({ includeBase64: false }); const code = fs.readFileSync( From c198fbc29ed608c506e8241294d6242f6d7a25b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 2 Nov 2026 16:39:25 -0800 Subject: [PATCH 04/13] Release 6.0.0 Bump all three packages to 6.0.0 and add their changelog sections. A major release because of three breaking changes: on React Native, reading a stellar.toml needs a TextDecoder that supports `fatal` (from stellar-sdk 17.3); a SEP-7 `pay` uri with a C destination and a memo is now rejected; and scValByType throws on a map whose keys collide. The key manager has no breaking changes and moves to 6.0.0 to stay in lockstep. Co-Authored-By: Claude Opus 5.5 --- @stellar/typescript-wallet-sdk-km/CHANGELOG.MD | 12 ++++++++++++ @stellar/typescript-wallet-sdk-km/package.json | 2 +- @stellar/typescript-wallet-sdk-soroban/CHANGELOG.MD | 11 +++++++++++ @stellar/typescript-wallet-sdk-soroban/package.json | 2 +- @stellar/typescript-wallet-sdk/CHANGELOG.MD | 13 +++++++++++++ @stellar/typescript-wallet-sdk/package.json | 2 +- 6 files changed, 39 insertions(+), 3 deletions(-) diff --git a/@stellar/typescript-wallet-sdk-km/CHANGELOG.MD b/@stellar/typescript-wallet-sdk-km/CHANGELOG.MD index 3a40696b..e1d17a4f 100644 --- a/@stellar/typescript-wallet-sdk-km/CHANGELOG.MD +++ b/@stellar/typescript-wallet-sdk-km/CHANGELOG.MD @@ -1,3 +1,15 @@ +# Release notes - Typescript Wallet SDK Key Manager - 6.0.0 + +This package has no breaking changes. It moves to 6.0.0 to stay in lockstep with the core SDK. + +### Added +* Protocol 30 support: upgraded `@stellar/stellar-sdk` from `17.0.1` to `17.4.0`, which adds CAP-84 muxed contract (`W…`) addresses. The Albedo and Freighter handlers read back a signed transaction that contains one. Before, decoding the signed envelope threw + +### Changed +* `KeyManager.fetchAuthToken` rejects a challenge whose `client_domain` operation has no source account or uses the server account as its source, as stellar-sdk 17.3's `WebAuth.readChallengeTx` does + +**Consumer note:** the key-store surface (`KeyManager`'s `registerEncrypter`, `storeKey`, `loadKey`, `loadAllKeyIds`, `removeKey` and `changePassword`, the keystores and the encrypters) doesn't use stellar-sdk and is unchanged, so upgrading from 5.x needs no code changes for it. Consumers that also depend on `@stellar/stellar-sdk` directly should align their pin to `17.4.0`, since this package bundles that exact version. + # Release notes - Typescript Wallet SDK Key Manager - 5.1.0 ### Changed diff --git a/@stellar/typescript-wallet-sdk-km/package.json b/@stellar/typescript-wallet-sdk-km/package.json index 33fecc4a..53e4e4ba 100644 --- a/@stellar/typescript-wallet-sdk-km/package.json +++ b/@stellar/typescript-wallet-sdk-km/package.json @@ -1,6 +1,6 @@ { "name": "@stellar/typescript-wallet-sdk-km", - "version": "5.1.0", + "version": "6.0.0", "engines": { "node": ">=22.12.0" }, diff --git a/@stellar/typescript-wallet-sdk-soroban/CHANGELOG.MD b/@stellar/typescript-wallet-sdk-soroban/CHANGELOG.MD index 44f7a004..7f3c3e3b 100644 --- a/@stellar/typescript-wallet-sdk-soroban/CHANGELOG.MD +++ b/@stellar/typescript-wallet-sdk-soroban/CHANGELOG.MD @@ -1,3 +1,14 @@ +# Release notes - Typescript Wallet SDK Soroban - 6.0.0 + +### BREAKING CHANGES +* `scValByType` throws a `TypeError` for a map, at any depth, with two keys that convert to the same JSON key, such as `u32 1` and `symbol "1"`. It used to render the map without one of those entries. The change comes from stellar-sdk 17.3's `scValToNative` + +### Added +* Protocol 30 support: upgraded `@stellar/stellar-sdk` from `17.0.1` to `17.4.0`, which adds CAP-84 muxed contract (`W…`) addresses. `scValByType` renders one as its `W…` address, and `getTokenInvocationArgs` returns it as `to` for a Stellar Asset Contract `transfer` or `mint` to it. Before, a transaction that contained one failed to decode + +### Fixed +* `getInvocationDetails` shows a Stellar Asset Contract's asset with its code as encoded, so an asset whose code is `xlm` shows as `xlm:G…`. It used to show as `XLM:G…`, the same as an asset whose code is `XLM`. The fix comes from stellar-sdk 17.3 + # Release notes - Typescript Wallet SDK Soroban - 5.1.0 * Version bump diff --git a/@stellar/typescript-wallet-sdk-soroban/package.json b/@stellar/typescript-wallet-sdk-soroban/package.json index 45180897..65dd4467 100644 --- a/@stellar/typescript-wallet-sdk-soroban/package.json +++ b/@stellar/typescript-wallet-sdk-soroban/package.json @@ -1,6 +1,6 @@ { "name": "@stellar/typescript-wallet-sdk-soroban", - "version": "5.1.0", + "version": "6.0.0", "engines": { "node": ">=22.12.0" }, diff --git a/@stellar/typescript-wallet-sdk/CHANGELOG.MD b/@stellar/typescript-wallet-sdk/CHANGELOG.MD index 38d28109..5c733ed7 100644 --- a/@stellar/typescript-wallet-sdk/CHANGELOG.MD +++ b/@stellar/typescript-wallet-sdk/CHANGELOG.MD @@ -1,3 +1,16 @@ +# Release notes - Typescript Wallet SDK - 6.0.0 + +### BREAKING CHANGES +* **React Native: reading a `stellar.toml` needs a `TextDecoder` that supports `{ fatal: true }`.** stellar-sdk 17.3 and later decodes `stellar.toml` with `TextDecoder("utf-8", { fatal: true })`. Where `TextDecoder` can't be built with `fatal`, as with `fast-text-encoding` on Hermes, `Anchor.sep1()` rejects, and with it every call that needs the anchor's `stellar.toml`, and `verifySignature()` on a SEP-7 URI returns `false` for a valid signature. Both worked with such a `TextDecoder` in 5.1.0. Loading the bundle doesn't need one. Verified in a Hermes-like sandbox test +* `isValidSep7Uri` and `parseSep7Uri` reject a `pay` URI that has a `memo` when its destination is a contract (`C…`) or muxed contract (`W…`) address. Paying either takes a Soroban transaction, and the network rejects a Soroban transaction that has a memo, so a wallet could only drop the memo. A `C…` destination with a memo used to be accepted + +### Added +* Protocol 30 support: upgraded `@stellar/stellar-sdk` from `17.0.1` to `17.4.0`, which adds CAP-84 muxed contract (`W…`) addresses. `Stellar.decodeTransaction` and `Sep7Tx.getTransaction` decode a transaction that contains one, and `isValidSep7Uri` accepts a `tx` URI whose transaction contains one. Before, decoding such a transaction threw, so `isValidSep7Uri` returned `false` +* `isValidSep7Uri` and `parseSep7Uri` accept a muxed contract (`W…`) address as a `pay` destination. As with a contract (`C…`) address, a wallet can only pay it with a Stellar Asset Contract `transfer`. `TransactionBuilder`'s `transfer`, `pathPay` and `accountMerge` reject it, as before + +### Changed +* Stricter checks from stellar-sdk 17.1 to 17.4 that reach this SDK: `AccountService.getInfo` and `getHistory` reject with a `TypeError` for an empty account address, which used to query a different Horizon endpoint; `Sep10.authenticate` and `Server.signChallengeTransaction` reject a challenge whose `client_domain` operation has no source account or uses the server account as its source; `Anchor.sep1()` rejects a `homeDomain` with credentials, a path, a query or a fragment before making a request; and a `stellar.toml` that isn't valid UTF-8 fails to load. See the stellar-sdk changelog for the full list + # Release notes - Typescript Wallet SDK - 5.1.0 ### Added diff --git a/@stellar/typescript-wallet-sdk/package.json b/@stellar/typescript-wallet-sdk/package.json index 52f51aa0..774d7864 100644 --- a/@stellar/typescript-wallet-sdk/package.json +++ b/@stellar/typescript-wallet-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@stellar/typescript-wallet-sdk", - "version": "5.1.0", + "version": "6.0.0", "engines": { "node": ">=22.12.0" }, From 7b0ee790bb3e811f6521a38a049d4a3bedc389e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 3 Nov 2026 10:27:51 -0800 Subject: [PATCH 05/13] test(sep7): submit a transfer to a W address from a tx uri on testnet Testnet runs Protocol 30 since ledger 5540313, so a W address can now go on chain. The test builds a native SAC transfer to a W with the SDK's builder, prepares it with RPC and passes it through a SEP-7 tx uri. The wallet side signs it and submits it with submitTransaction, then decodes the envelope Horizon recorded and checks the W. Co-Authored-By: Claude Opus 5.5 --- .../typescript-wallet-sdk/test/sep7.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/@stellar/typescript-wallet-sdk/test/sep7.test.ts b/@stellar/typescript-wallet-sdk/test/sep7.test.ts index a0414131..8dac3e63 100644 --- a/@stellar/typescript-wallet-sdk/test/sep7.test.ts +++ b/@stellar/typescript-wallet-sdk/test/sep7.test.ts @@ -1,12 +1,17 @@ import { Account, + Address, Asset, BASE_FEE, Keypair, Networks, Operation, StellarToml, + Transaction, TransactionBuilder, + nativeToScVal, + rpc, + xdr, } from "@stellar/stellar-sdk"; import { NativeAssetId, @@ -33,6 +38,7 @@ import { MUXED_CONTRACT_MAX_ID, SEP23_CONTRACT, SEP23_MUXED_CONTRACT, + TESTNET_NATIVE_SAC, TRANSFER_TO_MUXED_CONTRACT_XDR, transferRecipient, } from "./fixtures/muxedContract"; @@ -1131,6 +1137,52 @@ describe("SEP-7 with muxed contract (W) addresses", () => { }); }); +// Runs on testnet, which needs Protocol 30 for a W address to go on chain. A +// dapp builds the transfer, has RPC add its Soroban data and passes it to a +// wallet as a 'tx' uri, which the wallet signs and submits. +describe("SEP-7 'tx' uri for a transfer to a W address, on testnet", () => { + it("is signed and submitted by a wallet, and the network keeps the W", async () => { + const stellar = Wallet.TestNet().stellar(); + const sourceKp = stellar.account().createKeypair(); + await stellar.fundTestnetAccount(sourceKp.publicKey); + + const txBuilder = await stellar.transaction({ sourceAddress: sourceKp }); + const transfer = txBuilder + .addOperation( + Operation.invokeContractFunction({ + contract: TESTNET_NATIVE_SAC, + function: "transfer", + args: [ + new Address(sourceKp.publicKey).toScVal(), + new Address(MUXED_CONTRACT_MAX_ID).toScVal(), + nativeToScVal(12345, { type: "i128" }), + ], + }), + ) + .build(); + const prepared = await new rpc.Server( + "https://soroban-testnet.stellar.org", + ).prepareTransaction(transfer); + const uri = Sep7Tx.forTransaction(prepared).toString(); + + expect(isValidSep7Uri(uri)).toEqual({ result: true }); + const tx = (parseSep7Uri(uri) as Sep7Tx).getTransaction(); + expect(transferRecipient(tx)).toBe(MUXED_CONTRACT_MAX_ID); + + sourceKp.sign(tx); + expect(await stellar.submitTransaction(tx)).toBe(true); + + const record = await stellar.server + .transactions() + .transaction(xdr.encodeBytes(tx.hash(), "hex")) + .call(); + const onChain = stellar.decodeTransaction( + record.envelope_xdr, + ) as Transaction; + expect(transferRecipient(onChain)).toBe(MUXED_CONTRACT_MAX_ID); + }, 60000); +}); + // A C or W address can only be paid with a Stellar Asset Contract transfer, and // that's a Soroban transaction, which can't carry a memo. describe("SEP-7 'pay' to a contract (C or W) address with a memo", () => { From b927f552786fa62775085180579c34ca361b192c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 3 Nov 2026 11:17:12 -0800 Subject: [PATCH 06/13] test(e2e): check Anchor.sep1() needs a strict TextDecoder on React Native The changelog's React Native breaking change says Anchor.sep1() rejects where TextDecoder can't decode strictly, but only verifySignature() had a sandbox test. The new test reads a stub stellar.toml through Wallet.anchor().sep1() with each decoder. On the 5.1.0 bundle (stellar-sdk 17.0.1), sep1() resolves with the non-strict decoder, so the test fails there. Co-Authored-By: Claude Opus 5.5 --- .../test/e2e/reactNative.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/@stellar/typescript-wallet-sdk/test/e2e/reactNative.test.ts b/@stellar/typescript-wallet-sdk/test/e2e/reactNative.test.ts index 12526c11..e867012d 100644 --- a/@stellar/typescript-wallet-sdk/test/e2e/reactNative.test.ts +++ b/@stellar/typescript-wallet-sdk/test/e2e/reactNative.test.ts @@ -226,6 +226,45 @@ describe("React Native (Hermes-like) bundle compatibility", () => { expect(await verifyWith(NonFatalTextDecoder)).toBe(false); }); + it("reads an anchor's stellar.toml only with a TextDecoder that decodes strictly", async () => { + // Every anchor flow starts from Anchor.sep1(), which reads the anchor's + // stellar.toml. With a TextDecoder that can't decode strictly, it rejects. + const sep1With = (textDecoder: typeof TextDecoder) => { + const context = makeHermesLikeContext({ textDecoder }); + // React Native defines `window` as the global object, and + // Wallet.anchor() reads `window.URL` through it. + Object.assign(context, { + window: context, + Headers, + AbortController, + AbortSignal, + fetch: () => + Promise.resolve( + new Response( + 'WEB_AUTH_ENDPOINT="https://example.com/auth"\n[DOCUMENTATION]\nORG_NAME="Example"\n', + ), + ), + }); + const code = fs.readFileSync( + path.resolve(__dirname, "../../lib/bundle_browser.js"), + "utf8", + ); + vm.runInContext(code, context, { filename: "bundle_browser.js" }); + + return vm.runInContext( + `module.exports.Wallet.TestNet().anchor({ homeDomain: "example.com" }).sep1()`, + context, + ) as Promise<{ webAuthEndpoint: string }>; + }; + + expect((await sep1With(TextDecoder)).webAuthEndpoint).toBe( + "https://example.com/auth", + ); + await expect(sep1With(NonFatalTextDecoder)).rejects.toThrow( + "the 'fatal' option is unsupported", + ); + }); + it("requires btoa/atob for base64 paths, while base32/StrKey keypair creation stays pure JS", () => { const context = makeHermesLikeContext({ includeBase64: false }); const code = fs.readFileSync( From b7ed127534440334d44cc88a4a8c2c253da4545d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 3 Nov 2026 11:18:02 -0800 Subject: [PATCH 07/13] test: pin the client_domain source checks on SEP-10 challenges stellar-sdk 17.3's WebAuth.readChallengeTx rejects a challenge whose client_domain operation has no source account or uses the server account. Sep10.authenticate, Server.signChallengeTransaction and KeyManager.fetchAuthToken validate challenges with it, and 5.1.0 accepted such a challenge. Each test checks the error. The Sep10 and key manager tests also check that the client domain signer wasn't asked to sign and nothing was posted back. With the two checks removed from stellar-sdk, all six cases fail. Co-Authored-By: Claude Opus 5.5 --- .../test/keyManager.test.ts | 98 +++++++++++++++++++ .../typescript-wallet-sdk/test/auth.test.ts | 51 ++++++++++ .../typescript-wallet-sdk/test/server.test.ts | 39 ++++++++ 3 files changed, 188 insertions(+) diff --git a/@stellar/typescript-wallet-sdk-km/test/keyManager.test.ts b/@stellar/typescript-wallet-sdk-km/test/keyManager.test.ts index b7eacf18..109d9450 100644 --- a/@stellar/typescript-wallet-sdk-km/test/keyManager.test.ts +++ b/@stellar/typescript-wallet-sdk-km/test/keyManager.test.ts @@ -999,6 +999,104 @@ describe("fetchAuthToken", () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); + // WebAuth.readChallengeTx checks these since stellar-sdk 17.3. SEP-10 + // requires the client_domain operation's source to be the client domain's + // signing key. + test.each([ + ["has no source account", "none"], + ["uses the server account as its source", "server"], + ])( + "Rejects a challenge whose client_domain operation %s", + async (_, clientDomainSource) => { + const authServer = "https://www.stellar.org/auth"; + const password = "very secure password"; + + const keyNetwork = Networks.TESTNET; + + const accountKey = Keypair.random(); + const account = new Account(accountKey.publicKey(), "-1"); + + // set up the manager + const testStore = new MemoryKeyStore(); + const testKeyManager = new KeyManager({ + keyStore: testStore, + }); + + testKeyManager.registerEncrypter(IdentityEncrypter); + + const keypair = Keypair.master(keyNetwork); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: keyNetwork, + }) + .addOperation( + Operation.manageData({ + name: `stellar.org auth`, + value: randomBytes(48).toString("base64"), + source: keypair.publicKey(), + }), + ) + .addOperation( + Operation.manageData({ + name: "web_auth_domain", + value: new URL(authServer).hostname, + source: account.accountId(), + }), + ) + .addOperation( + Operation.manageData({ + name: "client_domain", + value: "wallet.example.com", + source: + clientDomainSource === "server" ? account.accountId() : undefined, + }), + ) + .setTimeout(300) + .build(); + + tx.sign(accountKey); + + const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce( + new Response( + JSON.stringify({ + transaction: tx.toXDR(), + network_passphrase: keyNetwork, + }), + ), + ); + + const keyMetadata = await testKeyManager.storeKey({ + key: { + type: KeyType.plaintextKey, + publicKey: keypair.publicKey(), + privateKey: keypair.secret(), + network: keyNetwork, + }, + password, + encrypterName: "IdentityEncrypter", + }); + + const onChallengeTransactionSignature = jest.fn(); + + const result = testKeyManager.fetchAuthToken({ + id: keyMetadata.id, + password, + authServer, + authServerKey: account.accountId(), + authServerHomeDomains: ["stellar.org"], + clientDomain: "wallet.example.com", + onChallengeTransactionSignature, + }); + await expect(result).rejects.toThrow(WebAuth.InvalidChallengeError); + await expect(result).rejects.toThrow(/client_domain/); + + // The challenge was fetched, but nothing was signed or posted back. + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(onChallengeTransactionSignature).not.toHaveBeenCalled(); + }, + ); + test("Can use a challenge token", async () => { const authServer = "https://www.stellar.org/auth"; const password = "very secure password"; diff --git a/@stellar/typescript-wallet-sdk/test/auth.test.ts b/@stellar/typescript-wallet-sdk/test/auth.test.ts index b0cd769c..e43fc12a 100644 --- a/@stellar/typescript-wallet-sdk/test/auth.test.ts +++ b/@stellar/typescript-wallet-sdk/test/auth.test.ts @@ -726,6 +726,57 @@ describe("Sep10 challenge validation", () => { }); }); + // WebAuth.readChallengeTx checks these since stellar-sdk 17.3. SEP-10 + // requires the client_domain operation's source to be the client domain's + // signing key. + describe("client_domain operation source", () => { + it.each([ + ["has no source account", "none"], + ["uses the server account as its source", "server"], + ])( + "should reject a challenge whose client_domain operation %s", + async (_, clientDomainSource) => { + const serverKeypair = Keypair.random(); + const clientKeypair = Keypair.random(); + const { xdr } = buildChallenge({ + serverKeypair, + clientKeypair, + additionalOps: [ + Operation.manageData({ + name: "client_domain", + value: "wallet.example.com", + source: + clientDomainSource === "server" + ? serverKeypair.publicKey() + : undefined, + }), + ], + }); + const { sep10, postStub } = setupSep10({ + serverSigningKey: serverKeypair.publicKey(), + challengeXdr: xdr, + token: createJwt(clientKeypair), + }); + const signWithDomainAccount = sinon + .stub() + .rejects(new Error("the challenge should be rejected first")); + + const result = sep10.authenticate({ + accountKp: SigningKeypair.fromSecret(clientKeypair.secret()), + clientDomain: "wallet.example.com", + walletSigner: { + signWithClientAccount: ({ transaction }) => transaction, + signWithDomainAccount, + }, + }); + await expect(result).rejects.toThrow(ChallengeValidationFailedError); + await expect(result).rejects.toThrow(/client_domain/); + expect(signWithDomainAccount.notCalled).toBe(true); + expect(postStub.notCalled).toBe(true); + }, + ); + }); + describe("network passphrase mismatch", () => { it("should reject when server returns a different network passphrase", async () => { const { xdr, serverKeypair, clientKeypair } = buildChallenge(); diff --git a/@stellar/typescript-wallet-sdk/test/server.test.ts b/@stellar/typescript-wallet-sdk/test/server.test.ts index bb0f4819..c8e0e243 100644 --- a/@stellar/typescript-wallet-sdk/test/server.test.ts +++ b/@stellar/typescript-wallet-sdk/test/server.test.ts @@ -277,6 +277,45 @@ describe("signChallengeTransaction validation", () => { ).rejects.toThrow(ChallengeValidationFailedError); }); + // WebAuth.readChallengeTx checks these since stellar-sdk 17.3. SEP-10 + // requires the client_domain operation's source to be the client domain's + // signing key. + it.each([ + ["has no source account", "none"], + ["uses the server account as its source", "server"], + ])( + "should reject a challenge whose client_domain operation %s", + async (_, clientDomainSource) => { + const challenge = TransactionBuilder.cloneFrom( + TransactionBuilder.fromXDR( + buildChallenge(anchorAKp, anchorA, accountKp.publicKey), + networkPassphrase, + ) as Transaction, + ) + .addOperation( + Operation.manageData({ + name: "client_domain", + value: "wallet.example.com", + source: + clientDomainSource === "server" + ? anchorAKp.publicKey() + : undefined, + }), + ) + .build(); + challenge.sign(anchorAKp); + + const result = Server.signChallengeTransaction({ + accountKp, + challengeTx: challenge.toXDR(), + networkPassphrase, + anchorDomain: anchorA, + }); + await expect(result).rejects.toThrow(ChallengeValidationFailedError); + await expect(result).rejects.toThrow(/client_domain/); + }, + ); + it("should accept an explicit homeDomain that differs from anchorDomain", async () => { const homeDomain = "wallet-home.example.com"; const challengeTx = buildChallenge( From 62d1e68873f00bdf05038e90b5440644300e689f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 3 Nov 2026 11:18:13 -0800 Subject: [PATCH 08/13] docs(changelog): list the client_domain challenge rejection as breaking KeyManager.fetchAuthToken, Sep10.authenticate and Server.signChallengeTransaction reject a challenge that 5.1.0 accepted, so the entry moves from Changed to BREAKING CHANGES in the core and key manager changelogs. The key manager no longer says it has no breaking changes. The core's React Native entry now says each of its two claims has a sandbox test. Co-Authored-By: Claude Opus 5.5 --- @stellar/typescript-wallet-sdk-km/CHANGELOG.MD | 6 ++---- @stellar/typescript-wallet-sdk/CHANGELOG.MD | 5 +++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/@stellar/typescript-wallet-sdk-km/CHANGELOG.MD b/@stellar/typescript-wallet-sdk-km/CHANGELOG.MD index e1d17a4f..3a3a530c 100644 --- a/@stellar/typescript-wallet-sdk-km/CHANGELOG.MD +++ b/@stellar/typescript-wallet-sdk-km/CHANGELOG.MD @@ -1,13 +1,11 @@ # Release notes - Typescript Wallet SDK Key Manager - 6.0.0 -This package has no breaking changes. It moves to 6.0.0 to stay in lockstep with the core SDK. +### BREAKING CHANGES +* `KeyManager.fetchAuthToken` rejects a challenge whose `client_domain` operation has no source account or uses the server account as its source, throwing stellar-sdk's `InvalidChallengeError`. stellar-sdk 17.3's `WebAuth.readChallengeTx` added this check, since SEP-10 requires that source to be the client domain's signing key. 5.1.0 accepted such a challenge ### Added * Protocol 30 support: upgraded `@stellar/stellar-sdk` from `17.0.1` to `17.4.0`, which adds CAP-84 muxed contract (`W…`) addresses. The Albedo and Freighter handlers read back a signed transaction that contains one. Before, decoding the signed envelope threw -### Changed -* `KeyManager.fetchAuthToken` rejects a challenge whose `client_domain` operation has no source account or uses the server account as its source, as stellar-sdk 17.3's `WebAuth.readChallengeTx` does - **Consumer note:** the key-store surface (`KeyManager`'s `registerEncrypter`, `storeKey`, `loadKey`, `loadAllKeyIds`, `removeKey` and `changePassword`, the keystores and the encrypters) doesn't use stellar-sdk and is unchanged, so upgrading from 5.x needs no code changes for it. Consumers that also depend on `@stellar/stellar-sdk` directly should align their pin to `17.4.0`, since this package bundles that exact version. # Release notes - Typescript Wallet SDK Key Manager - 5.1.0 diff --git a/@stellar/typescript-wallet-sdk/CHANGELOG.MD b/@stellar/typescript-wallet-sdk/CHANGELOG.MD index 5c733ed7..8c303fe8 100644 --- a/@stellar/typescript-wallet-sdk/CHANGELOG.MD +++ b/@stellar/typescript-wallet-sdk/CHANGELOG.MD @@ -1,15 +1,16 @@ # Release notes - Typescript Wallet SDK - 6.0.0 ### BREAKING CHANGES -* **React Native: reading a `stellar.toml` needs a `TextDecoder` that supports `{ fatal: true }`.** stellar-sdk 17.3 and later decodes `stellar.toml` with `TextDecoder("utf-8", { fatal: true })`. Where `TextDecoder` can't be built with `fatal`, as with `fast-text-encoding` on Hermes, `Anchor.sep1()` rejects, and with it every call that needs the anchor's `stellar.toml`, and `verifySignature()` on a SEP-7 URI returns `false` for a valid signature. Both worked with such a `TextDecoder` in 5.1.0. Loading the bundle doesn't need one. Verified in a Hermes-like sandbox test +* **React Native: reading a `stellar.toml` needs a `TextDecoder` that supports `{ fatal: true }`.** stellar-sdk 17.3 and later decodes `stellar.toml` with `TextDecoder("utf-8", { fatal: true })`. Where `TextDecoder` can't be built with `fatal`, as with `fast-text-encoding` on Hermes, `Anchor.sep1()` rejects, and with it every call that needs the anchor's `stellar.toml`, and `verifySignature()` on a SEP-7 URI returns `false` for a valid signature. Both worked with such a `TextDecoder` in 5.1.0. Loading the bundle doesn't need one. Each is verified in a Hermes-like sandbox test * `isValidSep7Uri` and `parseSep7Uri` reject a `pay` URI that has a `memo` when its destination is a contract (`C…`) or muxed contract (`W…`) address. Paying either takes a Soroban transaction, and the network rejects a Soroban transaction that has a memo, so a wallet could only drop the memo. A `C…` destination with a memo used to be accepted +* `Sep10.authenticate` and `Server.signChallengeTransaction` reject a challenge whose `client_domain` operation has no source account or uses the server account as its source, with `ChallengeValidationFailedError`. stellar-sdk 17.3's `WebAuth.readChallengeTx` added this check, since SEP-10 requires that source to be the client domain's signing key. 5.1.0 accepted such a challenge ### Added * Protocol 30 support: upgraded `@stellar/stellar-sdk` from `17.0.1` to `17.4.0`, which adds CAP-84 muxed contract (`W…`) addresses. `Stellar.decodeTransaction` and `Sep7Tx.getTransaction` decode a transaction that contains one, and `isValidSep7Uri` accepts a `tx` URI whose transaction contains one. Before, decoding such a transaction threw, so `isValidSep7Uri` returned `false` * `isValidSep7Uri` and `parseSep7Uri` accept a muxed contract (`W…`) address as a `pay` destination. As with a contract (`C…`) address, a wallet can only pay it with a Stellar Asset Contract `transfer`. `TransactionBuilder`'s `transfer`, `pathPay` and `accountMerge` reject it, as before ### Changed -* Stricter checks from stellar-sdk 17.1 to 17.4 that reach this SDK: `AccountService.getInfo` and `getHistory` reject with a `TypeError` for an empty account address, which used to query a different Horizon endpoint; `Sep10.authenticate` and `Server.signChallengeTransaction` reject a challenge whose `client_domain` operation has no source account or uses the server account as its source; `Anchor.sep1()` rejects a `homeDomain` with credentials, a path, a query or a fragment before making a request; and a `stellar.toml` that isn't valid UTF-8 fails to load. See the stellar-sdk changelog for the full list +* Stricter checks from stellar-sdk 17.1 to 17.4 that reach this SDK: `AccountService.getInfo` and `getHistory` reject with a `TypeError` for an empty account address, which used to query a different Horizon endpoint; `Anchor.sep1()` rejects a `homeDomain` with credentials, a path, a query or a fragment before making a request; and a `stellar.toml` that isn't valid UTF-8 fails to load. See the stellar-sdk changelog for the full list # Release notes - Typescript Wallet SDK - 5.1.0 From 4539a59fffb4ba63cbf4ecd1f47ae8503bb8ee7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 4 Nov 2026 13:10:19 -0800 Subject: [PATCH 09/13] feat(sep7): make Sep7Pay throw on a memo with a C or W destination parseSep7Uri rejects a pay URI that has a memo and a contract (C) or muxed contract (W) destination, but Sep7Pay could still build one. Its constructor and its destination and memo setters now throw Sep7ContractMemoError, so the call that creates that combination fails and leaves the URI unchanged. Co-Authored-By: Claude Opus 5.5 --- @stellar/typescript-wallet-sdk/CHANGELOG.MD | 2 +- .../src/walletSdk/Exceptions/index.ts | 9 +++ .../src/walletSdk/Uri/Sep7Pay.ts | 39 +++++++++- .../typescript-wallet-sdk/test/sep7.test.ts | 72 +++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/@stellar/typescript-wallet-sdk/CHANGELOG.MD b/@stellar/typescript-wallet-sdk/CHANGELOG.MD index 8c303fe8..28fb8263 100644 --- a/@stellar/typescript-wallet-sdk/CHANGELOG.MD +++ b/@stellar/typescript-wallet-sdk/CHANGELOG.MD @@ -2,7 +2,7 @@ ### BREAKING CHANGES * **React Native: reading a `stellar.toml` needs a `TextDecoder` that supports `{ fatal: true }`.** stellar-sdk 17.3 and later decodes `stellar.toml` with `TextDecoder("utf-8", { fatal: true })`. Where `TextDecoder` can't be built with `fatal`, as with `fast-text-encoding` on Hermes, `Anchor.sep1()` rejects, and with it every call that needs the anchor's `stellar.toml`, and `verifySignature()` on a SEP-7 URI returns `false` for a valid signature. Both worked with such a `TextDecoder` in 5.1.0. Loading the bundle doesn't need one. Each is verified in a Hermes-like sandbox test -* `isValidSep7Uri` and `parseSep7Uri` reject a `pay` URI that has a `memo` when its destination is a contract (`C…`) or muxed contract (`W…`) address. Paying either takes a Soroban transaction, and the network rejects a Soroban transaction that has a memo, so a wallet could only drop the memo. A `C…` destination with a memo used to be accepted +* `isValidSep7Uri` and `parseSep7Uri` reject a `pay` URI that has a `memo` when its destination is a contract (`C…`) or muxed contract (`W…`) address, and `Sep7Pay` won't build one: its constructor and its `destination` and `memo` setters throw `Sep7ContractMemoError`. Paying either takes a Soroban transaction, and the network rejects a Soroban transaction that has a memo, so a wallet could only drop the memo. A `C…` destination with a memo used to be accepted * `Sep10.authenticate` and `Server.signChallengeTransaction` reject a challenge whose `client_domain` operation has no source account or uses the server account as its source, with `ChallengeValidationFailedError`. stellar-sdk 17.3's `WebAuth.readChallengeTx` added this check, since SEP-10 requires that source to be the client domain's signing key. 5.1.0 accepted such a challenge ### Added diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts index 0528aa9a..12e23f26 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts @@ -474,6 +474,15 @@ export class Sep7LongMsgError extends Error { } } +export class Sep7ContractMemoError extends Error { + constructor() { + super( + "the 'memo' parameter is not supported for a contract ('C') or muxed contract ('W') 'destination'", + ); + Object.setPrototypeOf(this, Sep7ContractMemoError.prototype); + } +} + export class Sep7UriTypeNotSupportedError extends Error { constructor(type: string) { super( diff --git a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts index 07232bd8..69c9f60a 100644 --- a/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts +++ b/@stellar/typescript-wallet-sdk/src/walletSdk/Uri/Sep7Pay.ts @@ -1,6 +1,31 @@ -import { MemoType } from "@stellar/stellar-sdk"; +import { MemoType, StrKey } from "@stellar/stellar-sdk"; import { Sep7Base } from "../Uri"; import { Sep7OperationType, WEB_STELLAR_SCHEME } from "../Types"; +import { Sep7ContractMemoError } from "../Exceptions"; + +/** + * Throws if a uri would have a memo with a contract ("C...") or muxed + * contract ("W...") destination, which 'parseSep7Uri' rejects. Paying either + * takes a Soroban transaction, which can't have a memo. + * + * @param {string | undefined} destination the uri 'destination' param. + * @param {string | undefined} memo the uri 'memo' param. + * @throws {Sep7ContractMemoError} if both are set and 'destination' is a + * contract or muxed contract address. + */ +const assertNoContractMemo = ( + destination: string | undefined, + memo: string | undefined, +) => { + if ( + memo && + destination && + (StrKey.isValidContract(destination) || + StrKey.isValidMuxedContract(destination)) + ) { + throw new Sep7ContractMemoError(); + } +}; /** * The Sep-7 'pay' operation represents a request to pay a specific address @@ -27,9 +52,13 @@ export class Sep7Pay extends Sep7Base { * * @constructor * @param {URL | string} [uri] - uri to initialize the Sep7 instance. + * @throws {Sep7ContractMemoError} if 'uri' has a memo and a contract ("C...") + * or muxed contract ("W...") destination. */ constructor(uri?: URL | string) { super(uri ?? new URL(`${WEB_STELLAR_SCHEME}${Sep7OperationType.pay}`)); + + assertNoContractMemo(this.destination, this.memo); } /** @@ -62,8 +91,12 @@ export class Sep7Pay extends Sep7Base { * Deletes the uri 'destination' param if set as 'undefined'. * * @param {string | undefined} destination the uri 'destination' param to be set. + * @throws {Sep7ContractMemoError} if 'destination' is a contract ("C...") or + * muxed contract ("W...") address and the uri has a memo. */ set destination(destination: string | undefined) { + assertNoContractMemo(destination, this.memo); + this.setParam("destination", destination); } @@ -146,8 +179,12 @@ export class Sep7Pay extends Sep7Base { * Deletes the uri 'memo' param if set as 'undefined'. * * @param {string | undefined} memo the uri 'memo' param to be set. + * @throws {Sep7ContractMemoError} if 'memo' is set and the uri's destination + * is a contract ("C...") or muxed contract ("W...") address. */ set memo(memo: string | undefined) { + assertNoContractMemo(this.destination, memo); + this.setParam("memo", memo); } diff --git a/@stellar/typescript-wallet-sdk/test/sep7.test.ts b/@stellar/typescript-wallet-sdk/test/sep7.test.ts index 8dac3e63..3b7ec641 100644 --- a/@stellar/typescript-wallet-sdk/test/sep7.test.ts +++ b/@stellar/typescript-wallet-sdk/test/sep7.test.ts @@ -30,6 +30,7 @@ import { URI_REPLACE_MAX_LENGTH, } from "../src/walletSdk/Types"; import { + Sep7ContractMemoError, Sep7InvalidUriError, Sep7LongMsgError, } from "../src/walletSdk/Exceptions"; @@ -1212,3 +1213,74 @@ describe("SEP-7 'pay' to a contract (C or W) address with a memo", () => { }, ); }); + +// Sep7Pay shouldn't build a uri that parseSep7Uri rejects. +describe("Sep7Pay with a memo and a contract (C or W) destination", () => { + const ACCOUNT = "GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO"; + const MUXED_ACCOUNT = + "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAABUTGI4"; + + describe.each([ + ["C", SEP23_CONTRACT], + ["W", SEP23_MUXED_CONTRACT], + ])("to a %s address", (_, destination) => { + it("the memo setter throws and leaves the uri unchanged", () => { + const uri = Sep7Pay.forDestination(destination); + + expect(() => { + uri.memo = "skdjfasf"; + }).toThrow(Sep7ContractMemoError); + expect(uri.toString()).toBe(`web+stellar:pay?destination=${destination}`); + }); + + it("the destination setter throws on a uri that has a memo and leaves it unchanged", () => { + const uri = Sep7Pay.forDestination(ACCOUNT); + uri.memo = "skdjfasf"; + + expect(() => { + uri.destination = destination; + }).toThrow(Sep7ContractMemoError); + expect(uri.toString()).toBe( + `web+stellar:pay?destination=${ACCOUNT}&memo=skdjfasf`, + ); + }); + + it("the constructor throws on a uri that has a memo", () => { + expect( + () => + new Sep7Pay( + `web+stellar:pay?destination=${destination}&memo=skdjfasf`, + ), + ).toThrow(Sep7ContractMemoError); + }); + + it("builds a uri that parses once the memo is cleared", () => { + const uri = Sep7Pay.forDestination(ACCOUNT); + uri.memo = "skdjfasf"; + uri.memo = undefined; + uri.destination = destination; + uri.amount = "10"; + + expect(() => { + uri.memo = undefined; + }).not.toThrow(); + expect(uri.toString()).toBe( + `web+stellar:pay?destination=${destination}&amount=10`, + ); + expect(parseSep7Uri(uri.toString())).toBeInstanceOf(Sep7Pay); + }); + }); + + it.each([ + ["G", ACCOUNT], + ["M", MUXED_ACCOUNT], + ])("the memo setter accepts a %s destination", (_, destination) => { + const uri = Sep7Pay.forDestination(destination); + uri.memo = "skdjfasf"; + + expect(uri.toString()).toBe( + `web+stellar:pay?destination=${destination}&memo=skdjfasf`, + ); + expect(parseSep7Uri(uri.toString())).toBeInstanceOf(Sep7Pay); + }); +}); From fe3868a495078fa4f89b35df4ac8a85cf92288d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 6 Nov 2026 09:57:45 -0800 Subject: [PATCH 10/13] test: give the account-modifying testnet tests room for a slow friendbot Both tests funded two accounts with friendbot one after the other, and a single friendbot call has been taking 13 to 26 seconds, while ledgers still close every 5 seconds. That alone used up most of their 30 and 45 second limits. They now fund both accounts in parallel and allow 90 seconds. Co-Authored-By: Claude Opus 5.5 --- .../test/stellar.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/@stellar/typescript-wallet-sdk/test/stellar.test.ts b/@stellar/typescript-wallet-sdk/test/stellar.test.ts index f7df9cca..7a5f310b 100644 --- a/@stellar/typescript-wallet-sdk/test/stellar.test.ts +++ b/@stellar/typescript-wallet-sdk/test/stellar.test.ts @@ -649,6 +649,8 @@ describe("Asset", () => { }); }); +// These tests wait on testnet, where one friendbot call can take 25 s or more, +// so they fund their accounts in parallel and allow 90 s. describe("Account Modifying", () => { it("should modify account ", async () => { const wallet = Wallet.TestNet(); @@ -656,10 +658,10 @@ describe("Account Modifying", () => { const sourceKp = new SigningKeypair(Keypair.random()); const otherKp = new SigningKeypair(Keypair.random()); - await axios.get( - "https://friendbot.stellar.org/?addr=" + sourceKp.publicKey, - ); - await axios.get("https://friendbot.stellar.org/?addr=" + otherKp.publicKey); + await Promise.all([ + axios.get("https://friendbot.stellar.org/?addr=" + sourceKp.publicKey), + axios.get("https://friendbot.stellar.org/?addr=" + otherKp.publicKey), + ]); // Add account signer let txBuilder = await stellar.transaction({ @@ -723,7 +725,7 @@ describe("Account Modifying", () => { resp = await stellar.server.loadAccount(sourceKp.publicKey); expect(resp.signers[0].weight).toBe(0); - }, 45000); + }, 90000); it("should merge account", async () => { const wallet = Wallet.TestNet(); @@ -732,8 +734,10 @@ describe("Account Modifying", () => { const accountKp = account.createKeypair(); const sourceKp = account.createKeypair(); - await stellar.fundTestnetAccount(accountKp.publicKey); - await stellar.fundTestnetAccount(sourceKp.publicKey); + await Promise.all([ + stellar.fundTestnetAccount(accountKp.publicKey), + stellar.fundTestnetAccount(sourceKp.publicKey), + ]); const txBuilder = await stellar.transaction({ sourceAddress: accountKp, @@ -758,7 +762,7 @@ describe("Account Modifying", () => { expect(found).toBeFalsy(); const accResp = await stellar.server.loadAccount(accountKp.publicKey); expect(parseInt(accResp.balances[0].balance)).toBeGreaterThan(19998); - }, 30000); + }, 90000); }); // Hermetic (no network): stellar-sdk v16 tightened Memo validation, so a From 084288643110b67b922cc0aedc47f10ec3ab1fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 6 Nov 2026 10:50:01 -0800 Subject: [PATCH 11/13] test: fund the SponsoringBuilder testnet accounts in parallel Its beforeAll funded two accounts with friendbot one after the other within 30 seconds, and a single friendbot call has been taking 15 to 26 seconds, so the hook timed out and failed all four tests. Friendbot serves parallel calls at the same time, so the hook now funds both accounts in parallel and allows 90 seconds, like the account-modifying tests. Co-Authored-By: Claude Opus 5.5 --- .../typescript-wallet-sdk/test/stellar.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/@stellar/typescript-wallet-sdk/test/stellar.test.ts b/@stellar/typescript-wallet-sdk/test/stellar.test.ts index 7a5f310b..50f60797 100644 --- a/@stellar/typescript-wallet-sdk/test/stellar.test.ts +++ b/@stellar/typescript-wallet-sdk/test/stellar.test.ts @@ -501,6 +501,8 @@ let txnSourceKp; let sponsorKp; let newKp; describe("SponsoringBuilder", () => { + // One friendbot call can take 25 s or more on testnet, so this funds both + // accounts in parallel and allows 90 s. beforeAll(async () => { wal = Wallet.TestNet(); stellar = wal.stellar(); @@ -508,13 +510,11 @@ describe("SponsoringBuilder", () => { txnSourceKp = new SigningKeypair(Keypair.random()); sponsorKp = new SigningKeypair(Keypair.random()); newKp = new SigningKeypair(Keypair.random()); - await axios.get( - "https://friendbot.stellar.org/?addr=" + sponsorKp.publicKey, - ); - await axios.get( - "https://friendbot.stellar.org/?addr=" + txnSourceKp.publicKey, - ); - }, 30000); + await Promise.all([ + axios.get("https://friendbot.stellar.org/?addr=" + sponsorKp.publicKey), + axios.get("https://friendbot.stellar.org/?addr=" + txnSourceKp.publicKey), + ]); + }, 90000); it("should sponsor creating an account", async () => { const wal = Wallet.TestNet(); From e25cac6e230e6fe9af2a795dd6d77d73cc35724f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 6 Nov 2026 10:58:52 -0800 Subject: [PATCH 12/13] test: allow 90 s for the testnet tests that wait on one friendbot call The SEP-10 header-token test, the SEP-6 setup and the account service setup each fund a new account with friendbot and then do more testnet work, all within 30 seconds. A single friendbot call has been taking 15 to 26 seconds, which timed out the SEP-10 test on CI and, in October, the SEP-6 setup. They now allow 90 seconds. Co-Authored-By: Claude Opus 5.5 --- @stellar/typescript-wallet-sdk/test/accountService.test.ts | 5 +++-- @stellar/typescript-wallet-sdk/test/sep6.test.ts | 3 ++- @stellar/typescript-wallet-sdk/test/wallet.test.ts | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/@stellar/typescript-wallet-sdk/test/accountService.test.ts b/@stellar/typescript-wallet-sdk/test/accountService.test.ts index 96d95a22..290e616d 100644 --- a/@stellar/typescript-wallet-sdk/test/accountService.test.ts +++ b/@stellar/typescript-wallet-sdk/test/accountService.test.ts @@ -11,7 +11,8 @@ let accountAddress: string; describe("Horizon", () => { // Creates testing stellar account with USDC trustline - // in case it doesn't exist just yet + // in case it doesn't exist just yet. One friendbot call can take 25 s or + // more on testnet, so this allows 90 s. beforeAll(async () => { const wallet = Wallet.TestNet(); const stellar = wallet.stellar(); @@ -46,7 +47,7 @@ describe("Horizon", () => { await stellar.submitTransaction(addUsdcTx); accountAddress = testingAccountKp.publicKey; - }, 30000); + }, 90000); it("should return stellar account details", async () => { const response = await accountService.getInfo({ accountAddress }); diff --git a/@stellar/typescript-wallet-sdk/test/sep6.test.ts b/@stellar/typescript-wallet-sdk/test/sep6.test.ts index a82c394d..2b31a25f 100644 --- a/@stellar/typescript-wallet-sdk/test/sep6.test.ts +++ b/@stellar/typescript-wallet-sdk/test/sep6.test.ts @@ -7,6 +7,7 @@ let sep6; let accountKp; describe("SEP-6", () => { + // One friendbot call can take 25 s or more on testnet, so this allows 90 s. beforeAll(async () => { wallet = Wallet.TestNet(); anchor = wallet.anchor({ homeDomain: "testanchor.stellar.org" }); @@ -16,7 +17,7 @@ describe("SEP-6", () => { await axios.get( "https://friendbot.stellar.org/?addr=" + accountKp.publicKey, ); - }, 30000); + }, 90000); it("should get anchor info", async () => { const resp = await sep6.info(); diff --git a/@stellar/typescript-wallet-sdk/test/wallet.test.ts b/@stellar/typescript-wallet-sdk/test/wallet.test.ts index 412c205e..ab625bc1 100644 --- a/@stellar/typescript-wallet-sdk/test/wallet.test.ts +++ b/@stellar/typescript-wallet-sdk/test/wallet.test.ts @@ -2033,6 +2033,7 @@ describe("AuthHeaderSigner", () => { beforeAll(() => { jest.resetAllMocks(); }); + // One friendbot call can take 25 s or more on testnet, so this allows 90 s. it("full sep-10 auth using header token should work", async () => { const wallet = Wallet.TestNet(); const accountKp = wallet.stellar().account().createKeypair(); @@ -2054,7 +2055,7 @@ describe("AuthHeaderSigner", () => { }); expect(authToken).toBeTruthy(); - }, 30000); + }, 90000); it("should match example implementation generated JWT", async () => { const generatedAuthToken = From 33bb2443c605dc82f5a908bae5c3df554f5c4a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 6 Nov 2026 11:06:34 -0800 Subject: [PATCH 13/13] test: give the integration tests room for a slow friendbot The recovery integration test creates three new accounts, so it always funded them with friendbot one after the other before doing anything else, within 60 seconds. A single friendbot call has been taking 15 to 26 seconds, and the test timed out on CI. It now funds them in parallel and allows 120 seconds. The anchor platform setup makes one friendbot call within 30 seconds, so it now allows 90 seconds too. Co-Authored-By: Claude Opus 5.5 --- .../test/integration/anchorplatform.test.ts | 3 ++- .../test/integration/recovery.test.ts | 20 +++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/@stellar/typescript-wallet-sdk/test/integration/anchorplatform.test.ts b/@stellar/typescript-wallet-sdk/test/integration/anchorplatform.test.ts index 779a08d3..6bbcdad4 100644 --- a/@stellar/typescript-wallet-sdk/test/integration/anchorplatform.test.ts +++ b/@stellar/typescript-wallet-sdk/test/integration/anchorplatform.test.ts @@ -9,6 +9,7 @@ let accountKp; const anchorUrl = "anchor-sep-server-dev.stellar.org"; describe("Anchor Platform Integration Tests", () => { + // One friendbot call can take 25 s or more on testnet, so this allows 90 s. beforeAll(async () => { // Setup wallet = Wallet.TestNet(); @@ -16,7 +17,7 @@ describe("Anchor Platform Integration Tests", () => { anchor = wallet.anchor({ homeDomain: anchorUrl }); accountKp = stellar.account().createKeypair(); await stellar.fundTestnetAccount(accountKp.publicKey); - }, 30000); + }, 90000); it("SEP-10 auth should work", async () => { const auth = await anchor.sep10(); diff --git a/@stellar/typescript-wallet-sdk/test/integration/recovery.test.ts b/@stellar/typescript-wallet-sdk/test/integration/recovery.test.ts index f4276341..92daa0c0 100644 --- a/@stellar/typescript-wallet-sdk/test/integration/recovery.test.ts +++ b/@stellar/typescript-wallet-sdk/test/integration/recovery.test.ts @@ -51,15 +51,15 @@ describe("Recovery Integration Tests", () => { await stellar.server.loadAccount(deviceKp.publicKey); await stellar.server.loadAccount(recoveryKp.publicKey); } catch (e) { - await axios.get( - "https://friendbot.stellar.org/?addr=" + accountKp.publicKey, - ); - await axios.get( - "https://friendbot.stellar.org/?addr=" + deviceKp.publicKey, - ); - await axios.get( - "https://friendbot.stellar.org/?addr=" + recoveryKp.publicKey, - ); + // One friendbot call can take 25 s or more on testnet, so these run in + // parallel and the test allows 120 s. + await Promise.all([ + axios.get("https://friendbot.stellar.org/?addr=" + accountKp.publicKey), + axios.get("https://friendbot.stellar.org/?addr=" + deviceKp.publicKey), + axios.get( + "https://friendbot.stellar.org/?addr=" + recoveryKp.publicKey, + ), + ]); } // Create SEP-30 identities @@ -164,5 +164,5 @@ describe("Recovery Integration Tests", () => { expect(resp.signers.find((obj) => obj.key === newKp.publicKey).weight).toBe( 10, ); - }, 60000); + }, 120000); });