From 00b4cfc90b3410a8d03d312f850e39b186a3164e Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 31 Mar 2026 14:05:10 -0400 Subject: [PATCH 01/12] util/continued_fraction updated --- src/util/continued_fraction.ts | 45 +++++++++++++++++++++++ test/unit/util/continued_fraction.test.ts | 27 ++++++++++---- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/util/continued_fraction.ts b/src/util/continued_fraction.ts index 20b7ab70..d5d9ab62 100644 --- a/src/util/continued_fraction.ts +++ b/src/util/continued_fraction.ts @@ -1,6 +1,7 @@ import BigNumber from "./bignumber.js"; const MAX_INT = ((1 << 31) >>> 0) - 1; +const MAX_INT_BN = new BigNumber(MAX_INT); /** * Calculates and returns the best rational approximation of the given real @@ -55,6 +56,50 @@ export function best_r( const [n, d] = lastFraction; if (n.isZero() || d.isZero()) { + // Standard convergents produced a degenerate fraction (e.g. for values + // where 1/value > MAX_INT). Recover by computing a semi-convergent: find + // the largest coefficient that keeps both n and d within int32 bounds. + // Skip recovery for genuinely zero input — there is no valid approximation. + const input = new BigNumber(rawNumber); + + if (input.isZero()) { + throw new Error("Couldn't find approximation"); + } + + const prev1 = fractions[fractions.length - 1]; + const prev2 = fractions[fractions.length - 2]; + + if (prev1 && prev2) { + let aMax = MAX_INT_BN; + + if (prev1[0].gt(0)) { + aMax = BigNumber.min( + aMax, + MAX_INT_BN.minus(prev2[0]) + .div(prev1[0]) + .integerValue(BigNumber.ROUND_FLOOR), + ); + } + + if (prev1[1].gt(0)) { + aMax = BigNumber.min( + aMax, + MAX_INT_BN.minus(prev2[1]) + .div(prev1[1]) + .integerValue(BigNumber.ROUND_FLOOR), + ); + } + + if (aMax.gte(1)) { + const hn = aMax.times(prev1[0]).plus(prev2[0]); + const kn = aMax.times(prev1[1]).plus(prev2[1]); + + if (!hn.isZero() && !kn.isZero()) { + return [hn.toNumber(), kn.toNumber()]; + } + } + } + throw new Error("Couldn't find approximation"); } diff --git a/test/unit/util/continued_fraction.test.ts b/test/unit/util/continued_fraction.test.ts index dc2049a0..57f01376 100644 --- a/test/unit/util/continued_fraction.test.ts +++ b/test/unit/util/continued_fraction.test.ts @@ -24,7 +24,7 @@ describe("best_r", () => { ["4757,50", "95.14"], ["3729,5000", "0.74580"], ["4119,1", "4119.0"], - ["118,37", new BigNumber(118).div(37)] + ["118,37", new BigNumber(118).div(37)], ]; for (const [expected, input] of tests) { @@ -37,12 +37,23 @@ describe("best_r", () => { expect(best_r("-1.73").toString()).toBe("-173,100"); }); - it("throws an error when best rational approximation cannot be found", () => { - expect(() => best_r("0.0000000003")).toThrowError( - /Couldn't find approximation/ - ); - expect(() => best_r("2147483648")).toThrowError( - /Couldn't find approximation/ - ); + it("approximates values near int32 boundaries", () => { + // Very small value: best int32 approximation is 1/MAX_INT + expect(best_r("0.0000000003").toString()).toBe("1,2147483647"); + // Value just above MAX_INT: best int32 approximation is MAX_INT/1 + expect(best_r("2147483648").toString()).toBe("2147483647,1"); + }); + + it("round-trips XDR prices at int32 boundaries", () => { + // Regression: fromXDRPrice({n:1, d:2147483647}) produces a string like + // "4.6566128752457969e-10" which must survive best_r without throwing. + const BigNum = new BigNumber(1).div(new BigNumber(2147483647)); + const [n, d] = best_r(BigNum); + expect(n).toBe(1); + expect(d).toBe(2147483647); + }); + + it("throws an error for zero", () => { + expect(() => best_r("0")).toThrowError(/Couldn't find approximation/); }); }); From 552f6eb328ee185db30d6ee9409c6f221e17d4c3 Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 31 Mar 2026 14:27:38 -0400 Subject: [PATCH 02/12] muxed_account updated --- src/muxed_account.ts | 22 ++++++++++++++++++++++ test/unit/muxed_account.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/muxed_account.ts b/src/muxed_account.ts index f16b7ceb..c6b99a35 100644 --- a/src/muxed_account.ts +++ b/src/muxed_account.ts @@ -8,6 +8,24 @@ import { extractBaseAddress, } from "./util/decode_encode_muxed_account.js"; +const MAX_UINT64 = BigInt("18446744073709551615"); // 2^64 - 1 + +function validateUint64Id(id: string): void { + let value: bigint; + + try { + value = BigInt(id); + } catch { + throw new Error(`id is not a valid uint64 string: ${id}`); + } + + if (value < BigInt(0) || value > MAX_UINT64) { + throw new Error( + `id value out of range for uint64 [0, ${MAX_UINT64}]: ${id}`, + ); + } +} + /** * Represents a muxed account for transactions and operations. * @@ -57,6 +75,8 @@ export class MuxedAccount { throw new Error("accountId is invalid"); } + validateUint64Id(id); + this.account = baseAccount; this._muxedXdr = encodeMuxedAccount(accountId, id); this._mAddress = encodeMuxedAccountToAddress(this._muxedXdr); @@ -112,6 +132,8 @@ export class MuxedAccount { throw new Error("id should be a string representing a number (uint64)"); } + validateUint64Id(id); + this._muxedXdr.med25519().id(xdr.Uint64.fromString(id)); this._mAddress = encodeMuxedAccountToAddress(this._muxedXdr); this._id = id; diff --git a/test/unit/muxed_account.test.ts b/test/unit/muxed_account.test.ts index 139ce8a1..41071469 100644 --- a/test/unit/muxed_account.test.ts +++ b/test/unit/muxed_account.test.ts @@ -113,6 +113,29 @@ describe("MuxedAccount.setId (error cases)", () => { }); }); +describe("MuxedAccount uint64 overflow", () => { + // 2^64 = 18446744073709551616 (one above the max uint64 value) + const OVERFLOW_ID = "18446744073709551616"; + + it("rejects overflow in constructor", () => { + const base = new Account(PUBKEY, "0"); + expect(() => new MuxedAccount(base, OVERFLOW_ID)).toThrow(); + }); + + it("rejects overflow in setId", () => { + const base = new Account(PUBKEY, "0"); + const mux = new MuxedAccount(base, "0"); + expect(() => mux.setId(OVERFLOW_ID)).toThrow(); + }); + + it("accepts the maximum valid uint64 value", () => { + const MAX_UINT64 = "18446744073709551615"; + const base = new Account(PUBKEY, "0"); + const mux = new MuxedAccount(base, MAX_UINT64); + expect(mux.id()).toBe(MAX_UINT64); + }); +}); + describe("MuxedAccount.fromAddress (error cases)", () => { it("throws when given a G-address instead of an M-address", () => { expect(() => MuxedAccount.fromAddress(PUBKEY, "0")).toThrow(); From ad22db88f58a48f18ad4b68b43222b5808e5ec43 Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 31 Mar 2026 14:52:41 -0400 Subject: [PATCH 03/12] Added operation test --- test/unit/operation.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/unit/operation.test.ts b/test/unit/operation.test.ts index 4397e7e7..b524f175 100644 --- a/test/unit/operation.test.ts +++ b/test/unit/operation.test.ts @@ -154,4 +154,9 @@ describe("toXDRPrice()", () => { /price must be positive/, ); }); + + it("throws for a zero denominator", () => { + expect(() => toXDRPrice({ n: 1, d: 0 })).toThrow(/price must be positive/); + expect(() => toXDRPrice({ n: 0, d: 0 })).toThrow(/price must be positive/); + }); }); From 06d451ddb0ce8b1bf86b3684562841269840cf59 Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 31 Mar 2026 15:56:04 -0400 Subject: [PATCH 04/12] Add immutable tx flag --- src/fee_bump_transaction.ts | 7 +- src/transaction.ts | 6 +- src/transaction_base.ts | 26 ++++- src/transaction_builder.ts | 20 +++- test/unit/transaction.test.ts | 185 ++++++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 7 deletions(-) diff --git a/src/fee_bump_transaction.ts b/src/fee_bump_transaction.ts index bd02da5f..8693b9ec 100644 --- a/src/fee_bump_transaction.ts +++ b/src/fee_bump_transaction.ts @@ -22,10 +22,14 @@ export class FeeBumpTransaction extends TransactionBase * @param envelope - transaction envelope object or base64 encoded string. * @param networkPassphrase - passphrase of the target Stellar network * (e.g. "Public Global Stellar Network ; September 2015"). + * @param opts - additional options + * @param opts.immutableTx - when true, the `tx` getter returns a + * defensive copy so external code cannot mutate the signed transaction */ constructor( envelope: xdr.TransactionEnvelope | string, networkPassphrase: string, + opts?: { immutableTx?: boolean }, ) { if (typeof envelope === "string") { const buffer = Buffer.from(envelope, "base64"); @@ -46,7 +50,7 @@ export class FeeBumpTransaction extends TransactionBase // clone signatures const signatures = (txEnvelope.signatures() || []).slice(); - super(tx, signatures, fee, networkPassphrase); + super(tx, signatures, fee, networkPassphrase, opts?.immutableTx ?? false); const innerTxEnvelope = xdr.TransactionEnvelope.envelopeTypeTx( tx.innerTx().v1(), @@ -55,6 +59,7 @@ export class FeeBumpTransaction extends TransactionBase this._innerTransaction = new Transaction( innerTxEnvelope, networkPassphrase, + opts, ); } diff --git a/src/transaction.ts b/src/transaction.ts index 06758950..904e849d 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -41,10 +41,14 @@ export class Transaction extends TransactionBase< * @param envelope - transaction envelope object or base64 encoded string * @param networkPassphrase - passphrase of the target stellar network * (e.g. "Public Global Stellar Network ; September 2015") + * @param opts - additional options + * @param opts.immutableTx - when true, the `tx` getter returns a + * defensive copy so external code cannot mutate the signed transaction */ constructor( envelope: xdr.TransactionEnvelope | string, networkPassphrase: string, + opts?: { immutableTx?: boolean }, ) { if (typeof envelope === "string") { const buffer = Buffer.from(envelope, "base64"); @@ -70,7 +74,7 @@ export class Transaction extends TransactionBase< const fee = tx.fee().toString(); const signatures = (txEnvelope.signatures() || []).slice(); - super(tx, signatures, fee, networkPassphrase); + super(tx, signatures, fee, networkPassphrase, opts?.immutableTx ?? false); this._envelopeType = envelopeType; this._memo = tx.memo(); diff --git a/src/transaction_base.ts b/src/transaction_base.ts index abaea4d3..a339fde5 100644 --- a/src/transaction_base.ts +++ b/src/transaction_base.ts @@ -12,12 +12,14 @@ export class TransactionBase< private _signatures: xdr.DecoratedSignature[]; private _fee: string; private _networkPassphrase: string; + private _immutableTx: boolean; constructor( tx: TTx, signatures: xdr.DecoratedSignature[], fee: string, networkPassphrase: string, + immutableTx: boolean = false, ) { if (typeof networkPassphrase !== "string") { throw new Error( @@ -29,6 +31,7 @@ export class TransactionBase< this._tx = tx; this._signatures = signatures; this._fee = fee; + this._immutableTx = immutableTx; } /** The list of signatures for this transaction. */ @@ -40,8 +43,29 @@ export class TransactionBase< throw new Error("Transaction is immutable"); } - /** The underlying XDR transaction object. */ + /** + * The underlying XDR transaction object. + * + * When `immutableTx` is enabled, this returns a defensive copy so that + * external mutations cannot alter the transaction that will be signed or + * serialized. + */ get tx(): TTx { + if (this._immutableTx) { + const buf = this._tx.toXDR(); + + // Making sure we have the right type here, since the base class doesn't + // know which transaction type it is. + if (this._tx instanceof xdr.Transaction) { + return xdr.Transaction.fromXDR(buf) as TTx; + } + + if (this._tx instanceof xdr.TransactionV0) { + return xdr.TransactionV0.fromXDR(buf) as TTx; + } + + return xdr.FeeBumpTransaction.fromXDR(buf) as TTx; + } return this._tx; } diff --git a/src/transaction_builder.ts b/src/transaction_builder.ts index ef29c50b..9832635d 100644 --- a/src/transaction_builder.ts +++ b/src/transaction_builder.ts @@ -94,6 +94,12 @@ export interface TransactionBuilderOptions { * non-contract transactions. */ sorobanData?: xdr.SorobanTransactionData | string; + /** + * When true, the built transaction's `tx` getter returns a defensive copy + * so that external code cannot mutate the XDR that will be signed or + * serialized. Defaults to false for backwards compatibility. + */ + immutableTx?: boolean; } /** @@ -161,6 +167,7 @@ export class TransactionBuilder { memo: Memo; networkPassphrase: string | null; sorobanData: xdr.SorobanTransactionData | null; + immutableTx: boolean; /** * @param sourceAccount - source account for this transaction @@ -200,6 +207,7 @@ export class TransactionBuilder { this.sorobanData = opts.sorobanData ? new SorobanDataBuilder(opts.sorobanData).build() : null; + this.immutableTx = opts.immutableTx ?? false; } /** @@ -966,7 +974,9 @@ export class TransactionBuilder { throw new Error("networkPassphrase must be set to build a transaction"); } - const tx = new Transaction(txEnvelope, this.networkPassphrase); + const tx = new Transaction(txEnvelope, this.networkPassphrase, { + immutableTx: this.immutableTx, + }); this.source.incrementSequenceNumber(); @@ -1013,6 +1023,7 @@ export class TransactionBuilder { baseFee: string, innerTx: Transaction, networkPassphrase: string, + opts?: { immutableTx?: boolean }, ): FeeBumpTransaction { const innerOps = innerTx.operations.length; @@ -1106,7 +1117,7 @@ export class TransactionBuilder { const envelope = xdr.TransactionEnvelope.envelopeTypeTxFeeBump(feeBumpTxEnvelope); - return new FeeBumpTransaction(envelope, networkPassphrase); + return new FeeBumpTransaction(envelope, networkPassphrase, opts); } /** @@ -1122,16 +1133,17 @@ export class TransactionBuilder { static fromXDR( envelope: xdr.TransactionEnvelope | string, networkPassphrase: string, + opts?: { immutableTx?: boolean }, ): FeeBumpTransaction | Transaction { if (typeof envelope === "string") { envelope = xdr.TransactionEnvelope.fromXDR(envelope, "base64"); } if (envelope.switch() === xdr.EnvelopeType.envelopeTypeTxFeeBump()) { - return new FeeBumpTransaction(envelope, networkPassphrase); + return new FeeBumpTransaction(envelope, networkPassphrase, opts); } - return new Transaction(envelope, networkPassphrase); + return new Transaction(envelope, networkPassphrase, opts); } } diff --git a/test/unit/transaction.test.ts b/test/unit/transaction.test.ts index 3e35bc0b..b544c907 100644 --- a/test/unit/transaction.test.ts +++ b/test/unit/transaction.test.ts @@ -17,6 +17,7 @@ import { Claimant } from "../../src/claimant.js"; import { SignerKey } from "../../src/signerkey.js"; import { StrKey } from "../../src/strkey.js"; import { hash } from "../../src/hashing.js"; +import { PaymentResult } from "../../src/operations/types.js"; import xdr from "../../src/xdr.js"; function expectBuffersToBeEqual( @@ -221,6 +222,190 @@ describe("Transaction", () => { }); }); + describe("tx getter immutability", () => { + it("returns a defensive copy when immutableTx is enabled", () => { + const source = new Account( + "GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB", + "0", + ); + const tx = new TransactionBuilder(source, { + fee: "100", + networkPassphrase: Networks.TESTNET, + immutableTx: true, + }) + .addOperation( + Operation.payment({ + destination: + "GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2", + asset: Asset.native(), + amount: "10", + }), + ) + .setTimeout(TimeoutInfinite) + .build(); + + const hashBefore = tx.hash().toString("hex"); + + // Attempt to mutate the XDR via the tx getter — should have no effect + tx.tx.fee(999999); + + const hashAfter = tx.hash().toString("hex"); + expect(hashAfter).toBe(hashBefore); + }); + + it("signed transaction matches displayed fields when immutableTx is enabled", () => { + const kp = Keypair.random(); + const dest = Keypair.random(); + const source = new Account(kp.publicKey(), "0"); + + const tx = new TransactionBuilder(source, { + fee: "100", + networkPassphrase: Networks.TESTNET, + immutableTx: true, + }) + .addOperation( + Operation.payment({ + destination: dest.publicKey(), + asset: Asset.native(), + amount: "10", + }), + ) + .setTimeout(TimeoutInfinite) + .build(); + + // Mutate via the tx getter — should have no effect + tx.tx.fee(50000); + + // Sign and rebuild + tx.sign(kp); + const rebuilt = new Transaction(tx.toXDR(), Networks.TESTNET); + + // The serialized transaction must match the cached getter values + expect(rebuilt.fee).toBe(tx.fee); + const rebuiltOp = rebuilt.operations[0] as PaymentResult; + const originalOp = tx.operations[0] as PaymentResult; + expect(rebuiltOp.amount).toBe(originalOp.amount); + }); + + it("returns the live reference by default (immutableTx not set)", () => { + const source = new Account( + "GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB", + "0", + ); + const tx = new TransactionBuilder(source, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: + "GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2", + asset: Asset.native(), + amount: "10", + }), + ) + .setTimeout(TimeoutInfinite) + .build(); + + // Without immutableTx, tx getter returns the same reference + const ref1 = tx.tx; + const ref2 = tx.tx; + expect(ref1).toBe(ref2); + }); + + it("returns different copies on each access when immutableTx is enabled", () => { + const source = new Account( + "GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB", + "0", + ); + const tx = new TransactionBuilder(source, { + fee: "100", + networkPassphrase: Networks.TESTNET, + immutableTx: true, + }) + .addOperation( + Operation.payment({ + destination: + "GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2", + asset: Asset.native(), + amount: "10", + }), + ) + .setTimeout(TimeoutInfinite) + .build(); + + // Each access returns a fresh copy + const ref1 = tx.tx; + const ref2 = tx.tx; + expect(ref1).not.toBe(ref2); + // But they are equivalent + expect(ref1.fee()).toBe(ref2.fee()); + }); + + it("works with Transaction constructed from XDR", () => { + const kp = Keypair.random(); + const source = new Account(kp.publicKey(), "0"); + + const original = new TransactionBuilder(source, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: Keypair.random().publicKey(), + asset: Asset.native(), + amount: "10", + }), + ) + .setTimeout(TimeoutInfinite) + .build(); + + original.sign(kp); + const xdrString = original.toXDR(); + + // Reconstruct with immutableTx + const tx = new Transaction(xdrString, Networks.TESTNET, { + immutableTx: true, + }); + + const hashBefore = tx.hash().toString("hex"); + tx.tx.fee(999999); + const hashAfter = tx.hash().toString("hex"); + expect(hashAfter).toBe(hashBefore); + }); + + it("works with TransactionBuilder.fromXDR", () => { + const kp = Keypair.random(); + const source = new Account(kp.publicKey(), "0"); + + const original = new TransactionBuilder(source, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: Keypair.random().publicKey(), + asset: Asset.native(), + amount: "10", + }), + ) + .setTimeout(TimeoutInfinite) + .build(); + + original.sign(kp); + const xdrString = original.toXDR(); + + const tx = TransactionBuilder.fromXDR(xdrString, Networks.TESTNET, { + immutableTx: true, + }) as Transaction; + + const hashBefore = tx.hash().toString("hex"); + tx.tx.fee(999999); + const hashAfter = tx.hash().toString("hex"); + expect(hashAfter).toBe(hashBefore); + }); + }); + it("throws when a garbage Network is selected", () => { const source = new Account( "GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB", From 6904d284d37c01ea5b6ff321205043ef1baac520 Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 31 Mar 2026 16:32:44 -0400 Subject: [PATCH 05/12] auth updated --- src/auth.ts | 8 ++-- test/unit/auth.test.ts | 103 +++++++++++++++++++++++++++++++++++------ 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 1960572f..86393b9e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,6 +2,7 @@ import xdr from "./xdr.js"; import { Keypair } from "./keypair.js"; import { StrKey } from "./strkey.js"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars import { Networks } from "./network.js"; import { hash } from "./hashing.js"; @@ -123,7 +124,7 @@ export async function authorizeEntry( entry: xdr.SorobanAuthorizationEntry, signer: Keypair | SigningCallback, validUntilLedgerSeq: number, - networkPassphrase: string = Networks.TESTNET, + networkPassphrase: string, ): Promise { // no-op if it's source account auth if ( @@ -222,8 +223,7 @@ export async function authorizeEntry( * {@link Keypair} to `signer`, this can be omitted, as it just uses * {@link Keypair.publicKey}) * @param networkPassphrase - the network passphrase is incorporated into the - * signature (see {@link Networks} for options, default: - * {@link Networks.TESTNET}) + * signature (see {@link Networks} for options) * * @see authorizeEntry */ @@ -232,7 +232,7 @@ export function authorizeInvocation( validUntilLedgerSeq: number, invocation: xdr.SorobanAuthorizedInvocation, publicKey: string = "", - networkPassphrase: string = Networks.TESTNET, + networkPassphrase: string, ): Promise { // We use keypairs as a source of randomness for the nonce to avoid mucking // with any crypto dependencies. Note that this just has to be random and diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index fefa1ddd..3f1992ac 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -10,6 +10,7 @@ import { StrKey } from "../../src/strkey.js"; import { hash } from "../../src/hashing.js"; import { scValToNative } from "../../src/scval.js"; import { expectDefined } from "../support/expect_defined.js"; +import { Networks } from "../../src/network.js"; import xdr from "../../src/xdr.js"; describe("building authorization entries", () => { @@ -44,7 +45,12 @@ describe("building authorization entries", () => { describe("authorizeEntry", () => { it("signs the entry correctly with a Keypair", async () => { - const signedEntry = await authorizeEntry(authEntry, kp, 10); + const signedEntry = await authorizeEntry( + authEntry, + kp, + 10, + Networks.TESTNET, + ); expect(signedEntry.rootInvocation().toXDR()).toEqual( authEntry.rootInvocation().toXDR(), @@ -73,7 +79,12 @@ describe("building authorization entries", () => { const callback: SigningCallback = async (preimage) => kp.sign(hash(preimage.toXDR())); - const signedEntry = await authorizeEntry(authEntry, callback, 10); + const signedEntry = await authorizeEntry( + authEntry, + callback, + 10, + Networks.TESTNET, + ); const signedAddr = signedEntry.credentials().address(); expect(signedAddr.signatureExpirationLedger()).toBe(10); @@ -95,7 +106,12 @@ describe("building authorization entries", () => { publicKey: kp.publicKey(), }); - const signedEntry = await authorizeEntry(authEntry, callback, 10); + const signedEntry = await authorizeEntry( + authEntry, + callback, + 10, + Networks.TESTNET, + ); const signedAddr = signedEntry.credentials().address(); expect(signedAddr.signatureExpirationLedger()).toBe(10); @@ -117,7 +133,12 @@ describe("building authorization entries", () => { credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), }); - const result = await authorizeEntry(sourceAccountEntry, kp, 10); + const result = await authorizeEntry( + sourceAccountEntry, + kp, + 10, + Networks.TESTNET, + ); expect(result.toXDR()).toEqual(sourceAccountEntry.toXDR()); }); @@ -141,7 +162,12 @@ describe("building authorization entries", () => { ), }); - const signed = await authorizeEntry(entryForRandom, randomKp, 10); + const signed = await authorizeEntry( + entryForRandom, + randomKp, + 10, + Networks.TESTNET, + ); expect(signed.credentials().address().signatureExpirationLedger()).toBe( 10, ); @@ -157,20 +183,48 @@ describe("building authorization entries", () => { publicKey: kp.publicKey(), // claims to be kp but signed with wrongKp }); - await expect(authorizeEntry(authEntry, badCallback, 10)).rejects.toThrow( - /signature doesn't match payload/, - ); + await expect( + authorizeEntry(authEntry, badCallback, 10, Networks.TESTNET), + ).rejects.toThrow(/signature doesn't match payload/); }); it("throws with a bad signature from a callback", async () => { - const badCallback: SigningCallback = async (_preimage) => ({ + const badCallback: SigningCallback = async () => ({ signature: Buffer.from("bad-signature-data"), publicKey: kp.publicKey(), }); - await expect(authorizeEntry(authEntry, badCallback, 10)).rejects.toThrow( - /signature doesn't match payload/, + await expect( + authorizeEntry(authEntry, badCallback, 10, Networks.TESTNET), + ).rejects.toThrow(/signature doesn't match payload/); + }); + + it("produces different signatures for different networks", async () => { + const signedTestnet = await authorizeEntry( + authEntry, + kp, + 10, + Networks.TESTNET, ); + const signedPublic = await authorizeEntry( + authEntry, + kp, + 10, + Networks.PUBLIC, + ); + + const sigTestnet = signedTestnet + .credentials() + .address() + .signature() + .toXDR("hex"); + const sigPublic = signedPublic + .credentials() + .address() + .signature() + .toXDR("hex"); + + expect(sigTestnet).not.toBe(sigPublic); }); }); @@ -180,6 +234,8 @@ describe("building authorization entries", () => { kp, 10, authEntry.rootInvocation(), + "", + Networks.TESTNET, ); expect(signedEntry.rootInvocation().toXDR()).toEqual( @@ -204,6 +260,7 @@ describe("building authorization entries", () => { 10, authEntry.rootInvocation(), kp.publicKey(), + Networks.TESTNET, ); const signedAddr = signedEntry.credentials().address(); @@ -220,7 +277,13 @@ describe("building authorization entries", () => { // When called with a non-Keypair signer and no explicit publicKey, the // implementation throws Error("authorizeInvocation requires publicKey parameter"). expect(() => - authorizeInvocation(callback, 10, authEntry.rootInvocation()), + authorizeInvocation( + callback, + 10, + authEntry.rootInvocation(), + "", + Networks.TESTNET, + ), ).toThrow("authorizeInvocation requires publicKey parameter"); }); }); @@ -248,6 +311,8 @@ describe("building authorization entries", () => { kp, 10, authEntry.rootInvocation(), + "", + Networks.TESTNET, ); expect(entry.credentials().address().nonce().toBigInt()).toBe( 4294967296n, @@ -260,6 +325,8 @@ describe("building authorization entries", () => { kp, 10, authEntry.rootInvocation(), + "", + Networks.TESTNET, ); expect(entry.credentials().address().nonce().toBigInt()).toBe(-1n); }); @@ -270,6 +337,8 @@ describe("building authorization entries", () => { kp, 10, authEntry.rootInvocation(), + "", + Networks.TESTNET, ); expect(entry.credentials().address().nonce().toBigInt()).toBe( -9223372036854775808n, @@ -282,6 +351,8 @@ describe("building authorization entries", () => { kp, 10, authEntry.rootInvocation(), + "", + Networks.TESTNET, ); expect(entry.credentials().address().nonce().toBigInt()).toBe(0n); }); @@ -290,7 +361,13 @@ describe("building authorization entries", () => { stubRawBytes([0, 0, 0]); // only 3 bytes expect(() => - authorizeInvocation(kp, 10, authEntry.rootInvocation()), + authorizeInvocation( + kp, + 10, + authEntry.rootInvocation(), + "", + Networks.TESTNET, + ), ).toThrow(/need at least 8 bytes to convert to Int64, got 3/); }); }); From eb6ce903e32aa663787534c7a760fb3f84a8d087 Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 31 Mar 2026 17:14:40 -0400 Subject: [PATCH 06/12] scval updated --- src/scval.ts | 6 ++- test/unit/scval.test.ts | 97 ++++++++++++++++++++++++++++++++++------- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/src/scval.ts b/src/scval.ts index c24838c6..b5fe7170 100644 --- a/src/scval.ts +++ b/src/scval.ts @@ -220,7 +220,7 @@ export function nativeToScVal( ); } - if (val.constructor?.name !== "Object") { + if (Object.getPrototypeOf(val) !== Object.prototype) { throw new TypeError( `cannot interpret ${ val.constructor?.name @@ -240,7 +240,9 @@ export function nativeToScVal( // the type can be specified with an entry for the key and the value, // e.g. val = { 'hello': 1 } and opts.type = { hello: [ 'symbol', // 'u128' ]} or you can use `null` for the default interpretation - const [keyType, valType] = mapTypeSpec[k] ?? [null, null]; + const [keyType, valType] = Object.hasOwn(mapTypeSpec, k) + ? (mapTypeSpec[k] ?? [null, null]) + : [null, null]; const keyOpts: NativeToScValOpts = keyType ? { type: keyType } : {}; const valOpts: NativeToScValOpts = valType ? { type: valType } : {}; diff --git a/test/unit/scval.test.ts b/test/unit/scval.test.ts index 88a86876..ee1ebe2c 100644 --- a/test/unit/scval.test.ts +++ b/test/unit/scval.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ import { describe, it, expect } from "vitest"; import { @@ -69,7 +68,7 @@ describe("parsing and building ScVals - from scval_test.js", () => { ["u64", xdr.ScVal.scvU64(new xdr.Uint64(1))], [ "vec", - // eslint-disable-next-line @typescript-eslint/unbound-method + xdr.ScVal.scvVec(["same", "type", "list"].map(xdr.ScVal.scvString)), ], ["void", xdr.ScVal.scvVoid()], @@ -149,7 +148,7 @@ describe("parsing and building ScVals - from scval_test.js", () => { [xdr.ScVal.scvBool(false), xdr.ScVal.scvString("second")], [ xdr.ScVal.scvU32(2), - // eslint-disable-next-line @typescript-eslint/unbound-method + xdr.ScVal.scvVec(inputVec.map(xdr.ScVal.scvString)), ], ].map(([key, val]: any) => new xdr.ScMapEntry({ key, val })), @@ -1309,7 +1308,7 @@ describe("scValToNative", () => { val: xdr.ScVal.scvString("nope"), }), ]); - const result = scValToNative(scv) as any; + const result = scValToNative(scv); expect(result["1"]).toBe("one"); expect(result["false"]).toBe("nope"); }); @@ -1359,7 +1358,7 @@ describe("scValToNative", () => { const scv = xdr.ScVal.scvError( xdr.ScError.sceWasmVm(xdr.ScErrorCode.scecInvalidInput()), ); - const result = scValToNative(scv) as any; + const result = scValToNative(scv); expect(result.type).toBe("system"); expect(typeof result.code).toBe("number"); expect(typeof result.value).toBe("string"); @@ -1373,7 +1372,7 @@ describe("scValToNative", () => { ]; for (const code of codes) { const scv = xdr.ScVal.scvError(xdr.ScError.sceWasmVm(code)); - const result = scValToNative(scv) as any; + const result = scValToNative(scv); expect(result.type).toBe("system"); } }); @@ -1483,7 +1482,9 @@ describe("scvSortedMap", () => { const secondCopy = expectDefined(copy[1]); // Original array should be unchanged - expect(scValToNative(firstEntry.key())).toBe(scValToNative(firstCopy.key())); + expect(scValToNative(firstEntry.key())).toBe( + scValToNative(firstCopy.key()), + ); expect(scValToNative(secondEntry.key())).toBe( scValToNative(secondCopy.key()), ); @@ -1598,7 +1599,7 @@ describe("round-trip: nativeToScVal -> scValToNative", () => { it("round-trips simple objects", () => { const obj = { x: 1n, y: 2n }; const scv = nativeToScVal(obj); - const result = scValToNative(scv) as any; + const result = scValToNative(scv); // Keys become sorted, bigints stay as bigint expect(result.x).toBe(1n); expect(result.y).toBe(2n); @@ -1611,7 +1612,7 @@ describe("round-trip: nativeToScVal -> scValToNative", () => { flag: true, nothing: null, }; - const result = scValToNative(nativeToScVal(obj)) as any; + const result = scValToNative(nativeToScVal(obj)); expect(result.name).toBe("test"); expect(result.items).toEqual(["a", "b"]); expect(result.flag).toBe(true); @@ -1685,7 +1686,7 @@ describe("edge cases and stress tests", () => { val = { nested: val }; } const scv = nativeToScVal(val); - let result = scValToNative(scv) as any; + let result = scValToNative(scv); for (let i = 0; i < 10; i++) { expect(result).toHaveProperty("nested"); result = result.nested; @@ -1696,13 +1697,13 @@ describe("edge cases and stress tests", () => { it("handles deeply nested arrays", () => { const scv = nativeToScVal([[[1]], [[2]]]); - const result = scValToNative(scv) as any; + const result = scValToNative(scv); expect(result).toEqual([[[1n]], [[2n]]]); }); it("handles object with numeric string keys", () => { const scv = nativeToScVal({ "0": "a", "1": "b", "2": "c" }); - const result = scValToNative(scv) as any; + const result = scValToNative(scv); expect(result["0"]).toBe("a"); expect(result["1"]).toBe("b"); expect(result["2"]).toBe("c"); @@ -1713,7 +1714,7 @@ describe("edge cases and stress tests", () => { "key with spaces": true, "key-with-dashes": false, }); - const result = scValToNative(scv) as any; + const result = scValToNative(scv); expect(result["key with spaces"]).toBe(true); expect(result["key-with-dashes"]).toBe(false); }); @@ -1779,7 +1780,7 @@ describe("edge cases and stress tests", () => { val: xdr.ScVal.scvString("no"), }), ]); - const result = scValToNative(scv) as any; + const result = scValToNative(scv); expect(result["true"]).toBe("yes"); expect(result["false"]).toBe("no"); }); @@ -1862,7 +1863,7 @@ describe("edge cases and stress tests", () => { const scv = nativeToScVal(gigaMap); expect(scv.switch().name).toBe("scvMap"); - const result = scValToNative(scv) as any; + const result = scValToNative(scv); expect(result.bool).toBe(true); expect(result.void).toBe(null); expect(result.u64).toBe(1n); @@ -1974,3 +1975,69 @@ describe("edge cases and stress tests", () => { ); }); }); + +describe("nativeToScVal prototype pollution safety", () => { + describe("Object.prototype keys in map type spec lookup", () => { + it("should handle object with 'toString' key without type hints", () => { + const result = nativeToScVal({ toString: "hello" }); + const native = scValToNative(result); + expect(native).toEqual({ toString: "hello" }); + }); + + it("should handle object with 'hasOwnProperty' key without type hints", () => { + const result = nativeToScVal({ hasOwnProperty: "test" }); + const native = scValToNative(result); + expect(native).toEqual({ hasOwnProperty: "test" }); + }); + + it("should handle object with 'valueOf' key without type hints", () => { + const result = nativeToScVal({ valueOf: 42 }); + const native = scValToNative(result); + expect(native).toEqual({ valueOf: 42n }); // numbers roundtrip as bigint + }); + + it("should handle object with '__proto__' key without type hints", () => { + const result = nativeToScVal({ __proto__: "value" }); + const native = scValToNative(result); + expect(native).toEqual({ __proto__: "value" }); + }); + + it("should handle multiple prototype keys mixed with normal keys", () => { + const input = { + toString: "a", + normal: "b", + hasOwnProperty: "c", + }; + const result = nativeToScVal(input); + const native = scValToNative(result); + expect(native).toEqual(input); + }); + }); + + describe("val.constructor?.name check with 'constructor' key", () => { + it("should handle object with 'constructor' key (string value)", () => { + const result = nativeToScVal({ constructor: "test" }); + const native = scValToNative(result); + expect(native).toEqual({ constructor: "test" }); + }); + + it("should handle object with 'constructor' key (null value)", () => { + const result = nativeToScVal({ constructor: null }); + const native = scValToNative(result); + expect(native).toEqual({ constructor: null }); + }); + + it("should handle object with 'constructor' key (number value)", () => { + const result = nativeToScVal({ constructor: 42 }); + const native = scValToNative(result); + expect(native).toEqual({ constructor: 42n }); // numbers roundtrip as bigint + }); + + it("should handle object with 'constructor' key alongside normal keys", () => { + const input = { constructor: "foo", name: "bar", value: 123 }; + const result = nativeToScVal(input); + const native = scValToNative(result); + expect(native).toEqual({ constructor: "foo", name: "bar", value: 123n }); + }); + }); +}); From 4df96f192f8e6272e746b05df6934e51dcdd92d4 Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 31 Mar 2026 17:42:03 -0400 Subject: [PATCH 07/12] Added source acc test for getClaimableBalanceId() --- test/unit/transaction.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/unit/transaction.test.ts b/test/unit/transaction.test.ts index b544c907..fbdfeb9d 100644 --- a/test/unit/transaction.test.ts +++ b/test/unit/transaction.test.ts @@ -929,6 +929,31 @@ describe("Transaction", () => { ); }); + it("uses transaction source even when op has its own source", () => { + const gSource = new Account(address, "1234"); + const tx = makeBuilder(gSource) + .addOperation( + Operation.createClaimableBalance({ + asset: Asset.native(), + amount: "100", + claimants: [ + new Claimant(address, Claimant.predicateUnconditional()), + ], + source: Keypair.random().publicKey(), + }), + ) + .build(); + + // Per Stellar Core (mParentTx.getSourceID()), the balance ID is always + // derived from the transaction source, not the operation source. + // The expected hash is the same as the "calculates from transaction src" + // test because the tx source, sequence, and opIndex are identical. + const balanceId = tx.getClaimableBalanceId(0); + expect(balanceId).toBe( + "00000000536af35c666a28d26775008321655e9eda2039154270484e3f81d72c66d5c26f", + ); + }); + it("throws on invalid operations", () => { const gSource = new Account(address, "1234"); const tx = makeBuilder(gSource) From 0d3750e88eaa146178ad6faf476e8309ad0d4a6f Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 31 Mar 2026 18:00:50 -0400 Subject: [PATCH 08/12] Copilot feedback --- src/auth.ts | 3 +++ test/unit/transaction.test.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/auth.ts b/src/auth.ts index 86393b9e..184acd92 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -225,6 +225,9 @@ export async function authorizeEntry( * @param networkPassphrase - the network passphrase is incorporated into the * signature (see {@link Networks} for options) * + * @note `publicKey` appears before the required `networkPassphrase` for + * backwards compatibility. Reordering would be a breaking change. + * * @see authorizeEntry */ export function authorizeInvocation( diff --git a/test/unit/transaction.test.ts b/test/unit/transaction.test.ts index fbdfeb9d..d6c8d9ed 100644 --- a/test/unit/transaction.test.ts +++ b/test/unit/transaction.test.ts @@ -17,7 +17,7 @@ import { Claimant } from "../../src/claimant.js"; import { SignerKey } from "../../src/signerkey.js"; import { StrKey } from "../../src/strkey.js"; import { hash } from "../../src/hashing.js"; -import { PaymentResult } from "../../src/operations/types.js"; +import type { PaymentResult } from "../../src/operations/types.js"; import xdr from "../../src/xdr.js"; function expectBuffersToBeEqual( From b2fca1df8b6cd9f6848bebcc5a93fa83284c858e Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 09:21:24 -0400 Subject: [PATCH 09/12] Always return immutable tx, remove opt-in immutableTx flag --- src/fee_bump_transaction.ts | 7 +----- src/transaction.ts | 10 +++----- src/transaction_base.ts | 33 ++++++++++++------------ src/transaction_builder.ts | 20 +++------------ test/unit/transaction.test.ts | 47 ++++++----------------------------- 5 files changed, 32 insertions(+), 85 deletions(-) diff --git a/src/fee_bump_transaction.ts b/src/fee_bump_transaction.ts index 8693b9ec..bd02da5f 100644 --- a/src/fee_bump_transaction.ts +++ b/src/fee_bump_transaction.ts @@ -22,14 +22,10 @@ export class FeeBumpTransaction extends TransactionBase * @param envelope - transaction envelope object or base64 encoded string. * @param networkPassphrase - passphrase of the target Stellar network * (e.g. "Public Global Stellar Network ; September 2015"). - * @param opts - additional options - * @param opts.immutableTx - when true, the `tx` getter returns a - * defensive copy so external code cannot mutate the signed transaction */ constructor( envelope: xdr.TransactionEnvelope | string, networkPassphrase: string, - opts?: { immutableTx?: boolean }, ) { if (typeof envelope === "string") { const buffer = Buffer.from(envelope, "base64"); @@ -50,7 +46,7 @@ export class FeeBumpTransaction extends TransactionBase // clone signatures const signatures = (txEnvelope.signatures() || []).slice(); - super(tx, signatures, fee, networkPassphrase, opts?.immutableTx ?? false); + super(tx, signatures, fee, networkPassphrase); const innerTxEnvelope = xdr.TransactionEnvelope.envelopeTypeTx( tx.innerTx().v1(), @@ -59,7 +55,6 @@ export class FeeBumpTransaction extends TransactionBase this._innerTransaction = new Transaction( innerTxEnvelope, networkPassphrase, - opts, ); } diff --git a/src/transaction.ts b/src/transaction.ts index 904e849d..d914468a 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -41,14 +41,10 @@ export class Transaction extends TransactionBase< * @param envelope - transaction envelope object or base64 encoded string * @param networkPassphrase - passphrase of the target stellar network * (e.g. "Public Global Stellar Network ; September 2015") - * @param opts - additional options - * @param opts.immutableTx - when true, the `tx` getter returns a - * defensive copy so external code cannot mutate the signed transaction */ constructor( envelope: xdr.TransactionEnvelope | string, networkPassphrase: string, - opts?: { immutableTx?: boolean }, ) { if (typeof envelope === "string") { const buffer = Buffer.from(envelope, "base64"); @@ -74,7 +70,7 @@ export class Transaction extends TransactionBase< const fee = tx.fee().toString(); const signatures = (txEnvelope.signatures() || []).slice(); - super(tx, signatures, fee, networkPassphrase, opts?.immutableTx ?? false); + super(tx, signatures, fee, networkPassphrase); this._envelopeType = envelopeType; this._memo = tx.memo(); @@ -83,12 +79,12 @@ export class Transaction extends TransactionBase< switch (this._envelopeType) { case xdr.EnvelopeType.envelopeTypeTxV0(): this._source = StrKey.encodeEd25519PublicKey( - (this.tx as xdr.TransactionV0).sourceAccountEd25519(), + (tx as xdr.TransactionV0).sourceAccountEd25519(), ); break; default: this._source = encodeMuxedAccountToAddress( - (this.tx as xdr.Transaction).sourceAccount(), + (tx as xdr.Transaction).sourceAccount(), ); break; } diff --git a/src/transaction_base.ts b/src/transaction_base.ts index a339fde5..fe8c403f 100644 --- a/src/transaction_base.ts +++ b/src/transaction_base.ts @@ -12,14 +12,12 @@ export class TransactionBase< private _signatures: xdr.DecoratedSignature[]; private _fee: string; private _networkPassphrase: string; - private _immutableTx: boolean; constructor( tx: TTx, signatures: xdr.DecoratedSignature[], fee: string, networkPassphrase: string, - immutableTx: boolean = false, ) { if (typeof networkPassphrase !== "string") { throw new Error( @@ -31,7 +29,6 @@ export class TransactionBase< this._tx = tx; this._signatures = signatures; this._fee = fee; - this._immutableTx = immutableTx; } /** The list of signatures for this transaction. */ @@ -46,27 +43,29 @@ export class TransactionBase< /** * The underlying XDR transaction object. * - * When `immutableTx` is enabled, this returns a defensive copy so that - * external mutations cannot alter the transaction that will be signed or - * serialized. + * Returns a defensive copy so that external mutations cannot alter the + * transaction that will be signed or serialized. + * + * @throws {Error} if the internal transaction is not a recognized XDR type */ get tx(): TTx { - if (this._immutableTx) { - const buf = this._tx.toXDR(); + const buf = this._tx.toXDR(); - // Making sure we have the right type here, since the base class doesn't - // know which transaction type it is. - if (this._tx instanceof xdr.Transaction) { - return xdr.Transaction.fromXDR(buf) as TTx; - } + // Making sure we have the right type here, since the base class doesn't + // know which transaction type it is. + if (this._tx instanceof xdr.Transaction) { + return xdr.Transaction.fromXDR(buf) as TTx; + } - if (this._tx instanceof xdr.TransactionV0) { - return xdr.TransactionV0.fromXDR(buf) as TTx; - } + if (this._tx instanceof xdr.TransactionV0) { + return xdr.TransactionV0.fromXDR(buf) as TTx; + } + if (this._tx instanceof xdr.FeeBumpTransaction) { return xdr.FeeBumpTransaction.fromXDR(buf) as TTx; } - return this._tx; + + throw new Error("Unknown transaction type"); } set tx(_value: TTx) { diff --git a/src/transaction_builder.ts b/src/transaction_builder.ts index 9832635d..ef29c50b 100644 --- a/src/transaction_builder.ts +++ b/src/transaction_builder.ts @@ -94,12 +94,6 @@ export interface TransactionBuilderOptions { * non-contract transactions. */ sorobanData?: xdr.SorobanTransactionData | string; - /** - * When true, the built transaction's `tx` getter returns a defensive copy - * so that external code cannot mutate the XDR that will be signed or - * serialized. Defaults to false for backwards compatibility. - */ - immutableTx?: boolean; } /** @@ -167,7 +161,6 @@ export class TransactionBuilder { memo: Memo; networkPassphrase: string | null; sorobanData: xdr.SorobanTransactionData | null; - immutableTx: boolean; /** * @param sourceAccount - source account for this transaction @@ -207,7 +200,6 @@ export class TransactionBuilder { this.sorobanData = opts.sorobanData ? new SorobanDataBuilder(opts.sorobanData).build() : null; - this.immutableTx = opts.immutableTx ?? false; } /** @@ -974,9 +966,7 @@ export class TransactionBuilder { throw new Error("networkPassphrase must be set to build a transaction"); } - const tx = new Transaction(txEnvelope, this.networkPassphrase, { - immutableTx: this.immutableTx, - }); + const tx = new Transaction(txEnvelope, this.networkPassphrase); this.source.incrementSequenceNumber(); @@ -1023,7 +1013,6 @@ export class TransactionBuilder { baseFee: string, innerTx: Transaction, networkPassphrase: string, - opts?: { immutableTx?: boolean }, ): FeeBumpTransaction { const innerOps = innerTx.operations.length; @@ -1117,7 +1106,7 @@ export class TransactionBuilder { const envelope = xdr.TransactionEnvelope.envelopeTypeTxFeeBump(feeBumpTxEnvelope); - return new FeeBumpTransaction(envelope, networkPassphrase, opts); + return new FeeBumpTransaction(envelope, networkPassphrase); } /** @@ -1133,17 +1122,16 @@ export class TransactionBuilder { static fromXDR( envelope: xdr.TransactionEnvelope | string, networkPassphrase: string, - opts?: { immutableTx?: boolean }, ): FeeBumpTransaction | Transaction { if (typeof envelope === "string") { envelope = xdr.TransactionEnvelope.fromXDR(envelope, "base64"); } if (envelope.switch() === xdr.EnvelopeType.envelopeTypeTxFeeBump()) { - return new FeeBumpTransaction(envelope, networkPassphrase, opts); + return new FeeBumpTransaction(envelope, networkPassphrase); } - return new Transaction(envelope, networkPassphrase, opts); + return new Transaction(envelope, networkPassphrase); } } diff --git a/test/unit/transaction.test.ts b/test/unit/transaction.test.ts index d6c8d9ed..91c3ab1c 100644 --- a/test/unit/transaction.test.ts +++ b/test/unit/transaction.test.ts @@ -223,7 +223,7 @@ describe("Transaction", () => { }); describe("tx getter immutability", () => { - it("returns a defensive copy when immutableTx is enabled", () => { + it("returns a defensive copy", () => { const source = new Account( "GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB", "0", @@ -231,7 +231,6 @@ describe("Transaction", () => { const tx = new TransactionBuilder(source, { fee: "100", networkPassphrase: Networks.TESTNET, - immutableTx: true, }) .addOperation( Operation.payment({ @@ -253,7 +252,7 @@ describe("Transaction", () => { expect(hashAfter).toBe(hashBefore); }); - it("signed transaction matches displayed fields when immutableTx is enabled", () => { + it("signed transaction matches displayed fields", () => { const kp = Keypair.random(); const dest = Keypair.random(); const source = new Account(kp.publicKey(), "0"); @@ -261,7 +260,6 @@ describe("Transaction", () => { const tx = new TransactionBuilder(source, { fee: "100", networkPassphrase: Networks.TESTNET, - immutableTx: true, }) .addOperation( Operation.payment({ @@ -287,7 +285,7 @@ describe("Transaction", () => { expect(rebuiltOp.amount).toBe(originalOp.amount); }); - it("returns the live reference by default (immutableTx not set)", () => { + it("returns different copies on each access", () => { const source = new Account( "GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB", "0", @@ -307,33 +305,6 @@ describe("Transaction", () => { .setTimeout(TimeoutInfinite) .build(); - // Without immutableTx, tx getter returns the same reference - const ref1 = tx.tx; - const ref2 = tx.tx; - expect(ref1).toBe(ref2); - }); - - it("returns different copies on each access when immutableTx is enabled", () => { - const source = new Account( - "GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB", - "0", - ); - const tx = new TransactionBuilder(source, { - fee: "100", - networkPassphrase: Networks.TESTNET, - immutableTx: true, - }) - .addOperation( - Operation.payment({ - destination: - "GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2", - asset: Asset.native(), - amount: "10", - }), - ) - .setTimeout(TimeoutInfinite) - .build(); - // Each access returns a fresh copy const ref1 = tx.tx; const ref2 = tx.tx; @@ -363,10 +334,7 @@ describe("Transaction", () => { original.sign(kp); const xdrString = original.toXDR(); - // Reconstruct with immutableTx - const tx = new Transaction(xdrString, Networks.TESTNET, { - immutableTx: true, - }); + const tx = new Transaction(xdrString, Networks.TESTNET); const hashBefore = tx.hash().toString("hex"); tx.tx.fee(999999); @@ -395,9 +363,10 @@ describe("Transaction", () => { original.sign(kp); const xdrString = original.toXDR(); - const tx = TransactionBuilder.fromXDR(xdrString, Networks.TESTNET, { - immutableTx: true, - }) as Transaction; + const tx = TransactionBuilder.fromXDR( + xdrString, + Networks.TESTNET, + ) as Transaction; const hashBefore = tx.hash().toString("hex"); tx.tx.fee(999999); From dab2285b4b7b897b9b37087fa8bc8017dfc4ebad Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 14:54:45 -0400 Subject: [PATCH 10/12] Breaking: Change authorizeInvocation() from positional args to a params object --- src/auth.ts | 41 +++++++++------- test/unit/auth.test.ts | 105 +++++++++++++++++++---------------------- 2 files changed, 74 insertions(+), 72 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 184acd92..2e65aca1 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -209,34 +209,43 @@ export async function authorizeEntry( * * This is in contrast to {@link authorizeEntry}, which signs an existing entry. * - * @param signer - either a {@link Keypair} instance (or anything with a + * @param params - the parameters for building and signing the authorization + * @param params.signer - either a {@link Keypair} instance (or anything with a * `.sign(buf): Buffer-like` method) or a function which takes a payload (a * {@link xdr.HashIdPreimageSorobanAuthorization} instance) input and returns * the signature of the hash of the raw payload bytes (where the signing key * should correspond to the address in the `entry`) - * @param validUntilLedgerSeq - the (exclusive) future ledger sequence number - * until which this authorization entry should be valid (if + * @param params.validUntilLedgerSeq - the (exclusive) future ledger sequence + * number until which this authorization entry should be valid (if * `currentLedgerSeq==validUntilLedgerSeq`, this is expired) - * @param invocation - the invocation tree that we're authorizing (likely, this - * comes from transaction simulation) - * @param publicKey - the public identity of the signer (when providing a + * @param params.invocation - the invocation tree that we're authorizing + * (likely, this comes from transaction simulation) + * @param params.networkPassphrase - the network passphrase is incorporated into + * the signature (see {@link Networks} for options) + * @param params.publicKey - the public identity of the signer (when providing a * {@link Keypair} to `signer`, this can be omitted, as it just uses * {@link Keypair.publicKey}) - * @param networkPassphrase - the network passphrase is incorporated into the - * signature (see {@link Networks} for options) - * - * @note `publicKey` appears before the required `networkPassphrase` for - * backwards compatibility. Reordering would be a breaking change. * * @see authorizeEntry */ +export interface AuthorizeInvocationParams { + signer: Keypair | SigningCallback; + validUntilLedgerSeq: number; + invocation: xdr.SorobanAuthorizedInvocation; + networkPassphrase: string; + publicKey?: string; +} + export function authorizeInvocation( - signer: Keypair | SigningCallback, - validUntilLedgerSeq: number, - invocation: xdr.SorobanAuthorizedInvocation, - publicKey: string = "", - networkPassphrase: string, + params: AuthorizeInvocationParams, ): Promise { + const { + signer, + validUntilLedgerSeq, + invocation, + networkPassphrase, + publicKey = "", + } = params; // We use keypairs as a source of randomness for the nonce to avoid mucking // with any crypto dependencies. Note that this just has to be random and // unique, not cryptographically secure, so it's fine. diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 3f1992ac..19838391 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -230,13 +230,12 @@ describe("building authorization entries", () => { describe("authorizeInvocation", () => { it("can build from scratch with a Keypair", async () => { - const signedEntry = await authorizeInvocation( - kp, - 10, - authEntry.rootInvocation(), - "", - Networks.TESTNET, - ); + const signedEntry = await authorizeInvocation({ + signer: kp, + validUntilLedgerSeq: 10, + invocation: authEntry.rootInvocation(), + networkPassphrase: Networks.TESTNET, + }); expect(signedEntry.rootInvocation().toXDR()).toEqual( authEntry.rootInvocation().toXDR(), @@ -255,13 +254,13 @@ describe("building authorization entries", () => { publicKey: kp.publicKey(), }); - const signedEntry = await authorizeInvocation( - callback, - 10, - authEntry.rootInvocation(), - kp.publicKey(), - Networks.TESTNET, - ); + const signedEntry = await authorizeInvocation({ + signer: callback, + validUntilLedgerSeq: 10, + invocation: authEntry.rootInvocation(), + networkPassphrase: Networks.TESTNET, + publicKey: kp.publicKey(), + }); const signedAddr = signedEntry.credentials().address(); expect(signedAddr.signatureExpirationLedger()).toBe(10); @@ -277,13 +276,12 @@ describe("building authorization entries", () => { // When called with a non-Keypair signer and no explicit publicKey, the // implementation throws Error("authorizeInvocation requires publicKey parameter"). expect(() => - authorizeInvocation( - callback, - 10, - authEntry.rootInvocation(), - "", - Networks.TESTNET, - ), + authorizeInvocation({ + signer: callback, + validUntilLedgerSeq: 10, + invocation: authEntry.rootInvocation(), + networkPassphrase: Networks.TESTNET, + }), ).toThrow("authorizeInvocation requires publicKey parameter"); }); }); @@ -307,13 +305,12 @@ describe("building authorization entries", () => { // 0 instead of the correct 2^32. it("upper 4 bytes contribute to the nonce", async () => { stubRawBytes([0, 0, 0, 1, 0, 0, 0, 0]); - const entry = await authorizeInvocation( - kp, - 10, - authEntry.rootInvocation(), - "", - Networks.TESTNET, - ); + const entry = await authorizeInvocation({ + signer: kp, + validUntilLedgerSeq: 10, + invocation: authEntry.rootInvocation(), + networkPassphrase: Networks.TESTNET, + }); expect(entry.credentials().address().nonce().toBigInt()).toBe( 4294967296n, ); // 2^32 @@ -321,25 +318,23 @@ describe("building authorization entries", () => { it("all-0xFF bytes produce nonce -1 (signed Int64 all-bits-set)", async () => { stubRawBytes([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); - const entry = await authorizeInvocation( - kp, - 10, - authEntry.rootInvocation(), - "", - Networks.TESTNET, - ); + const entry = await authorizeInvocation({ + signer: kp, + validUntilLedgerSeq: 10, + invocation: authEntry.rootInvocation(), + networkPassphrase: Networks.TESTNET, + }); expect(entry.credentials().address().nonce().toBigInt()).toBe(-1n); }); it("high bit set produces Int64 minimum value", async () => { stubRawBytes([0x80, 0, 0, 0, 0, 0, 0, 0]); - const entry = await authorizeInvocation( - kp, - 10, - authEntry.rootInvocation(), - "", - Networks.TESTNET, - ); + const entry = await authorizeInvocation({ + signer: kp, + validUntilLedgerSeq: 10, + invocation: authEntry.rootInvocation(), + networkPassphrase: Networks.TESTNET, + }); expect(entry.credentials().address().nonce().toBigInt()).toBe( -9223372036854775808n, ); // -(2^63), Int64 minimum @@ -347,13 +342,12 @@ describe("building authorization entries", () => { it("all-zero bytes produce nonce 0", async () => { stubRawBytes([0, 0, 0, 0, 0, 0, 0, 0]); - const entry = await authorizeInvocation( - kp, - 10, - authEntry.rootInvocation(), - "", - Networks.TESTNET, - ); + const entry = await authorizeInvocation({ + signer: kp, + validUntilLedgerSeq: 10, + invocation: authEntry.rootInvocation(), + networkPassphrase: Networks.TESTNET, + }); expect(entry.credentials().address().nonce().toBigInt()).toBe(0n); }); @@ -361,13 +355,12 @@ describe("building authorization entries", () => { stubRawBytes([0, 0, 0]); // only 3 bytes expect(() => - authorizeInvocation( - kp, - 10, - authEntry.rootInvocation(), - "", - Networks.TESTNET, - ), + authorizeInvocation({ + signer: kp, + validUntilLedgerSeq: 10, + invocation: authEntry.rootInvocation(), + networkPassphrase: Networks.TESTNET, + }), ).toThrow(/need at least 8 bytes to convert to Int64, got 3/); }); }); From 64e014e7e8c0a7328bcd71d7ae79ccdb4349c8b7 Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 15:02:15 -0400 Subject: [PATCH 11/12] Updated changelog --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ddb234f..3030b35c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,38 @@ the type declarations to match the actual runtime property set by `buildInvocationTree`. TypeScript callers referencing `.token` should switch to `.asset`. +- `authorizeInvocation()` now takes a single `AuthorizeInvocationParams` + object instead of positional arguments. Callers must switch from + `authorizeInvocation(signer, validUntilLedgerSeq, invocation, publicKey, + networkPassphrase)` to + `authorizeInvocation({ signer, validUntilLedgerSeq, invocation, + networkPassphrase, publicKey })`. +- `authorizeEntry()` no longer defaults `networkPassphrase` to + `Networks.TESTNET`. Callers that relied on the default must now pass the + network passphrase explicitly. +- `TransactionBase.tx` now returns a defensive copy of the underlying XDR + object. External mutations on the returned value no longer affect the + transaction that will be signed or serialized. Code that relied on + mutating `tx` directly will need to be updated. + +### Fixed + +- `nativeToScVal` now uses `Object.getPrototypeOf(val) !== Object.prototype` + instead of `val.constructor?.name !== "Object"` to detect plain objects, + fixing false negatives for objects created across different realms or with + overridden `constructor` properties. +- `nativeToScVal` now uses `Object.hasOwn` when looking up per-key type + specs in map conversions, preventing inherited properties from the + prototype chain from being used as type hints. +- `best_r` (continued fraction approximation) no longer throws for small + numbers whose reciprocal exceeds `MAX_INT`. It now computes a + semi-convergent to recover a valid rational approximation. +- `MuxedAccount` constructor and `setId` now validate that the `id` string + represents a valid uint64 value (0 to 2^64 - 1), throwing immediately on + out-of-range or non-numeric input. +- `Transaction` constructor now reads `sourceAccountEd25519()` and + `sourceAccount()` from the local `tx` parameter instead of `this.tx`, + avoiding the overhead of a defensive copy during construction. ## [`v14.1.0`](https://github.com/stellar/js-stellar-base/compare/v14.0.4...v14.1.0): From c4e1bf496481558a08fe993443e8b379116f6a8f Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 15:51:00 -0400 Subject: [PATCH 12/12] Clean up changelog --- CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6adda811..7c2395ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,6 @@ StrKey strings. - `Transaction` no longer has generic type parameters ``. Code like `Transaction>` will no longer compile. -- The default `networkPassphrase` for `authorizeEntry` and - `authorizeInvocation` has changed from `Networks.FUTURENET` to - `Networks.TESTNET`. Callers omitting this argument will silently produce - signatures for a different network. - `Operation.isValidAmount()`, `Operation.constructAmountRequirementsError()`, and `Operation.setSourceAccount()` have been removed from the runtime `Operation` class. They now exist only as internal standalone functions in operations.ts and are not re-exported. These methods were never part of the published TypeScript declarations, but JavaScript callers could still access them before. - The revoke sponsorship operation `type` field has been split from a single `"revokeSponsorship"` into 7 specific strings: `"revokeAccountSponsorship"`, @@ -84,10 +80,6 @@ `parseFloat()` to `Number()`. Strings with trailing non-numeric characters (e.g., `"123abc"`) that previously silently succeeded will now be rejected. -- `CreateInvocation.token` has been renamed to `CreateInvocation.asset` in - the type declarations to match the actual runtime property set by - `buildInvocationTree`. TypeScript callers referencing `.token` should - switch to `.asset`. - `authorizeInvocation()` now takes a single `AuthorizeInvocationParams` object instead of positional arguments. Callers must switch from `authorizeInvocation(signer, validUntilLedgerSeq, invocation, publicKey,