From 00b4cfc90b3410a8d03d312f850e39b186a3164e Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 31 Mar 2026 14:05:10 -0400 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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 be746400ac851412a4fc2726dda47b0ab64ad694 Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 1 Apr 2026 13:22:02 -0400 Subject: [PATCH 09/23] operation + revoke_sponsorship updated --- src/operation.ts | 8 ++ src/operations/revoke_sponsorship.ts | 13 ++++ src/operations/types.ts | 1 + .../operations/revoke_sponsorship.test.ts | 73 +++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/src/operation.ts b/src/operation.ts index b105689a..e15dd817 100644 --- a/src/operation.ts +++ b/src/operation.ts @@ -575,6 +575,14 @@ function convertXDRSignerKeyToObject( attrs.sha256Hash = signerKey.hashX().toString("hex"); break; } + case xdr.SignerKeyType.signerKeyTypeEd25519SignedPayload().name: { + const signedPayload = signerKey.ed25519SignedPayload(); + + attrs.ed25519SignedPayload = StrKey.encodeSignedPayload( + signedPayload.toXDR(), + ); + break; + } default: { throw new Error(`Unknown signerKey: ${signerKey.switch().name}`); } diff --git a/src/operations/revoke_sponsorship.ts b/src/operations/revoke_sponsorship.ts index d15a7ffb..8481cc2b 100644 --- a/src/operations/revoke_sponsorship.ts +++ b/src/operations/revoke_sponsorship.ts @@ -327,6 +327,19 @@ export function revokeSignerSponsorship( } key = xdr.SignerKey.signerKeyTypeHashX(buffer); + } else if (opts.signer.ed25519SignedPayload) { + if (!StrKey.isValidSignedPayload(opts.signer.ed25519SignedPayload)) { + throw new Error("signer.ed25519SignedPayload is invalid."); + } + + const rawPayload = StrKey.decodeSignedPayload( + opts.signer.ed25519SignedPayload, + ); + + const signedPayloadXdr = + xdr.SignerKeyEd25519SignedPayload.fromXDR(rawPayload); + + key = xdr.SignerKey.signerKeyTypeEd25519SignedPayload(signedPayloadXdr); } else { throw new Error("signer is invalid"); } diff --git a/src/operations/types.ts b/src/operations/types.ts index 5ffcbb00..b4b02d2c 100644 --- a/src/operations/types.ts +++ b/src/operations/types.ts @@ -219,6 +219,7 @@ export interface RevokeLiquidityPoolSponsorshipOpts { export type RevokeSignerOpts = | Ed25519PublicKeySignerOpt + | Ed25519SignedPayloadSignerOpt | PreAuthTxSignerOpt | Sha256HashSignerOpt; diff --git a/test/unit/operations/revoke_sponsorship.test.ts b/test/unit/operations/revoke_sponsorship.test.ts index 051b1707..ec850158 100644 --- a/test/unit/operations/revoke_sponsorship.test.ts +++ b/test/unit/operations/revoke_sponsorship.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { Operation } from "../../../src/operation.js"; import { Asset } from "../../../src/asset.js"; import { LiquidityPoolId } from "../../../src/liquidity_pool_id.js"; +import { Keypair } from "../../../src/keypair.js"; +import { StrKey } from "../../../src/strkey.js"; import { hash } from "../../../src/hashing.js"; import xdr from "../../../src/xdr.js"; import { @@ -428,4 +430,75 @@ describe("Operation.revokeSignerSponsorship()", () => { const roundtripped = xdr.Operation.fromXDR(hex, "hex"); expect(roundtripped.body().switch().name).toBe("revokeSponsorship"); }); + + it("deserializes a revokeSignerSponsorship with an ed25519SignedPayload signer", () => { + // Build the XDR operation manually to test the deserialization path + const kp = Keypair.random(); + const payload = Buffer.alloc(10, 0xab); + const signedPayload = new xdr.SignerKeyEd25519SignedPayload({ + ed25519: kp.rawPublicKey(), + payload, + }); + const signerKey = + xdr.SignerKey.signerKeyTypeEd25519SignedPayload(signedPayload); + + const revokeSigner = new xdr.RevokeSponsorshipOpSigner({ + accountId: kp.xdrAccountId(), + signerKey, + }); + const revokeOp = + xdr.RevokeSponsorshipOp.revokeSponsorshipSigner(revokeSigner); + const opBody = xdr.OperationBody.revokeSponsorship(revokeOp); + const xdrOp = new xdr.Operation({ sourceAccount: null, body: opBody }); + + const obj = expectOperationType( + Operation.fromXDRObject(xdrOp), + "revokeSignerSponsorship", + ); + const decodedSigner = expectObjectWithProperty( + obj.signer, + "ed25519SignedPayload", + ); + + // The encoded signed payload should be a valid StrKey P... address + expect( + StrKey.isValidSignedPayload(decodedSigner.ed25519SignedPayload), + ).toBe(true); + }); + + it("creates a revokeSignerSponsorshipOp with an ed25519SignedPayload signer", () => { + // Build a valid signed payload StrKey from a keypair + payload + const kp = Keypair.random(); + const payload = Buffer.alloc(10, 0xab); + const signedPayloadXdr = new xdr.SignerKeyEd25519SignedPayload({ + ed25519: kp.rawPublicKey(), + payload, + }); + const encodedPayload = StrKey.encodeSignedPayload(signedPayloadXdr.toXDR()); + + const signer = { ed25519SignedPayload: encodedPayload }; + const op = Operation.revokeSignerSponsorship({ account, signer }); + const operation = xdr.Operation.fromXDR(op.toXDR("hex"), "hex"); + const obj = expectOperationType( + Operation.fromXDRObject(operation), + "revokeSignerSponsorship", + ); + const decodedSigner = expectObjectWithProperty( + obj.signer, + "ed25519SignedPayload", + ); + + expect(operation.body().switch().name).toBe("revokeSponsorship"); + expect(obj.account).toBe(account); + expect(decodedSigner.ed25519SignedPayload).toBe(encodedPayload); + }); + + it("fails with an invalid ed25519SignedPayload signer", () => { + expect(() => + Operation.revokeSignerSponsorship({ + account, + signer: { ed25519SignedPayload: "PBAD" }, + }), + ).toThrow(/signer\.ed25519SignedPayload is invalid/); + }); }); From b9744b3b52d5328b3180ea18fd01ccae3b9a0d30 Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 1 Apr 2026 13:38:30 -0400 Subject: [PATCH 10/23] transaction_builder updated --- src/transaction_builder.ts | 25 ++++++++++++--- test/unit/transaction_builder.test.ts | 46 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/transaction_builder.ts b/src/transaction_builder.ts index 9832635d..57667031 100644 --- a/src/transaction_builder.ts +++ b/src/transaction_builder.ts @@ -5,7 +5,10 @@ import xdr from "./xdr.js"; import { Account } from "./account.js"; import { MuxedAccount } from "./muxed_account.js"; -import { decodeAddressToMuxedAccount } from "./util/decode_encode_muxed_account.js"; +import { + decodeAddressToMuxedAccount, + extractBaseAddress, +} from "./util/decode_encode_muxed_account.js"; import { Transaction } from "./transaction.js"; import { FeeBumpTransaction } from "./fee_bump_transaction.js"; @@ -704,7 +707,15 @@ export class TransactionBuilder { } } - if (destination === this.source.accountId()) { + // Resolve M... muxed addresses to their underlying G... address for + // ledger key construction (Keypair.fromPublicKey only accepts G... keys). + const destinationBaseAddress = isDestinationContract + ? destination + : extractBaseAddress(destination); + + if ( + destinationBaseAddress === extractBaseAddress(this.source.accountId()) + ) { throw new Error("Destination cannot be the same as the source account."); } @@ -786,15 +797,19 @@ export class TransactionBuilder { footprint.readWrite().push( xdr.LedgerKey.account( new xdr.LedgerKeyAccount({ - accountId: Keypair.fromPublicKey(destination).xdrPublicKey(), + accountId: Keypair.fromPublicKey( + destinationBaseAddress, + ).xdrPublicKey(), }), ), ); - } else if (asset.getIssuer() !== destination) { + } else if (asset.getIssuer() !== destinationBaseAddress) { footprint.readWrite().push( xdr.LedgerKey.trustline( new xdr.LedgerKeyTrustLine({ - accountId: Keypair.fromPublicKey(destination).xdrPublicKey(), + accountId: Keypair.fromPublicKey( + destinationBaseAddress, + ).xdrPublicKey(), asset: asset.toTrustLineXDRObject(), }), ), diff --git a/test/unit/transaction_builder.test.ts b/test/unit/transaction_builder.test.ts index a330f63d..e5ab9395 100644 --- a/test/unit/transaction_builder.test.ts +++ b/test/unit/transaction_builder.test.ts @@ -2255,4 +2255,50 @@ describe("addSacTransferOperation with invalid destination", () => { ); }).toThrow("networkPassphrase must be set to add a SAC transfer operation"); }); + + it("succeeds with a muxed (M...) destination for native asset transfer", () => { + const destKp = Keypair.random(); + const muxedDest = StrKey.encodeMed25519PublicKey( + Buffer.concat([ + Buffer.alloc(8), + StrKey.decodeEd25519PublicKey(destKp.publicKey()), + ]), + ); + + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + }) + .addSacTransferOperation(muxedDest, Asset.native(), "10000000") + .setTimeout(TimeoutInfinite) + .build(); + }).not.toThrow(); + }); + + it("succeeds with a muxed (M...) destination for non-native asset transfer", () => { + const destKp = Keypair.random(); + const muxedDest = StrKey.encodeMed25519PublicKey( + Buffer.concat([ + Buffer.alloc(8), + StrKey.decodeEd25519PublicKey(destKp.publicKey()), + ]), + ); + const asset = new Asset( + "USDC", + "GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2", + ); + + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + }) + .addSacTransferOperation(muxedDest, asset, "10000000") + .setTimeout(TimeoutInfinite) + .build(); + }).not.toThrow(); + }); }); From d2ec4ba3223e99f9627440dd2327523265fd1706 Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 1 Apr 2026 14:43:16 -0400 Subject: [PATCH 11/23] soroban updated --- src/soroban.ts | 8 ++++++-- test/unit/soroban.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/soroban.ts b/src/soroban.ts index 3fb5b321..dfed87d3 100644 --- a/src/soroban.ts +++ b/src/soroban.ts @@ -21,7 +21,9 @@ export class Soroban { throw new TypeError("No decimals are allowed"); } - let formatted = amount; + const negative = amount.startsWith("-"); + let formatted = negative ? amount.slice(1) : amount; + if (decimals > 0) { if (decimals > formatted.length) { formatted = ["0", formatted.toString().padStart(decimals, "0")].join( @@ -35,10 +37,12 @@ export class Soroban { } } - return formatted + formatted = formatted .replace(/(\.\d*?)0+$/, "$1") // strip trailing zeroes .replace(/\.$/, ".0") // but keep at least one .replace(/^\./, "0."); // and a leading one + + return negative ? `-${formatted}` : formatted; } /** diff --git a/test/unit/soroban.test.ts b/test/unit/soroban.test.ts index 11a7184e..f0e4aca5 100644 --- a/test/unit/soroban.test.ts +++ b/test/unit/soroban.test.ts @@ -52,6 +52,13 @@ describe("Soroban", () => { it("handles negative amounts", () => { expect(Soroban.formatTokenAmount("-1000", 3)).toBe("-1.0"); }); + + it("handles negative amounts requiring zero-padding", () => { + expect(Soroban.formatTokenAmount("-1", 3)).toBe("-0.001"); + expect(Soroban.formatTokenAmount("-1", 1)).toBe("-0.1"); + expect(Soroban.formatTokenAmount("-123", 4)).toBe("-0.0123"); + expect(Soroban.formatTokenAmount("-123", 5)).toBe("-0.00123"); + }); }); describe("parseTokenAmount", () => { From c8d64951e98ac0792cafe74f31ef495f4557f65c Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 1 Apr 2026 15:09:27 -0400 Subject: [PATCH 12/23] Fix locale-dependent map key sorting in scvSortedMap and nativeToScVal --- src/scval.ts | 9 ++++++--- test/unit/scval.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/scval.ts b/src/scval.ts index b5fe7170..f9d1c57c 100644 --- a/src/scval.ts +++ b/src/scval.ts @@ -235,7 +235,7 @@ export function nativeToScVal( // The Soroban runtime expects maps to have their keys in sorted // order, so let's do that here as part of the conversion to prevent // confusing error messages on execution. - .sort(([key1], [key2]) => key1.localeCompare(key2)) + .sort(([key1], [key2]) => (key1 < key2 ? -1 : key1 > key2 ? 1 : 0)) .map(([k, v]) => { // the type can be specified with an entry for the key and the value, // e.g. val = { 'hello': 1 } and opts.type = { hello: [ 'symbol', @@ -477,8 +477,11 @@ export function scvSortedMap(items: xdr.ScMapEntry[]): xdr.ScVal { if (nativeA === nativeB) return 0; return nativeA < (nativeB as bigint | number) ? -1 : 1; - default: - return nativeA.toString().localeCompare(nativeB.toString()); + default: { + const strA = nativeA.toString(); + const strB = nativeB.toString(); + return strA < strB ? -1 : strA > strB ? 1 : 0; + } } }); diff --git a/test/unit/scval.test.ts b/test/unit/scval.test.ts index ee1ebe2c..6a6177d8 100644 --- a/test/unit/scval.test.ts +++ b/test/unit/scval.test.ts @@ -958,6 +958,17 @@ describe("nativeToScVal", () => { expect(entries[2].key().value()).toBe("z"); }); + it("sorts mixed-case keys by codepoint order, not locale order", () => { + // Codepoint order: 'B' (66) < '_' (95) < 'a' (97) + // localeCompare would give: _key, admin, Balance (case-insensitive) + // Correct byte order: Balance, _key, admin + const scv = nativeToScVal({ admin: 1, _key: 2, Balance: 3 }); + const entries = scv.value() as any[]; + expect(entries[0].key().value()).toBe("Balance"); + expect(entries[1].key().value()).toBe("_key"); + expect(entries[2].key().value()).toBe("admin"); + }); + it("handles empty object", () => { const scv = nativeToScVal({}); expect(scv.switch().name).toBe("scvMap"); @@ -1562,6 +1573,31 @@ describe("scvSortedMap", () => { expect(result[0].key().value()).toBe("a"); expect(result[1].key().value()).toBe("b"); }); + + it("sorts mixed-case string keys by codepoint order, not locale order", () => { + // Codepoint order: 'A' (65) < 'I' (73) < '_' (95) < 'a' (97) < 'i' (105) + // localeCompare would sort case-insensitively: _admin, Admin, balance + // Correct byte order: Admin, _admin, balance + const entries = [ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("balance"), + val: xdr.ScVal.scvU32(3), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("Admin"), + val: xdr.ScVal.scvU32(1), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("_admin"), + val: xdr.ScVal.scvU32(2), + }), + ]; + const sorted = scvSortedMap(entries); + const result = sorted.value() as any[]; + expect(result[0].key().value()).toBe("Admin"); + expect(result[1].key().value()).toBe("_admin"); + expect(result[2].key().value()).toBe("balance"); + }); }); // --------------------------------------------------------------------------- From 008caed9419d665b570b2a99b04967c7b1d00fe4 Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 1 Apr 2026 15:27:18 -0400 Subject: [PATCH 13/23] Reject non-boolean trustline flag values in setTrustLineFlags --- src/operations/set_trustline_flags.ts | 6 ++++ .../operations/set_trustline_flags.test.ts | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/operations/set_trustline_flags.ts b/src/operations/set_trustline_flags.ts index 602d845d..bd742403 100644 --- a/src/operations/set_trustline_flags.ts +++ b/src/operations/set_trustline_flags.ts @@ -65,6 +65,12 @@ export function setTrustLineFlags( throw new Error(`Invalid flag name: ${flagName}`); } + if (typeof flagValue !== "boolean" && typeof flagValue !== "undefined") { + throw new TypeError( + `opts.flags.${flagName} must be a boolean (got ${typeof flagValue})`, + ); + } + if (flagValue === true) { setFlag |= bit.value; } else if (flagValue === false) { diff --git a/test/unit/operations/set_trustline_flags.test.ts b/test/unit/operations/set_trustline_flags.test.ts index a272df83..282de1c4 100644 --- a/test/unit/operations/set_trustline_flags.test.ts +++ b/test/unit/operations/set_trustline_flags.test.ts @@ -139,6 +139,36 @@ describe("Operation.setTrustLineFlags()", () => { ).toThrow(/Source address is invalid/); }); + it("throws when flag value is truthy but not boolean true", () => { + expect(() => + Operation.setTrustLineFlags({ + trustor: account, + asset, + flags: { authorized: 1 } as unknown as { authorized?: boolean }, + }), + ).toThrow(); + }); + + it("throws when flag value is falsy but not boolean false", () => { + expect(() => + Operation.setTrustLineFlags({ + trustor: account, + asset, + flags: { authorized: 0 } as unknown as { authorized?: boolean }, + }), + ).toThrow(); + }); + + it("throws when flag value is a string", () => { + expect(() => + Operation.setTrustLineFlags({ + trustor: account, + asset, + flags: { authorized: "true" } as unknown as { authorized?: boolean }, + }), + ).toThrow(); + }); + it("roundtrips through XDR hex encoding", () => { const op = Operation.setTrustLineFlags({ trustor: account, From 3250551e2edfacdb53fbb7bb614db1db14a23726 Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 1 Apr 2026 16:39:55 -0400 Subject: [PATCH 14/23] Copilot feedback --- src/operations/revoke_sponsorship.ts | 1 + src/transaction_builder.ts | 7 ++++--- test/unit/transaction_builder.test.ts | 28 +++++++++++++++++++++++++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/operations/revoke_sponsorship.ts b/src/operations/revoke_sponsorship.ts index 8481cc2b..82530ee8 100644 --- a/src/operations/revoke_sponsorship.ts +++ b/src/operations/revoke_sponsorship.ts @@ -274,6 +274,7 @@ export function revokeLiquidityPoolSponsorship( * @param opts.signer.ed25519PublicKey - (optional) The ed25519 public key of the signer. * @param opts.signer.sha256Hash - (optional) sha256 hash (Buffer or hex string). * @param opts.signer.preAuthTx - (optional) Hash (Buffer or hex string) of transaction. + * @param opts.signer.ed25519SignedPayload - (optional) Signed payload signer (StrKey P... address). * @param opts.source - The source account for the operation. Defaults to the transaction's source account. * * @example diff --git a/src/transaction_builder.ts b/src/transaction_builder.ts index 57667031..dc4fe426 100644 --- a/src/transaction_builder.ts +++ b/src/transaction_builder.ts @@ -728,6 +728,7 @@ export class TransactionBuilder { const contractId = asset.contractId(this.networkPassphrase); const functionName = "transfer"; const source = this.source.accountId(); + const sourceBaseAddress = extractBaseAddress(source); const args = [ nativeToScVal(source, { type: "address" }), nativeToScVal(destination, { type: "address" }), @@ -821,15 +822,15 @@ export class TransactionBuilder { footprint.readWrite().push( xdr.LedgerKey.account( new xdr.LedgerKeyAccount({ - accountId: Keypair.fromPublicKey(source).xdrPublicKey(), + accountId: Keypair.fromPublicKey(sourceBaseAddress).xdrPublicKey(), }), ), ); - } else if (asset.getIssuer() !== source) { + } else if (asset.getIssuer() !== sourceBaseAddress) { footprint.readWrite().push( xdr.LedgerKey.trustline( new xdr.LedgerKeyTrustLine({ - accountId: Keypair.fromPublicKey(source).xdrPublicKey(), + accountId: Keypair.fromPublicKey(sourceBaseAddress).xdrPublicKey(), asset: asset.toTrustLineXDRObject(), }), ), diff --git a/test/unit/transaction_builder.test.ts b/test/unit/transaction_builder.test.ts index e5ab9395..71f1bc27 100644 --- a/test/unit/transaction_builder.test.ts +++ b/test/unit/transaction_builder.test.ts @@ -2260,8 +2260,8 @@ describe("addSacTransferOperation with invalid destination", () => { const destKp = Keypair.random(); const muxedDest = StrKey.encodeMed25519PublicKey( Buffer.concat([ - Buffer.alloc(8), StrKey.decodeEd25519PublicKey(destKp.publicKey()), + Buffer.alloc(8), ]), ); @@ -2281,8 +2281,8 @@ describe("addSacTransferOperation with invalid destination", () => { const destKp = Keypair.random(); const muxedDest = StrKey.encodeMed25519PublicKey( Buffer.concat([ - Buffer.alloc(8), StrKey.decodeEd25519PublicKey(destKp.publicKey()), + Buffer.alloc(8), ]), ); const asset = new Asset( @@ -2301,4 +2301,28 @@ describe("addSacTransferOperation with invalid destination", () => { .build(); }).not.toThrow(); }); + + it("succeeds with a MuxedAccount source for native asset transfer", () => { + const muxedSource = MuxedAccount.fromAddress( + StrKey.encodeMed25519PublicKey( + Buffer.concat([ + StrKey.decodeEd25519PublicKey(source.accountId()), + Buffer.alloc(8), + ]), + ), + source.sequenceNumber(), + ); + const destKp = Keypair.random(); + + expect(() => { + new TransactionBuilder(muxedSource, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + }) + .addSacTransferOperation(destKp.publicKey(), Asset.native(), "10000000") + .setTimeout(TimeoutInfinite) + .build(); + }).not.toThrow(); + }); }); From e4ed91ec2efb5f92fb7beb6941c75b238619794f Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 1 Apr 2026 17:03:02 -0400 Subject: [PATCH 15/23] Fix negative BigInt bit-length off-by-one in ScInt type auto-selection --- src/numbers/sc_int.ts | 9 +++++---- test/unit/numbers/sc_int.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/numbers/sc_int.ts b/src/numbers/sc_int.ts index 63ae215c..9650e016 100644 --- a/src/numbers/sc_int.ts +++ b/src/numbers/sc_int.ts @@ -108,9 +108,10 @@ export class ScInt extends XdrLargeInt { } function nearestBigIntSize(bigI: bigint): number { - // Note: Even though BigInt.toString(2) includes the negative sign for - // negative values (???), the following is still accurate, because the - // negative sign would be represented by a sign bit. - const bitlen = bigI.toString(2).length; + // Use the absolute value because BigInt.toString(2) prepends a "-" for + // negative values, which would inflate the length by 1 and misclassify + // exact boundary values like -(2^63), -(2^127), and -(2^255). + const abs = bigI < 0n ? -bigI : bigI; + const bitlen = abs.toString(2).length; return [64, 128, 256].find((len) => bitlen <= len) ?? bitlen; } diff --git a/test/unit/numbers/sc_int.test.ts b/test/unit/numbers/sc_int.test.ts index 0b1a8e01..7b482749 100644 --- a/test/unit/numbers/sc_int.test.ts +++ b/test/unit/numbers/sc_int.test.ts @@ -161,6 +161,24 @@ describe("ScInt", () => { expect(sci.type).toBe("i64"); }); + it("selects i64 for the exact i64 minimum (-(2^63))", () => { + const min = -(2n ** 63n); + const sci = new ScInt(min); + expect(sci.type).toBe("i64"); + }); + + it("selects i128 for the exact i128 minimum (-(2^127))", () => { + const min = -(2n ** 127n); + const sci = new ScInt(min); + expect(sci.type).toBe("i128"); + }); + + it("selects i256 for the exact i256 minimum (-(2^255))", () => { + const min = -(2n ** 255n); + const sci = new ScInt(min); + expect(sci.type).toBe("i256"); + }); + it("selects i128 for negative numbers beyond i64 range", () => { const val = -(1n << 64n); const sci = new ScInt(val); From b2fca1df8b6cd9f6848bebcc5a93fa83284c858e Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 09:21:24 -0400 Subject: [PATCH 16/23] 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 0eab134c7427e1bb3fe39d6b671b9d54fef6c73f Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 10:20:55 -0400 Subject: [PATCH 17/23] Validate timebounds/ledgerbounds in TransactionBuilder constructor --- src/transaction_builder.ts | 71 ++++++++++++++- test/unit/transaction_builder.test.ts | 119 ++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) diff --git a/src/transaction_builder.ts b/src/transaction_builder.ts index dcadcf6f..0899b33d 100644 --- a/src/transaction_builder.ts +++ b/src/transaction_builder.ts @@ -185,8 +185,57 @@ export class TransactionBuilder { this.operations = []; this.baseFee = opts.fee; - this.timebounds = opts.timebounds ? { ...opts.timebounds } : null; - this.ledgerbounds = opts.ledgerbounds ? { ...opts.ledgerbounds } : null; + if (opts.timebounds) { + const minTime = toEpochSeconds(opts.timebounds.minTime); + const maxTime = toEpochSeconds(opts.timebounds.maxTime); + + if (minTime !== undefined && minTime < 0) { + throw new Error("min_time cannot be negative"); + } + + if (maxTime !== undefined && maxTime < 0) { + throw new Error("max_time cannot be negative"); + } + + if ( + minTime !== undefined && + maxTime !== undefined && + maxTime > 0 && + minTime > maxTime + ) { + throw new Error("min_time cannot be greater than max_time"); + } + + this.timebounds = { ...opts.timebounds }; + } else { + this.timebounds = null; + } + + if (opts.ledgerbounds) { + const minLedger = opts.ledgerbounds.minLedger; + const maxLedger = opts.ledgerbounds.maxLedger; + + if (minLedger !== undefined && minLedger < 0) { + throw new Error("min_ledger cannot be negative"); + } + + if (maxLedger !== undefined && maxLedger < 0) { + throw new Error("max_ledger cannot be negative"); + } + + if ( + minLedger !== undefined && + maxLedger !== undefined && + maxLedger > 0 && + minLedger > maxLedger + ) { + throw new Error("min_ledger cannot be greater than max_ledger"); + } + + this.ledgerbounds = { ...opts.ledgerbounds }; + } else { + this.ledgerbounds = null; + } this.minAccountSequence = opts.minAccountSequence || null; this.minAccountSequenceAge = opts.minAccountSequenceAge !== undefined @@ -1158,3 +1207,21 @@ export class TransactionBuilder { export function isValidDate(d: Date | number | string): d is Date { return d instanceof Date && !Number.isNaN(d.getTime()); } + +/** + * Converts a Date, number, or string time value to epoch seconds for + * validation. Returns undefined if the value is undefined. + */ +function toEpochSeconds( + value: Date | number | string | undefined, +): number | undefined { + if (value === undefined) { + return undefined; + } + + if (value instanceof Date) { + return Math.floor(value.getTime() / 1000); + } + + return Number(value); +} diff --git a/test/unit/transaction_builder.test.ts b/test/unit/transaction_builder.test.ts index 71f1bc27..ec8053e1 100644 --- a/test/unit/transaction_builder.test.ts +++ b/test/unit/transaction_builder.test.ts @@ -1719,6 +1719,125 @@ describe("TransactionBuilder.cloneFrom", () => { // Additional coverage for setter methods and validation +describe("constructor timebounds/ledgerbounds validation", () => { + const networkPassphrase = Networks.TESTNET; + const source = new Account( + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + "0", + ); + + describe("timebounds", () => { + it("rejects inverted timebounds (minTime > non-zero maxTime)", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 9999999999, maxTime: 500 }, + }); + }).toThrow("min_time cannot be greater than max_time"); + }); + + it("rejects inverted Date timebounds", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { + minTime: new Date("2030-01-01"), + maxTime: new Date("2020-01-01"), + }, + }); + }).toThrow("min_time cannot be greater than max_time"); + }); + + it("allows maxTime=0 (indefinite) with non-zero minTime", () => { + const builder = new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 1000, maxTime: 0 }, + }); + expect(builder.timebounds?.minTime).toBe(1000); + expect(builder.timebounds?.maxTime).toBe(0); + }); + + it("allows equal minTime and maxTime", () => { + const builder = new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 1000, maxTime: 1000 }, + }); + expect(builder.timebounds?.minTime).toBe(1000); + expect(builder.timebounds?.maxTime).toBe(1000); + }); + + it("rejects negative minTime", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: -1, maxTime: 500 }, + }); + }).toThrow("min_time cannot be negative"); + }); + + it("rejects negative maxTime", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: -1 }, + }); + }).toThrow("max_time cannot be negative"); + }); + }); + + describe("ledgerbounds", () => { + it("rejects inverted ledgerbounds (minLedger > non-zero maxLedger)", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + ledgerbounds: { minLedger: 5000, maxLedger: 100 }, + }); + }).toThrow("min_ledger cannot be greater than max_ledger"); + }); + + it("allows maxLedger=0 (indefinite) with non-zero minLedger", () => { + const builder = new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + ledgerbounds: { minLedger: 1000, maxLedger: 0 }, + }); + expect(builder.ledgerbounds?.minLedger).toBe(1000); + expect(builder.ledgerbounds?.maxLedger).toBe(0); + }); + + it("rejects negative minLedger", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + ledgerbounds: { minLedger: -1, maxLedger: 100 }, + }); + }).toThrow("min_ledger cannot be negative"); + }); + + it("rejects negative maxLedger", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: 0 }, + ledgerbounds: { minLedger: 0, maxLedger: -1 }, + }); + }).toThrow("max_ledger cannot be negative"); + }); + }); +}); + describe("setTimebounds", () => { const source = new Account( "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", From f7fb823116eb4447beaa85c942b6423d4547034d Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 10:40:36 -0400 Subject: [PATCH 18/23] Accept undefined as delete-entry value in manageData --- src/operations/manage_data.ts | 5 +++-- test/unit/operations/manage_data.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/operations/manage_data.ts b/src/operations/manage_data.ts index e8cc089a..f5e08be0 100644 --- a/src/operations/manage_data.ts +++ b/src/operations/manage_data.ts @@ -23,7 +23,8 @@ export function manageData( if ( typeof opts.value !== "string" && !Buffer.isBuffer(opts.value) && - opts.value !== null + opts.value !== null && + opts.value !== undefined ) { throw new Error("value must be a string, Buffer or null"); } @@ -32,7 +33,7 @@ export function manageData( if (typeof opts.value === "string") { dataValue = Buffer.from(opts.value); } else { - dataValue = opts.value; + dataValue = opts.value ?? null; } if (dataValue !== null && dataValue.length > 64) { diff --git a/test/unit/operations/manage_data.test.ts b/test/unit/operations/manage_data.test.ts index 71576ffc..25c33f51 100644 --- a/test/unit/operations/manage_data.test.ts +++ b/test/unit/operations/manage_data.test.ts @@ -46,6 +46,23 @@ describe("Operation.manageData()", () => { expect(obj.value).toBeUndefined(); }); + it("round-trips a null-value (delete) manageData through fromXDRObject and back", () => { + const op = Operation.manageData({ name: "test", value: null }); + const xdrHex = op.toXDR("hex"); + const operation = xdr.Operation.fromXDR(Buffer.from(xdrHex, "hex")); + const parsed = expectOperationType( + Operation.fromXDRObject(operation), + "manageData", + ); + + // Rebuilding from the parsed result must not throw + const rebuilt = Operation.manageData({ + name: parsed.name, + value: parsed.value, + }); + expect(rebuilt).toBeInstanceOf(xdr.Operation); + }); + it("creates a manageData operation with source account", () => { const source = "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ"; const opts = { name: "test", value: "data", source }; From d32b8dfc126ac5f2e94869ed1b381e39f7336f73 Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 10:54:30 -0400 Subject: [PATCH 19/23] Accept line as alias for asset in changeTrust builder --- src/operations/change_trust.ts | 13 ++++++++---- test/unit/operations/change_trust.test.ts | 24 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/operations/change_trust.ts b/src/operations/change_trust.ts index 4c550304..e1325df2 100644 --- a/src/operations/change_trust.ts +++ b/src/operations/change_trust.ts @@ -28,12 +28,17 @@ const MAX_INT64 = "9223372036854775807"; export function changeTrust( opts: ChangeTrustOpts, ): xdr.Operation { + // Accept `line` as an alias for `asset` so that the output of + // fromXDRObject (which uses `line`) can round-trip back through here. + const asset = + opts.asset ?? + (opts as unknown as { line?: Asset | LiquidityPoolAsset }).line; let line: xdr.ChangeTrustAsset; - if (opts.asset instanceof Asset) { - line = opts.asset.toChangeTrustXDRObject(); - } else if (opts.asset instanceof LiquidityPoolAsset) { - line = opts.asset.toXDRObject(); + if (asset instanceof Asset) { + line = asset.toChangeTrustXDRObject(); + } else if (asset instanceof LiquidityPoolAsset) { + line = asset.toXDRObject(); } else { throw new TypeError("asset must be Asset or LiquidityPoolAsset"); } diff --git a/test/unit/operations/change_trust.test.ts b/test/unit/operations/change_trust.test.ts index 1af71fdc..5836ee6d 100644 --- a/test/unit/operations/change_trust.test.ts +++ b/test/unit/operations/change_trust.test.ts @@ -103,6 +103,30 @@ describe("Operation.changeTrust()", () => { ).toThrow(TypeError); }); + it("round-trips an Asset changeTrust through fromXDRObject and back", () => { + const op = Operation.changeTrust({ asset: usd, limit: "50.0000000" }); + const parsed = expectOperationType( + Operation.fromXDRObject(xdr.Operation.fromXDR(op.toXDR("hex"), "hex")), + "changeTrust", + ); + + // parsed has `line` (not `asset`); changeTrust accepts both + const rebuilt = Operation.changeTrust(parsed); + expect(rebuilt).toBeInstanceOf(xdr.Operation); + }); + + it("round-trips a LiquidityPoolAsset changeTrust through fromXDRObject and back", () => { + const op = Operation.changeTrust({ asset: lpAsset }); + const parsed = expectOperationType( + Operation.fromXDRObject(xdr.Operation.fromXDR(op.toXDR("hex"), "hex")), + "changeTrust", + ); + + // parsed has `line` (not `asset`); changeTrust accepts both + const rebuilt = Operation.changeTrust(parsed); + expect(rebuilt).toBeInstanceOf(xdr.Operation); + }); + it("preserves an optional source account", () => { const source = "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ"; const op = Operation.changeTrust({ asset: usd, source }); From c6e8f0e4d164d0081afbf0c9716d72263e61e04e Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 11:04:37 -0400 Subject: [PATCH 20/23] Review cleanup --- src/transaction_builder.ts | 19 +++++++------------ test/unit/transaction_builder.test.ts | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/transaction_builder.ts b/src/transaction_builder.ts index 0899b33d..7bfe12fd 100644 --- a/src/transaction_builder.ts +++ b/src/transaction_builder.ts @@ -318,11 +318,6 @@ export class TransactionBuilder { } if (tx.minAccountSequenceAge !== undefined) { builderOpts.minAccountSequenceAge = tx.minAccountSequenceAge; - console.log("minAccountSequenceAge", tx.minAccountSequenceAge); - console.log( - "builderOpts.minAccountSequenceAge", - builderOpts.minAccountSequenceAge, - ); } if (tx.minAccountSequenceLedgerGap !== undefined) { builderOpts.minAccountSequenceLedgerGap = tx.minAccountSequenceLedgerGap; @@ -335,10 +330,7 @@ export class TransactionBuilder { // User-provided opts override transaction defaults Object.assign(builderOpts, opts); - console.log( - "builderOpts.minAccountSequenceAge", - builderOpts.minAccountSequenceAge, - ); + const builder = new TransactionBuilder(source, builderOpts); tx.tx.operations().forEach((op) => builder.addOperation(op)); @@ -1219,9 +1211,12 @@ function toEpochSeconds( return undefined; } - if (value instanceof Date) { - return Math.floor(value.getTime() / 1000); + const num = + value instanceof Date ? Math.floor(value.getTime() / 1000) : Number(value); + + if (Number.isNaN(num)) { + throw new Error("timebounds value is not a valid number or Date"); } - return Number(value); + return num; } diff --git a/test/unit/transaction_builder.test.ts b/test/unit/transaction_builder.test.ts index ec8053e1..667b1216 100644 --- a/test/unit/transaction_builder.test.ts +++ b/test/unit/transaction_builder.test.ts @@ -1789,6 +1789,26 @@ describe("constructor timebounds/ledgerbounds validation", () => { }); }).toThrow("max_time cannot be negative"); }); + + it("rejects non-numeric string minTime", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: "abc" as unknown as number, maxTime: 500 }, + }); + }).toThrow("timebounds value is not a valid number or Date"); + }); + + it("rejects invalid Date maxTime", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: new Date("invalid") }, + }); + }).toThrow("timebounds value is not a valid number or Date"); + }); }); describe("ledgerbounds", () => { From 9d92c67ca0e2a693413c296758d94ef3b3f1e201 Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 11:42:03 -0400 Subject: [PATCH 21/23] Copilot feedback --- src/numbers/sc_int.ts | 17 ++++++++++++----- src/operations/manage_data.ts | 3 +++ src/transaction_builder.ts | 4 ++-- test/unit/numbers/sc_int.test.ts | 19 +++++++++++++++++++ test/unit/transaction_builder.test.ts | 24 ++++++++++++++++++++++-- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/numbers/sc_int.ts b/src/numbers/sc_int.ts index 9650e016..50f4a107 100644 --- a/src/numbers/sc_int.ts +++ b/src/numbers/sc_int.ts @@ -108,10 +108,17 @@ export class ScInt extends XdrLargeInt { } function nearestBigIntSize(bigI: bigint): number { - // Use the absolute value because BigInt.toString(2) prepends a "-" for - // negative values, which would inflate the length by 1 and misclassify - // exact boundary values like -(2^63), -(2^127), and -(2^255). - const abs = bigI < 0n ? -bigI : bigI; - const bitlen = abs.toString(2).length; + if (bigI < 0n) { + // Two's complement: N bits represent -(2^(N-1)) to 2^(N-1)-1. + // For negative values, compute the signed bit width as + // (bitlen of abs-1) + 1 to account for the sign bit. This correctly + // classifies -(2^63) as 64 bits (fits i64) and -(2^63)-1 as 65 bits + // (needs i128). + const abs = -bigI; + const bitlen = (abs - 1n).toString(2).length + 1; + return [64, 128, 256].find((len) => bitlen <= len) ?? bitlen; + } + + const bitlen = bigI.toString(2).length; return [64, 128, 256].find((len) => bitlen <= len) ?? bitlen; } diff --git a/src/operations/manage_data.ts b/src/operations/manage_data.ts index f5e08be0..74ac4c2e 100644 --- a/src/operations/manage_data.ts +++ b/src/operations/manage_data.ts @@ -20,6 +20,9 @@ export function manageData( throw new Error("name must be a string, up to 64 characters"); } + // undefined is accepted (treated as null/delete) for internal round-trip + // compatibility: fromXDRObject returns undefined for absent optional fields. + // The public API contract is null for delete-entry. if ( typeof opts.value !== "string" && !Buffer.isBuffer(opts.value) && diff --git a/src/transaction_builder.ts b/src/transaction_builder.ts index 7bfe12fd..7c20bd6b 100644 --- a/src/transaction_builder.ts +++ b/src/transaction_builder.ts @@ -1214,8 +1214,8 @@ function toEpochSeconds( const num = value instanceof Date ? Math.floor(value.getTime() / 1000) : Number(value); - if (Number.isNaN(num)) { - throw new Error("timebounds value is not a valid number or Date"); + if (!Number.isFinite(num) || num % 1 !== 0) { + throw new Error("timebounds value must be a finite integer or Date"); } return num; diff --git a/test/unit/numbers/sc_int.test.ts b/test/unit/numbers/sc_int.test.ts index 7b482749..56e2ad8d 100644 --- a/test/unit/numbers/sc_int.test.ts +++ b/test/unit/numbers/sc_int.test.ts @@ -179,6 +179,25 @@ describe("ScInt", () => { expect(sci.type).toBe("i256"); }); + it("selects i128 for -(2^63)-1 (just below i64 minimum)", () => { + const val = -(2n ** 63n) - 1n; + const sci = new ScInt(val); + expect(sci.type).toBe("i128"); + expect(sci.toBigInt()).toBe(val); + }); + + it("selects i256 for -(2^127)-1 (just below i128 minimum)", () => { + const val = -(2n ** 127n) - 1n; + const sci = new ScInt(val); + expect(sci.type).toBe("i256"); + expect(sci.toBigInt()).toBe(val); + }); + + it("throws for -(2^255)-1 (below i256 minimum)", () => { + const val = -(2n ** 255n) - 1n; + expect(() => new ScInt(val)).toThrow(RangeError); + }); + it("selects i128 for negative numbers beyond i64 range", () => { const val = -(1n << 64n); const sci = new ScInt(val); diff --git a/test/unit/transaction_builder.test.ts b/test/unit/transaction_builder.test.ts index 667b1216..06fe16b9 100644 --- a/test/unit/transaction_builder.test.ts +++ b/test/unit/transaction_builder.test.ts @@ -1797,7 +1797,7 @@ describe("constructor timebounds/ledgerbounds validation", () => { networkPassphrase, timebounds: { minTime: "abc" as unknown as number, maxTime: 500 }, }); - }).toThrow("timebounds value is not a valid number or Date"); + }).toThrow("timebounds value must be a finite integer or Date"); }); it("rejects invalid Date maxTime", () => { @@ -1807,7 +1807,27 @@ describe("constructor timebounds/ledgerbounds validation", () => { networkPassphrase, timebounds: { minTime: 0, maxTime: new Date("invalid") }, }); - }).toThrow("timebounds value is not a valid number or Date"); + }).toThrow("timebounds value must be a finite integer or Date"); + }); + + it("rejects Infinity as a timebounds value", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 0, maxTime: Infinity }, + }); + }).toThrow("timebounds value must be a finite integer or Date"); + }); + + it("rejects non-integer float as a timebounds value", () => { + expect(() => { + new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + timebounds: { minTime: 1.5, maxTime: 500 }, + }); + }).toThrow("timebounds value must be a finite integer or Date"); }); }); From c70ca5cd74ebfbaf843136c8ed6ec5d6fc10061f Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 14:05:47 -0400 Subject: [PATCH 22/23] Add tests for op check --- test/unit/operations/change_trust.test.ts | 8 ++++++-- test/unit/operations/manage_data.test.ts | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/test/unit/operations/change_trust.test.ts b/test/unit/operations/change_trust.test.ts index 5836ee6d..38d3a442 100644 --- a/test/unit/operations/change_trust.test.ts +++ b/test/unit/operations/change_trust.test.ts @@ -105,26 +105,30 @@ describe("Operation.changeTrust()", () => { it("round-trips an Asset changeTrust through fromXDRObject and back", () => { const op = Operation.changeTrust({ asset: usd, limit: "50.0000000" }); + const xdrHex = op.toXDR("hex"); const parsed = expectOperationType( - Operation.fromXDRObject(xdr.Operation.fromXDR(op.toXDR("hex"), "hex")), + Operation.fromXDRObject(xdr.Operation.fromXDR(xdrHex, "hex")), "changeTrust", ); // parsed has `line` (not `asset`); changeTrust accepts both const rebuilt = Operation.changeTrust(parsed); expect(rebuilt).toBeInstanceOf(xdr.Operation); + expect(rebuilt.toXDR("hex")).toBe(xdrHex); }); it("round-trips a LiquidityPoolAsset changeTrust through fromXDRObject and back", () => { const op = Operation.changeTrust({ asset: lpAsset }); + const xdrHex = op.toXDR("hex"); const parsed = expectOperationType( - Operation.fromXDRObject(xdr.Operation.fromXDR(op.toXDR("hex"), "hex")), + Operation.fromXDRObject(xdr.Operation.fromXDR(xdrHex, "hex")), "changeTrust", ); // parsed has `line` (not `asset`); changeTrust accepts both const rebuilt = Operation.changeTrust(parsed); expect(rebuilt).toBeInstanceOf(xdr.Operation); + expect(rebuilt.toXDR("hex")).toBe(xdrHex); }); it("preserves an optional source account", () => { diff --git a/test/unit/operations/manage_data.test.ts b/test/unit/operations/manage_data.test.ts index 25c33f51..e799394e 100644 --- a/test/unit/operations/manage_data.test.ts +++ b/test/unit/operations/manage_data.test.ts @@ -61,6 +61,7 @@ describe("Operation.manageData()", () => { value: parsed.value, }); expect(rebuilt).toBeInstanceOf(xdr.Operation); + expect(rebuilt.toXDR("hex")).toBe(xdrHex); }); it("creates a manageData operation with source account", () => { From 0e5828ac572ac81a00d4a9b514674b0162bc277d Mon Sep 17 00:00:00 2001 From: Iveta Date: Thu, 2 Apr 2026 16:26:14 -0400 Subject: [PATCH 23/23] Updated changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b7be545..22d20219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,6 +179,18 @@ - `TransactionBuilder.addSacTransferOperation` now supports muxed (M...) addresses for the destination and source. Previously, passing muxed addresses caused `Keypair.fromPublicKey` to throw. +- `ScInt` auto-type selection now correctly classifies negative boundary values + (e.g., `-(2^63)` fits `i64`, not `i128`). The previous bit-length calculation + was off by one for negative `BigInt` values. +- `changeTrust` now handles `line` internally as a fallback for `asset`, + fixing round-trip compatibility when feeding the output of + `Operation.fromXDRObject` (which returns `line`) back into the builder. +- `manageData` now accepts `undefined` for `opts.value` (treated as a + delete-entry), fixing round-trip compatibility with `Operation.fromXDRObject` + which returns `undefined` for absent optional fields. +- `TransactionBuilder` constructor now validates `timebounds` and + `ledgerbounds`: negative values and `min > max` now throw immediately instead + of producing silently invalid transactions. ## [`v14.1.0`](https://github.com/stellar/js-stellar-base/compare/v14.0.4...v14.1.0):