Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ project adheres to [Semantic Versioning](http://semver.org/).

* Decoding Array and VarArray now fast fails when the array length exceeds remaining bytes to decode ([#132](https://github.com/stellar/js-xdr/pull/132))

* Fixed silent truncation of bigint values exceeding the range of sized integers (`Hyper`, `UnsignedHyper`, and other `LargeInt` subtypes). Construction, encoding, and multi-part assembly now throw on overflow/underflow instead of silently clamping. `isValid` also validates value range. `sliceBigInt` now returns unsigned slice values for consistency ([#133](https://github.com/stellar/js-xdr/pull/133)).
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changelog entry claims that sliceBigInt now returns unsigned slice values, but the current implementation of sliceBigInt still uses BigInt.asIntN(...) for each slice (i.e., signed slices). Either update the changelog text to match the actual behavior or adjust sliceBigInt accordingly.

Suggested change
* Fixed silent truncation of bigint values exceeding the range of sized integers (`Hyper`, `UnsignedHyper`, and other `LargeInt` subtypes). Construction, encoding, and multi-part assembly now throw on overflow/underflow instead of silently clamping. `isValid` also validates value range. `sliceBigInt` now returns unsigned slice values for consistency ([#133](https://github.com/stellar/js-xdr/pull/133)).
* Fixed silent truncation of bigint values exceeding the range of sized integers (`Hyper`, `UnsignedHyper`, and other `LargeInt` subtypes). Construction, encoding, and multi-part assembly now throw on overflow/underflow instead of silently clamping. `isValid` also validates value range. `sliceBigInt` now returns signed slice values for consistency ([#133](https://github.com/stellar/js-xdr/pull/133)).

Copilot uses AI. Check for mistakes.




Comment on lines +18 to +20
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are multiple consecutive blank lines added after this bullet, which introduces unnecessary whitespace in the Unreleased section. Consider removing the extra empty lines to keep the changelog formatting consistent.

Copilot uses AI. Check for mistakes.
## [v3.1.2](https://github.com/stellar/js-xdr/compare/v3.1.1...v3.1.2)

### Fixed
Expand Down
56 changes: 41 additions & 15 deletions src/bigint-encoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,32 @@ export function encodeBigIntFromBits(parts, size, unsigned) {
throw new TypeError(`expected bigint-like values, got: ${parts} (${e})`);
}

// check for sign mismatches for single inputs (this is a special case to
// handle one parameter passed to e.g. UnsignedHyper et al.)
// see https://github.com/stellar/js-xdr/pull/100#discussion_r1228770845
if (unsigned && parts.length === 1 && parts[0] < 0n) {
throw new RangeError(`expected a positive value, got: ${parts}`);
// fast path: single value — validate and return directly without assembly
if (parts.length === 1) {
const value = parts[0];
if (unsigned && value < 0n) {
throw new RangeError(`expected a positive value, got: ${parts}`);
}
const [min, max] = calculateBigIntBoundaries(size, unsigned);
if (value < min || value > max) {
throw new TypeError(
`bigint value ${value} for ${formatIntName(
size,
unsigned
)} out of range [${min}, ${max}]`
);
}
Comment on lines +54 to +60
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out-of-range numeric values are currently reported with a TypeError, but this condition is a range violation (and other range checks in this function already use RangeError). Consider throwing RangeError here for consistency and clearer semantics (and to make it easier for callers/tests to distinguish invalid types vs invalid ranges).

Copilot uses AI. Check for mistakes.
return value;
}

// encode in big-endian fashion, shifting each slice by the slice size
let result = BigInt.asUintN(sliceSize, parts[0]); // safe: len >= 1
for (let i = 1; i < parts.length; i++) {
// multi-part assembly: encode in big-endian fashion, shifting each slice
let result = 0n;

for (let i = 0; i < parts.length; i++) {
assertSliceFits(parts[i], sliceSize);
result |= BigInt.asUintN(sliceSize, parts[i]) << BigInt(i * sliceSize);
}

// interpret value as signed if necessary and clamp it
if (!unsigned) {
result = BigInt.asIntN(size, result);
}
Expand Down Expand Up @@ -106,15 +118,11 @@ export function sliceBigInt(value, iSize, sliceSize) {
}

const shift = BigInt(sliceSize);
const mask = (1n << shift) - 1n;

// iterate shift and mask application
const result = new Array(total);
for (let i = 0; i < total; i++) {
// we force a signed interpretation to preserve sign in each slice value,
// but downstream can convert to unsigned if it's appropriate
result[i] = BigInt.asIntN(sliceSize, value); // clamps to size

// move on to the next chunk
result[i] = value & mask;
value >>= shift;
}

Expand All @@ -139,3 +147,21 @@ export function calculateBigIntBoundaries(size, unsigned) {
const boundary = 1n << BigInt(size - 1);
return [0n - boundary, boundary - 1n];
}

/**
* Asserts that a given part fits within the specified slice size.
* @param {bigint | number | string} part - The part to check.
* @param {number} sliceSize - The size of the slice in bits (e.g., 32, 64, 128)
* @returns {void}
* @throws {RangeError} If the part does not fit within the slice size.
*/
function assertSliceFits(part, sliceSize) {
const fitsSigned = BigInt.asIntN(sliceSize, part) === part;
const fitsUnsigned = BigInt.asUintN(sliceSize, part) === part;

if (!fitsSigned && !fitsUnsigned) {
throw new RangeError(
`slice value ${part} does not fit in ${sliceSize} bits`
);
}
}
40 changes: 26 additions & 14 deletions src/large-int.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,21 @@ export class LargeInt extends XdrPrimitiveType {
* @inheritDoc
*/
static read(reader) {
const { size } = this.prototype;
if (size === 64) return new this(reader.readBigUInt64BE());
return new this(
...Array.from({ length: size / 64 }, () =>
reader.readBigUInt64BE()
).reverse()
);
const { size, unsigned } = this.prototype;
if (size === 64) {
return new this(
unsigned ? reader.readBigUInt64BE() : reader.readBigInt64BE()
);
}
// assemble bigint directly from big-endian 64-bit chunks
let value = 0n;
for (let i = size / 64 - 1; i >= 0; i--) {
value |= reader.readBigUInt64BE() << BigInt(i * 64);
}
if (!unsigned) {
value = BigInt.asIntN(size, value);
}
return new this(value);
}

/**
Expand All @@ -88,12 +96,12 @@ export class LargeInt extends XdrPrimitiveType {
writer.writeBigInt64BE(value);
}
} else {
for (const part of sliceBigInt(value, size, 64).reverse()) {
if (unsigned) {
writer.writeBigUInt64BE(part);
} else {
writer.writeBigInt64BE(part);
}
// extract 64-bit chunks directly from bigint, big-endian order
const uvalue = unsigned ? value : BigInt.asUintN(size, value);
for (let i = size / 64 - 1; i >= 0; i--) {
writer.writeBigUInt64BE(
(uvalue >> BigInt(i * 64)) & 0xffffffffffffffffn
);
}
}
}
Expand All @@ -102,7 +110,11 @@ export class LargeInt extends XdrPrimitiveType {
* @inheritDoc
*/
static isValid(value) {
return typeof value === 'bigint' || value instanceof this;
if (value instanceof this) return true;
if (typeof value === 'bigint') {
return value >= this.MIN_VALUE && value <= this.MAX_VALUE;
}
return false;
}

/**
Expand Down
90 changes: 84 additions & 6 deletions test/unit/bigint-encoder_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,15 +250,93 @@ describe('encodeBigIntWithPrecision', function () {
}
}
});

it(`throws on slice overflow and underflow`, () => {
const invalidCases = [
{
parts: [0n, 0x100000000n],
bits: 64,
unsigned: true,
reason: 'u32 slice overflow (+2^32)'
},
{
parts: [0n, -0x80000001n],
bits: 64,
unsigned: false,
reason: 'i32 slice underflow (< -2^31)'
},
{
parts: [0n, 0n, 0n, 0x10000000000000000n],
bits: 256,
unsigned: true,
reason: 'u64 slice overflow (+2^64)'
},
{
parts: [0n, 0n, 0n, -0x8000000000000001n],
bits: 256,
unsigned: false,
reason: 'i64 slice underflow (< -2^63)'
}
];

for (const { parts, bits, unsigned, reason } of invalidCases) {
expect(
() => encodeBigIntFromBits(parts, bits, unsigned),
`${formatIntName(bits, unsigned)} should throw for ${reason}`
).to.throw(RangeError, /does not fit/i);
}
});

it(`accepts exact slice boundary values`, () => {
const validCases = [
{
parts: [0n, 0xffffffffn],
bits: 64,
unsigned: true,
expected: 0xffffffff00000000n,
reason: 'u32 upper boundary (2^32 - 1)'
},
{
parts: [0n, -0x80000000n],
bits: 64,
unsigned: false,
expected: -0x8000000000000000n,
reason: 'i32 lower boundary (-2^31)'
},
{
parts: [0n, 0n, 0n, 0xffffffffffffffffn],
bits: 256,
unsigned: true,
expected:
0xffffffffffffffff000000000000000000000000000000000000000000000000n,
reason: 'u64 upper boundary (2^64 - 1)'
},
{
parts: [0n, 0n, 0n, -0x8000000000000000n],
bits: 256,
unsigned: false,
expected:
-0x8000000000000000000000000000000000000000000000000000000000000000n,
reason: 'i64 lower boundary (-2^63)'
}
];

for (const { parts, bits, unsigned, expected, reason } of validCases) {
expect(
encodeBigIntFromBits(parts, bits, unsigned),
`${formatIntName(bits, unsigned)} should accept ${reason}`
).to.eq(expected);
}
});
});

describe('sliceBigInt', function () {
it(`slices values correctly`, () => {
const testCases = [
[0n, 64, 64, [0n]],
[0n, 256, 256, [0n]],
[-1n, 64, 32, [-1n, -1n]],
[0xfffffffffffffffen, 64, 32, [-2n, -1n]],
[-1n, 64, 32, [0xffffffffn, 0xffffffffn]],
[0xfffffffffffffffen, 64, 32, [0xfffffffen, 0xffffffffn]],
[
0x7fffffffffffffff5cffffffffffffffn,
128,
Expand All @@ -269,13 +347,13 @@ describe('sliceBigInt', function () {
0x80000000ffffffff0000000100000001n,
128,
32,
[1n, 1n, -1n, -0x80000000n]
[1n, 1n, 0xffffffffn, 0x80000000n]
],
[
-0x158fffffffffffffea6fffffffffffffea6fffffffffffffea7n,
256,
64,
[345n, 345n, 345n, -345n]
[0x159n, 0x159n, 0x159n, 0xfffffffffffffea7n]
],
[
0x0000000800000007000000060000000500000004000000030000000200000001n,
Expand All @@ -287,13 +365,13 @@ describe('sliceBigInt', function () {
-0x7fffffff8fffffff9fffffffafffffffbfffffffcfffffffdffffffffn,
256,
32,
[1n, 2n, 3n, 4n, 5n, 6n, 7n, -8n]
[1n, 2n, 3n, 4n, 5n, 6n, 7n, 0xfffffff8n]
],
[
-0x7fffffff800000005fffffffa00000003fffffffc00000001ffffffffn,
256,
32,
[1n, -2n, 3n, -4n, 5n, -6n, 7n, -8n]
[1n, 0xfffffffen, 3n, 0xfffffffcn, 5n, 0xfffffffan, 7n, 0xfffffff8n]
]
];
for (let [value, size, sliceSize, expected] of testCases) {
Expand Down
22 changes: 22 additions & 0 deletions test/unit/hyper_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,25 @@ describe('Hyper.fromString', function () {
expect(() => Hyper.fromString('105946095601.5')).to.throw(/bigint/);
});
});

describe('Hyper overflow/underflow', function () {
it('throws when constructing with a value exceeding i64 max', function () {
const tooBig = 2n ** 63n; // one above MAX_VALUE
expect(() => new Hyper(tooBig)).to.throw(/out of range|does not fit/i);
});

it('throws when constructing with a value below i64 min', function () {
const tooSmall = -(2n ** 63n) - 1n; // one below MIN_VALUE
expect(() => new Hyper(tooSmall)).to.throw(/out of range|does not fit/i);
});

it('throws for a 300-bit bigint', function () {
const huge = 2n ** 300n;
expect(() => new Hyper(huge)).to.throw(/out of range|does not fit/i);
});

it('accepts exact boundary values without throwing', function () {
expect(() => new Hyper(Hyper.MAX_VALUE)).to.not.throw();
expect(() => new Hyper(Hyper.MIN_VALUE)).to.not.throw();
});
});
Loading