Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@
`AuthFlags` (`AuthFlag | (number & {})`), accepting any number for combined
bitmask values.
- `toXDRPrice` now rejects denominator equal to zero (`d <= 0` instead of
`d < 0`).
`d < 0`). Numeric price values of zero, negative, `NaN`, and `Infinity` are
now rejected with `"price must be positive"` before reaching `best_r()`,
instead of producing a confusing `"Couldn't find approximation"` error.
- `checkUnsignedIntValue` string-to-number conversion changed from
`parseFloat()` to `Number()`. Strings with trailing non-numeric characters
(e.g., `"123abc"`) that previously silently succeeded will now be rejected.
Expand Down Expand Up @@ -131,7 +133,9 @@
prototype chain from being used as type hints.
- `nativeToScVal` now validates bounds for `u32`/`i32` types, throwing
`TypeError` for out-of-range values that previously passed through to the XDR
layer.
layer. The string-to-`u32`/`i32` path now uses `BigInt()` instead of
`parseInt()`, rejecting strings with trailing junk (e.g., `"123abc"`) and
correctly parsing hex/octal/binary prefixes instead of silently returning `0`.
- `best_r` (continued fraction approximation) no longer throws for small
numbers whose reciprocal exceeds `MAX_INT`. It now computes a
semi-convergent to recover a valid rational approximation.
Expand Down Expand Up @@ -191,6 +195,18 @@
- `TransactionBuilder` constructor now validates `timebounds` and
`ledgerbounds`: negative values and `min > max` now throw immediately instead
of producing silently invalid transactions.
- `XdrLargeInt.toI128()` and `toI256()` now reject unsigned values that exceed
the signed range (e.g., `2^127` for i128, `2^255` for i256), throwing
`RangeError` instead of silently flipping the sign bit via `BigInt.asIntN`.
- `Memo.id()` now rejects non-plain-digit strings such as scientific notation
(`"1e18"`) and decimal strings (`"1.0"`) that previously passed `BigNumber`
validation but crashed in `BigInt()` during XDR serialization.
- `TransactionBuilder.build()` now throws when the total fee (`baseFee *
operations`, or `baseFee * operations + resourceFee` for Soroban) exceeds
the uint32 maximum (`4294967295`), instead of producing an invalid XDR value.
- `TransactionBuilder.cloneFrom()` now throws when the source transaction has
zero operations, instead of producing a builder with `baseFee` set to
`Infinity`.

## [`v14.1.0`](https://github.com/stellar/js-stellar-base/compare/v14.0.4...v14.1.0):

Expand Down
2 changes: 1 addition & 1 deletion src/keypair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export class Keypair {
let hint = Buffer.from(dataBuffer.subarray(-4));
if (hint.length < 4) {
// append zeroes as needed
hint = Buffer.concat([hint, Buffer.alloc(4 - dataBuffer.length, 0)]);
hint = Buffer.concat([hint, Buffer.alloc(4 - hint.length, 0)]);
}

// XOR each byte of hint with corresponding byte of keyHint
Expand Down
7 changes: 7 additions & 0 deletions src/memo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ export class Memo<T extends MemoType = MemoType> {
throw error;
}

// Only plain decimal digit strings are accepted. Scientific notation
// ("1e18") and trailing-zero decimals ("1.0") pass BigNumber validation
// but crash in BigInt() during XDR serialization.
if (!/^[0-9]+$/.test(value)) {
throw error;
}

let number: BigNumber;
try {
number = new BigNumber(value);
Expand Down
8 changes: 7 additions & 1 deletion src/numbers/xdr_large_int.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ export class XdrLargeInt {
this._sizeCheck(128);

const v = this.int.toBigInt();
if (BigInt.asIntN(128, v) !== v) {
throw RangeError(`value too large for i128: ${v}`);
}
const hi64 = BigInt.asIntN(64, v >> 64n); // encode top 64 w/ sign bit
const lo64 = BigInt.asUintN(64, v); // grab btm 64, encode sign

Expand Down Expand Up @@ -196,10 +199,13 @@ export class XdrLargeInt {
/**
* The integer encoded with `ScValType = I256`
*
* Note: No size check needed - I256 is the largest signed type.
* @throws if the value cannot fit in a signed 256-bit integer
*/
toI256(): xdr.ScVal {
const v = this.int.toBigInt();
if (BigInt.asIntN(256, v) !== v) {
throw RangeError(`value too large for i256: ${v}`);
}
const hiHi64 = BigInt.asIntN(64, v >> 192n); // keep sign bit
const hiLo64 = BigInt.asUintN(64, v >> 128n);
const loHi64 = BigInt.asUintN(64, v >> 64n);
Expand Down
26 changes: 22 additions & 4 deletions src/scval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,29 @@ export function nativeToScVal(
case "address":
return new Address(val).toScVal();

case "u32":
return xdr.ScVal.scvU32(parseInt(val, 10));
case "u32": {
const bigintVal = BigInt(val);
if (
bigintVal < BigInt(xdr.Uint32.MIN_VALUE) ||
bigintVal > BigInt(xdr.Uint32.MAX_VALUE)
) {
throw new TypeError(`invalid value (${val}) for type u32`);
}
return xdr.ScVal.scvU32(Number(bigintVal));
}

case "i32":
return xdr.ScVal.scvI32(parseInt(val, 10));
case "i32": {
const bigintVal = BigInt(val);
// TODO: Update this check once xdr.Int32.MIN_VALUE in XDR is properly
// set to negative. Check this globally.
if (
bigintVal < -BigInt(xdr.Int32.MIN_VALUE) ||
bigintVal > BigInt(xdr.Int32.MAX_VALUE)
) {
throw new TypeError(`invalid value (${val}) for type i32`);
}
return xdr.ScVal.scvI32(Number(bigintVal));
Comment thread
quietbits marked this conversation as resolved.
}

default:
if (XdrLargeInt.isType(optType)) {
Expand Down
23 changes: 23 additions & 0 deletions src/transaction_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Address } from "./address.js";
import { Keypair } from "./keypair.js";

const HYPER_MAX_VALUE = Hyper.MAX_VALUE as unknown as bigint;
const UINT32_MAX = 4294967295; // 2^32 - 1

/**
* Minimum base fee for transactions. If this fee is below the network
Expand Down Expand Up @@ -297,6 +298,13 @@ export class TransactionBuilder {
throw new TypeError(`unsupported tx source account: ${tx.source}`);
}

if (tx.operations.length === 0) {
throw new Error(
"cannot clone a transaction with no operations: " +
"per-operation base fee cannot be determined",
);
}

// the initial fee passed to the builder gets scaled up based on the number
// of operations at the end, so we have to down-scale first
const unscaledFee = Math.floor(parseInt(tx.fee, 10) / tx.operations.length);
Expand Down Expand Up @@ -914,6 +922,14 @@ export class TransactionBuilder {
const fee = new BigNumber(this.baseFee)
.times(this.operations.length)
.toNumber();

if (fee > UINT32_MAX) {
throw new Error(
`Total fee (baseFee * operations) exceeds the maximum uint32 value (${UINT32_MAX}). ` +
`Got ${fee} from baseFee=${this.baseFee} and ${this.operations.length} operation(s).`,
);
}

const attrs: {
fee: number;
seqNum: xdr.SequenceNumber;
Expand Down Expand Up @@ -1006,6 +1022,13 @@ export class TransactionBuilder {
attrs.fee = new BigNumber(attrs.fee)
.plus(this.sorobanData.resourceFee().toString())
.toNumber();

if (attrs.fee > UINT32_MAX) {
throw new Error(
`Total fee (baseFee * operations + resourceFee) exceeds the maximum uint32 value (${UINT32_MAX}). ` +
`Got ${attrs.fee}.`,
);
}
} else {
attrs.ext = new xdr.TransactionExt(0);
}
Expand Down
4 changes: 4 additions & 0 deletions src/util/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ export function toXDRPrice(
if (typeof price === "object" && "n" in price && "d" in price) {
xdrObject = new xdr.Price(price);
} else {
const priceBN = new BigNumber(price);
if (!priceBN.gt(0) || !priceBN.isFinite()) {
throw new Error("price must be positive");
}
const approx = best_r(price);
Comment thread
quietbits marked this conversation as resolved.
xdrObject = new xdr.Price({
n: parseInt(String(approx[0]), 10),
Expand Down
68 changes: 44 additions & 24 deletions test/unit/memo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
MemoID,
MemoText,
MemoHash,
MemoReturn
MemoReturn,
} from "../../src/memo.js";

describe("Memo", () => {
Expand Down Expand Up @@ -47,7 +47,7 @@ describe("Memo", () => {
});

it("returns a value for a correct argument (utf8)", () => {
let memoText = new Memo(MemoText, Buffer.from([0xd1]))
const memoText = new Memo(MemoText, Buffer.from([0xd1]))
.toXDRObject()
.toXDR();
const expected = Buffer.from([
Expand All @@ -56,7 +56,7 @@ describe("Memo", () => {
// length
0x00, 0x00, 0x00, 0x01,
// value
0xd1, 0x00, 0x00, 0x00
0xd1, 0x00, 0x00, 0x00,
]);
expect(memoText.equals(expected)).toBe(true);
});
Expand Down Expand Up @@ -97,32 +97,32 @@ describe("Memo", () => {
it("throws an error when invalid argument was passed", () => {
// @ts-expect-error testing missing arg
expect(() => Memo.text()).toThrow(
/Expects string, array or buffer, max 28 bytes/
/Expects string, array or buffer, max 28 bytes/,
);
// @ts-expect-error testing invalid input
expect(() => Memo.text({})).toThrow(
/Expects string, array or buffer, max 28 bytes/
/Expects string, array or buffer, max 28 bytes/,
);
// @ts-expect-error testing invalid input
expect(() => Memo.text(10)).toThrow(
/Expects string, array or buffer, max 28 bytes/
/Expects string, array or buffer, max 28 bytes/,
);
// @ts-expect-error testing invalid input
expect(() => Memo.text(Infinity)).toThrow(
/Expects string, array or buffer, max 28 bytes/
/Expects string, array or buffer, max 28 bytes/,
);
// @ts-expect-error testing invalid input
expect(() => Memo.text(NaN)).toThrow(
/Expects string, array or buffer, max 28 bytes/
/Expects string, array or buffer, max 28 bytes/,
);
});

it("throws an error when string is longer than 28 bytes", () => {
expect(() => Memo.text("12345678901234567890123456789")).toThrow(
/Expects string, array or buffer, max 28 bytes/
/Expects string, array or buffer, max 28 bytes/,
);
expect(() => Memo.text("三代之時三代之時三代之時")).toThrow(
/Expects string, array or buffer, max 28 bytes/
/Expects string, array or buffer, max 28 bytes/,
);
});
});
Expand Down Expand Up @@ -171,6 +171,26 @@ describe("Memo", () => {
it("throws an error when value exceeds uint64 max", () => {
expect(() => Memo.id("18446744073709551616")).toThrow(/Expects a uint64/);
});

it("rejects scientific notation strings that BigInt cannot parse", () => {
// "1e18" passes BigNumber validation but BigInt("1e18") throws.
// Validation should reject it upfront instead of deferring the crash
// to toXDRObject().
expect(() => Memo.id("1e18")).toThrow(/Expects a uint64/);
});

it("rejects trailing-zero decimal strings that BigInt cannot parse", () => {
// "1.0" passes BigNumber.isInteger() but BigInt("1.0") throws.
expect(() => Memo.id("1.0")).toThrow(/Expects a uint64/);
});

it("scientific notation equivalent works when written as plain digits", () => {
// The value itself is valid — it's the string format that's the problem.
expect(() => Memo.id("1000000000000000000")).not.toThrow();
const memo = Memo.id("1000000000000000000");
const xdrMemo = memo.toXDRObject();
expect(xdrMemo.id().toString()).toBe("1000000000000000000");
});
});

describe(".hash() & .return()", () => {
Expand All @@ -191,7 +211,7 @@ describe("Memo", () => {
expect(baseMemo.type).toBe(MemoHash);
expect((baseMemo.value as Buffer).length).toBe(32);
expect((baseMemo.value as Buffer).toString("hex")).toBe(
buffer.toString("hex")
buffer.toString("hex"),
);
});

Expand All @@ -214,7 +234,7 @@ describe("Memo", () => {
expect(Buffer.isBuffer(baseMemo.value)).toBe(true);
expect((baseMemo.value as Buffer).length).toBe(32);
expect((baseMemo.value as Buffer).toString("hex")).toBe(
buffer.toString("hex")
buffer.toString("hex"),
);
});

Expand All @@ -224,8 +244,8 @@ describe("Memo", () => {
expect(() => method(Buffer.alloc(32).toString("hex"))).not.toThrow();
expect(() =>
method(
"0000000000000000000000000000000000000000000000000000000000000000"
)
"0000000000000000000000000000000000000000000000000000000000000000",
),
).not.toThrow();
}
});
Expand All @@ -251,20 +271,20 @@ describe("Memo", () => {
expect(() => method("test")).toThrow(/Expects a 32 byte hash value/);
// @ts-expect-error testing invalid input
expect(() => method([0, 10, 20])).toThrow(
/Expects a 32 byte hash value/
/Expects a 32 byte hash value/,
);
expect(() => method(Buffer.alloc(33).toString("hex"))).toThrow(
/Expects a 32 byte hash value/
/Expects a 32 byte hash value/,
);
expect(() =>
method(
"00000000000000000000000000000000000000000000000000000000000000"
)
"00000000000000000000000000000000000000000000000000000000000000",
),
).toThrow(/Expects a 32 byte hash value/);
expect(() =>
method(
"000000000000000000000000000000000000000000000000000000000000000000"
)
"000000000000000000000000000000000000000000000000000000000000000000",
),
).toThrow(/Expects a 32 byte hash value/);
}
});
Expand All @@ -291,20 +311,20 @@ describe("Memo", () => {
const buffer = Buffer.alloc(32, 10);
const memo = Memo.hash(buffer);

const value = memo.value as Buffer;
const value = memo.value;
value[0] = 0xff;

expect((memo.value as Buffer)[0]).toBe(10);
expect(memo.value[0]).toBe(10);
});

it("returns a copy for MemoReturn so mutations do not affect the original", () => {
const buffer = Buffer.alloc(32, 20);
const memo = Memo.return(buffer);

const value = memo.value as Buffer;
const value = memo.value;
value[0] = 0xff;

expect((memo.value as Buffer)[0]).toBe(20);
expect(memo.value[0]).toBe(20);
});
});
});
3 changes: 3 additions & 0 deletions test/unit/numbers/sc_int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,9 @@ describe("ScInt", () => {
expect(sci.toBigInt()).toBe(max);
});

// TODO: @stellar/js-xdr@4.0.0 now throws RangeError for oversized values
// instead of silently wrapping. Once the XDR upgrade is finalized, change
// this test to: expect(() => new ScInt(tooLarge, { type: "u64" })).toThrow(RangeError);
it("wraps oversized values for specified type (overflow to zero)", () => {
const tooLarge = 1n << 64n;
const sci = new ScInt(tooLarge, { type: "u64" });
Expand Down
Loading
Loading