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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@
/coverage/
/jsdoc/
.DS_Store
.claude/
.idea/
.vscode/
.copilot/
tsconfig.tsbuildinfo
14 changes: 13 additions & 1 deletion src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
7 changes: 4 additions & 3 deletions src/numbers/sc_int.js
Original file line number Diff line number Diff line change
Expand Up @@ -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})`);
Expand All @@ -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:
Expand All @@ -99,7 +100,7 @@ export class ScInt extends XdrLargeInt {
}
}

super(type, value);
super(type, bigValue);
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/soroban.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
);
Expand Down
2 changes: 1 addition & 1 deletion src/transaction_base.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class TransactionBase {
}

set networkPassphrase(networkPassphrase) {
this._networkPassphrase = networkPassphrase;
throw new Error('Transaction is immutable');
}
Comment on lines 62 to 64
Copy link
Contributor

Choose a reason for hiding this comment

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

This will have to be noted as a breaking change

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍 I'll update changelog once both "fixes" PRs are merged.

Copy link
Contributor

Choose a reason for hiding this comment

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

Arguably it doesn't have to be, you could call it a bug fix


/**
Expand Down
68 changes: 68 additions & 0 deletions test/unit/auth_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});
14 changes: 12 additions & 2 deletions test/unit/scint_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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);
});
Expand Down
5 changes: 5 additions & 0 deletions test/unit/soroban_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
];

Expand Down
26 changes: 26 additions & 0 deletions test/unit/transaction_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading