diff --git a/.gitignore b/.gitignore index d0c17f74d..c7abc6652 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ /coverage/ /jsdoc/ .DS_Store +.claude/ +.idea/ +.vscode/ +.copilot/ +tsconfig.tsbuildinfo diff --git a/src/auth.js b/src/auth.js index 335425afb..f2ef773b6 100644 --- a/src/auth.js +++ b/src/auth.js @@ -255,6 +255,18 @@ export function authorizeInvocation( } function bytesToInt64(bytes) { + const buf = bytes.subarray(0, 8); + // Process each 32-bit half separately: `<<` coerces to signed Int32, so + // working with 4 bytes at a time avoids truncating the upper 32 bits. // eslint-disable-next-line no-bitwise - return bytes.subarray(0, 8).reduce((accum, b) => (accum << 8) | b, 0); + const hi = (buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]; + // eslint-disable-next-line no-bitwise + const lo = (buf[4] << 24) | (buf[5] << 16) | (buf[6] << 8) | buf[7]; + // `>>> 0` reinterprets the signed Int32 as an unsigned 32-bit value before + // converting to BigInt, preventing sign-extension from corrupting the result. + // eslint-disable-next-line no-bitwise + const value = BigInt(hi >>> 0) * BigInt(2 ** 32) + BigInt(lo >>> 0); + // Reinterpret the unsigned 64-bit result as a signed Int64, matching the + // xdr.Int64 type (which expects values in the range [-2^63, 2^63-1]). + return BigInt.asIntN(64, value); } diff --git a/src/numbers/sc_int.js b/src/numbers/sc_int.js index 7f81d83ad..40331c068 100644 --- a/src/numbers/sc_int.js +++ b/src/numbers/sc_int.js @@ -73,7 +73,8 @@ import { XdrLargeInt } from './xdr_large_int'; */ export class ScInt extends XdrLargeInt { constructor(value, opts) { - const signed = value < 0; + const bigValue = BigInt(value); + const signed = bigValue < 0n; let type = opts?.type ?? ''; if (type.startsWith('u') && signed) { throw TypeError(`specified type ${opts.type} yet negative (${value})`); @@ -83,7 +84,7 @@ export class ScInt extends XdrLargeInt { // of the value, treating 64 as a minimum and 256 as a maximum. if (type === '') { type = signed ? 'i' : 'u'; - const bitlen = nearestBigIntSize(value); + const bitlen = nearestBigIntSize(bigValue); switch (bitlen) { case 64: @@ -99,7 +100,7 @@ export class ScInt extends XdrLargeInt { } } - super(type, value); + super(type, bigValue); } } diff --git a/src/soroban.js b/src/soroban.js index 1316bad49..a92ac443d 100644 --- a/src/soroban.js +++ b/src/soroban.js @@ -69,6 +69,12 @@ export class Soroban { throw new Error(`Invalid decimal value: ${value}`); } + if (fraction?.length > decimals) { + throw new Error( + `Too many decimal places in "${value}": expected at most ${decimals}, got ${fraction.length}` + ); + } + const shifted = BigInt( whole + (fraction?.padEnd(decimals, '0') ?? '0'.repeat(decimals)) ); diff --git a/src/transaction_base.js b/src/transaction_base.js index 7ba4339e7..acda6af7c 100644 --- a/src/transaction_base.js +++ b/src/transaction_base.js @@ -60,7 +60,7 @@ export class TransactionBase { } set networkPassphrase(networkPassphrase) { - this._networkPassphrase = networkPassphrase; + throw new Error('Transaction is immutable'); } /** diff --git a/test/unit/auth_test.js b/test/unit/auth_test.js index 7446ea289..39d30f953 100644 --- a/test/unit/auth_test.js +++ b/test/unit/auth_test.js @@ -99,4 +99,72 @@ describe('building authorization entries', function () { .then((signedEntry) => done()) .catch((err) => done(err)); }); + + describe('nonce generation uses all 8 bytes', function () { + let sandbox; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + function stubRawBytes(first8) { + const raw = new Uint8Array(32); + raw.set(first8); + sandbox + .stub(StellarBase.Keypair, 'random') + .returns({ rawPublicKey: () => raw }); + } + + // Regression: the old `<<` (Int32 shift) implementation discarded the upper 4 + // bytes. bytes [0,0,0,1, 0,0,0,0] — after consuming bytes 0-3 the accumulator + // reaches 1, then four more left-shifts overflow Int32 back to 0. The nonce was + // 0 instead of the correct 2^32. + it('upper 4 bytes contribute to the nonce', async function () { + stubRawBytes([0, 0, 0, 1, 0, 0, 0, 0]); + const entry = await StellarBase.authorizeInvocation( + kp, + 10, + authEntry.rootInvocation() + ); + expect(entry.credentials().address().nonce()).to.eql( + new xdr.Int64(4294967296n) + ); // 2^32 + }); + + it('all-0xFF bytes produce nonce -1 (signed Int64 all-bits-set)', async function () { + stubRawBytes([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + const entry = await StellarBase.authorizeInvocation( + kp, + 10, + authEntry.rootInvocation() + ); + expect(entry.credentials().address().nonce()).to.eql(new xdr.Int64(-1n)); + }); + + it('high bit set produces Int64 minimum value', async function () { + stubRawBytes([0x80, 0, 0, 0, 0, 0, 0, 0]); + const entry = await StellarBase.authorizeInvocation( + kp, + 10, + authEntry.rootInvocation() + ); + expect(entry.credentials().address().nonce()).to.eql( + new xdr.Int64(-9223372036854775808n) + ); // -(2^63), Int64 minimum + }); + + it('all-zero bytes produce nonce 0', async function () { + stubRawBytes([0, 0, 0, 0, 0, 0, 0, 0]); + const entry = await StellarBase.authorizeInvocation( + kp, + 10, + authEntry.rootInvocation() + ); + expect(entry.credentials().address().nonce()).to.eql(new xdr.Int64(0n)); + }); + }); }); diff --git a/test/unit/scint_test.js b/test/unit/scint_test.js index 05b009627..392287966 100644 --- a/test/unit/scint_test.js +++ b/test/unit/scint_test.js @@ -8,8 +8,12 @@ describe('creating large integers', function () { describe('picks the right types', function () { Object.entries({ u64: [1, '1', 0xdeadbeef, (1n << 64n) - 1n], - u128: [1n << 64n, (1n << 128n) - 1n], - u256: [1n << 128n, (1n << 256n) - 1n] + u128: [1n << 64n, (1n << 128n) - 1n, '18446744073709551616'], + u256: [ + 1n << 128n, + (1n << 256n) - 1n, + '340282366920938463463374607431768211456' + ] }).forEach(([type, values]) => { values.forEach((value) => { it(`picks ${type} for ${value}`, function () { @@ -276,6 +280,12 @@ describe('creating large integers', function () { }); }); + it('correctly sizes 2^64 passed as a string', function () { + const i = new StellarBase.ScInt('18446744073709551616'); + expect(i.type).to.equal('u128'); + expect(i.toBigInt()).to.equal(18446744073709551616n); + }); + it('throws when too big', function () { expect(() => new StellarBase.ScInt(1n << 400n)).to.throw(/expected/i); }); diff --git a/test/unit/soroban_test.js b/test/unit/soroban_test.js index 8ccfed689..227dbef55 100644 --- a/test/unit/soroban_test.js +++ b/test/unit/soroban_test.js @@ -64,6 +64,11 @@ describe('Soroban', function () { amount: '1000000.001.1', decimals: 7, expected: /Invalid decimal value/i + }, + { + amount: '1.999999', + decimals: 2, + expected: /Too many decimal places/i } ]; diff --git a/test/unit/transaction_test.js b/test/unit/transaction_test.js index 5f1c351fa..8e0e7d098 100644 --- a/test/unit/transaction_test.js +++ b/test/unit/transaction_test.js @@ -745,6 +745,32 @@ describe('Transaction', function () { expect(signers[0]).to.eql(StellarBase.SignerKey.decodeAddress(address)); }); }); + describe('immutability', function () { + it('throws when setting networkPassphrase', function () { + const source = new StellarBase.Account( + 'GBBM6BKZPEHWYO3E3YKREDPQXMS4VK35YLNU7NFBRI26RAN7GI5POFBB', + '0' + ); + const tx = new StellarBase.TransactionBuilder(source, { + fee: 100, + networkPassphrase: StellarBase.Networks.TESTNET + }) + .addOperation( + StellarBase.Operation.payment({ + destination: + 'GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2', + asset: StellarBase.Asset.native(), + amount: '100' + }) + ) + .setTimeout(StellarBase.TimeoutInfinite) + .build(); + + expect(() => { + tx.networkPassphrase = 'other'; + }).to.throw(/immutable/i); + }); + }); }); function expectBuffersToBeEqual(left, right) {