Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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
14 changes: 7 additions & 7 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,28 @@ on:
push:
branches: [main]
tags:
- '**'
- "**"
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
workflow_call:
inputs:
git_ref:
description: 'Git ref to checkout (branch, tag, or commit SHA)'
description: "Git ref to checkout (branch, tag, or commit SHA)"
required: true
type: string
run_unit_tests:
description: 'Run unit tests job'
description: "Run unit tests job"
required: false
type: boolean
default: true
run_integration_tests:
description: 'Run integration tests job'
description: "Run integration tests job"
required: false
type: boolean
default: true
run_browser_tests:
description: 'Run browser tests job'
description: "Run browser tests job"
required: false
type: boolean
default: true
Expand Down Expand Up @@ -141,7 +141,7 @@ jobs:

- name: Run docker in background
run: |
docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "rippled -a"
docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "mkdir -p /var/lib/rippled/db/ && rippled -a"

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
Expand Down Expand Up @@ -201,7 +201,7 @@ jobs:

- name: Run docker in background
run: |
docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "rippled -a"
docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "mkdir -p /var/lib/rippled/db/ && rippled -a"

- name: Setup npm version 10
run: |
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ From the top-level xrpl.js folder (one level above `packages`), run the followin
```bash
npm install
# sets up the rippled standalone Docker container - you can skip this step if you already have it set up
docker run -p 6006:6006 --rm -it --name rippled_standalone --volume $PWD/.ci-config:/etc/opt/ripple/ --entrypoint bash rippleci/rippled:develop -c 'rippled -a'
docker run -p 6006:6006 --rm -it --name rippled_standalone --volume $PWD/.ci-config:/etc/opt/ripple/ --entrypoint bash rippleci/rippled:develop -c 'mkdir -p /var/lib/rippled/db/ && rippled -a'
npm run build
npm run test:integration
```
Expand Down
3 changes: 3 additions & 0 deletions packages/ripple-binary-codec/HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Fixed
* Fix STNumber serialization logic to work with large mantissa scale [10^18, 10^19-1].

## 2.6.0 (2025-12-16)

### Added
Expand Down
7 changes: 6 additions & 1 deletion packages/ripple-binary-codec/src/enums/definitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@
}
],
[
"PreviousPaymentDate",
"PreviousPaymentDueDate",
{
"isSerialized": true,
"isSigningField": true,
Expand Down Expand Up @@ -3549,6 +3549,7 @@
"tecXCHAIN_SELF_COMMIT": 184,
"tecXCHAIN_SENDING_ACCOUNT_MISMATCH": 179,
"tecXCHAIN_WRONG_CHAIN": 176,

"tefALREADY": -198,
"tefBAD_ADD_AUTH": -197,
"tefBAD_AUTH": -196,
Expand All @@ -3571,6 +3572,7 @@
"tefPAST_SEQ": -190,
"tefTOO_BIG": -181,
"tefWRONG_PRIOR": -189,

"telBAD_DOMAIN": -398,
"telBAD_PATH_COUNT": -397,
"telBAD_PUBLIC_KEY": -396,
Expand All @@ -3588,6 +3590,7 @@
"telNO_DST_PARTIAL": -393,
"telREQUIRES_NETWORK_ID": -385,
"telWRONG_NETWORK": -386,

"temARRAY_EMPTY": -253,
"temARRAY_TOO_LARGE": -252,
"temBAD_AMM_TOKENS": -261,
Expand Down Expand Up @@ -3638,6 +3641,7 @@
"temXCHAIN_BRIDGE_BAD_REWARD_AMOUNT": -255,
"temXCHAIN_BRIDGE_NONDOOR_OWNER": -257,
"temXCHAIN_EQUAL_DOOR_ACCOUNTS": -260,

"terADDRESS_COLLISION": -86,
"terFUNDS_SPENT": -98,
"terINSUF_FEE_B": -97,
Expand All @@ -3653,6 +3657,7 @@
"terPRE_TICKET": -88,
"terQUEUED": -89,
"terRETRY": -99,

"tesSUCCESS": 0
},
"TRANSACTION_TYPES": {
Expand Down
109 changes: 77 additions & 32 deletions packages/ripple-binary-codec/src/types/st-number.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable complexity -- required for various checks */
import { BinaryParser } from '../serdes/binary-parser'
import { SerializedType } from './serialized-type'
import { writeInt32BE, writeInt64BE, readInt32BE, readInt64BE } from '../utils'
Expand All @@ -6,8 +7,8 @@ import { writeInt32BE, writeInt64BE, readInt32BE, readInt64BE } from '../utils'
* Constants for mantissa and exponent normalization per XRPL Number spec.
* These define allowed magnitude for mantissa and exponent after normalization.
*/
const MIN_MANTISSA = BigInt('1000000000000000')
const MAX_MANTISSA = BigInt('9999999999999999')
const MIN_MANTISSA = BigInt('1000000000000000000') // 10^18
const MAX_INT64 = BigInt('9223372036854775807') // 2^63 - 1, max signed 64-bit integer
const MIN_EXPONENT = -32768
const MAX_EXPONENT = 32768
const DEFAULT_VALUE_EXPONENT = -2147483648
Expand Down Expand Up @@ -72,7 +73,7 @@ function extractNumberPartsFromString(val: string): {
/**
* Normalize the mantissa and exponent to XRPL constraints.
*
* Ensures that after normalization, the mantissa is between MIN_MANTISSA and MAX_MANTISSA (unless zero).
* Ensures that after normalization, the mantissa is between MIN_MANTISSA and MAX_INT64.
* Adjusts the exponent as needed by shifting the mantissa left/right (multiplying/dividing by 10).
*
* @param mantissa - The unnormalized mantissa (BigInt).
Expand All @@ -87,16 +88,41 @@ function normalize(
let m = mantissa < BigInt(0) ? -mantissa : mantissa
const isNegative = mantissa < BigInt(0)

while (m !== BigInt(0) && m < MIN_MANTISSA && exponent > MIN_EXPONENT) {
if (m > MAX_INT64) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

just want to make sure, m here is already the mantissa with all trailing zeroes stripped, right?

Copy link
Collaborator Author

@Patel-Raj11 Patel-Raj11 Jan 27, 2026

Choose a reason for hiding this comment

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

No, if the user enters the input as 990000000000000. Then m here would be 990000000000000. But, I think it should be changed to the following to provide more room for other digits:

  1. 99000 -> m = 99, exponent = 3
  2. 99.99000 -> m = 9999, exponent = -2
  3. 0.0025 -> m = 25, exponent = -4
  4. 0.002500 -> m = 25, exponent = -4
  5. 0.250025 -> m = 250025, exponent = -6

I will make that change and add some tests.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I removed trailing zeroes from mantissa and added tests that passes (Otherwise would have failed due to m > MAX_INT64) in 9e3c80e

throw new Error('Mantissa overflow: value too large to represent')
}

// Handle zero
if (m === BigInt(0)) {
return { mantissa: BigInt(0), exponent: DEFAULT_VALUE_EXPONENT }
}

// Grow mantissa until it reaches MIN_MANTISSA
while (m < MIN_MANTISSA && exponent > MIN_EXPONENT) {
exponent -= 1
m *= BigInt(10)
}
while (m > MAX_MANTISSA) {
if (exponent >= MAX_EXPONENT)
throw new Error('Mantissa and exponent are too large')

// Handle underflow: if exponent too small or mantissa too small, throw error
if (exponent < MIN_EXPONENT || m < MIN_MANTISSA) {
throw new Error('Underflow: value too small to represent')
}

// Handle overflow: if exponent exceeds MAX_EXPONENT after growing.
if (exponent > MAX_EXPONENT) {
throw new Error('Exponent overflow: value too large to represent')
}

// Handle overflow: if mantissa exceeds MAX_INT64 (2^63-1) after growing.
// Rounding is not required because last digit is always 0 after growing.
if (m > MAX_INT64) {
if (exponent >= MAX_EXPONENT) {
throw new Error('Exponent overflow: value too large to represent')
}
exponent += 1
m /= BigInt(10)
}

Comment on lines +124 to +131
Copy link
Collaborator

@shawnxie999 shawnxie999 Jan 27, 2026

Choose a reason for hiding this comment

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

just want to make sure, do we really need to divide here

    exponent += 1
    m /= BigInt(10)

Take 99e20 as example. Initially the beginning of function, m is 99 and exponent is 20.

After m is brought to at least MIN_MANTISSA, m becomes 990,000,000,000,000,000 (18 digits) and exponent is 4.

m = 990,000,000,000,000,000 and exponent = 4 is already an acceptable/serializable input. So I don't believe we need to check if it's greater than INT64_MAX and to divide it further

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Actually after bringing m to be at-least MIN_MANTISSA, it's value becomes 9,900,000,000,000,000,000 and that is > INT64_MAX. So we are reducing mantissa by 10 and increasing exponent by 1. I added a test where the binary is taken from standalone rippled instance and json is what we get if we sign the same transaction using xrpl.js - 0e31a83

Copy link
Collaborator

Choose a reason for hiding this comment

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

sorry my bad, yes 9,900,000,000,000,000,000 would be m, but I believe that is still acceptable as long as it's less than MAX_MANTISSA

Copy link
Collaborator

Choose a reason for hiding this comment

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

what would happen if we removed this block of code? Would any tests fail?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ya, the transaction fails with temMALFORMED if we remove the if block.

Copy link
Collaborator

@shawnxie999 shawnxie999 Jan 27, 2026

Choose a reason for hiding this comment

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

I just tested in rippled. A number with m = 99 and exponent = 20 would turn into m = 9,900,000,000,000,000,000 (19 digits) and exponent = 3 after normalization.

So m can actually be greater than INT64_MAX, as long as the significant digits (99) is less than INT64_MAX

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm wondering what removing this if-block would do, since you just pushed a change that removes trailing zeroes from the mantissa

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If we remove the if-block (do not bring back increased manitssa <= INT64_MAX) the transaction fails with temMALFORMED because the transaction that got submitted does not have properly encoded AssetsMaximum because we use writeInt64BE to write the mantissa into bytes and now it's value is beyond INT64_MAX.

const bytes = new Uint8Array(12)
writeInt64BE(bytes, normalizedMantissa, 0)
writeInt32BE(bytes, normalizedExponent, 8)

This is the transaction that essentially gets submitted to rippled and fails.

{
        TransactionType: 'VaultCreate',
        Sequence: 65,
        Fee: '5000000',
        SigningPubKey: 'ED8455056CF70CAFB7F569C4319EAD3875295B6B4DA0AD5AA57C4E1A690B0AC3E5',
        TxnSignature: '32A5DD46982A7F2FEDD06CE85C95B2B5158FB26787D279FB36DF16C592FB7F576EDAA59A71D392362E255EB108B342D06B1E2010CFEA8198FB93529B6082170E',
        Account: 'rHHJLEQ42gJrakkCfNebAkKFiYZPmFw6u3',
        AssetsMaximum: '-8546744073709551616e3',
        Asset: { currency: 'USD', issuer: 'rGertFz66AA5zJKMijfBZ8UBuWWMjLRbHo' }
}

However, if we truncate the manitssa to 99 (so that significant digits are less than INT64_MAX) and move all the trailing zeroes to exponent to just before encoding it to bytes, the transaction fails at rippled with fails local checks: Invalid signature..

Can you help in confirming that if we need to use writeUInt64BE for encoding so that we can go beyond signed INT64_MAX but stay within MAX_MANTISSA while encoding?

Also, if we keep the current logic (truncating if it goes beyond INT64_MAX) this is the bytes representation 0DBD2FC137A3000000000004 for 99e20 when encoded. Can you check if you get the same representation in rippled when a request is parsed when received using sign and submit mode of submit command?

if (isNegative) m = -m
return { mantissa: m, exponent }
}
Expand Down Expand Up @@ -159,17 +185,9 @@ export class STNumber extends SerializedType {
* @throws Error if val is not a valid number string.
*/
static fromValue(val: string): STNumber {
const { mantissa, exponent, isNegative } = extractNumberPartsFromString(val)
let normalizedMantissa: bigint
let normalizedExponent: number

if (mantissa === BigInt(0) && exponent === 0 && !isNegative) {
normalizedMantissa = BigInt(0)
normalizedExponent = DEFAULT_VALUE_EXPONENT
} else {
;({ mantissa: normalizedMantissa, exponent: normalizedExponent } =
normalize(mantissa, exponent))
}
const { mantissa, exponent } = extractNumberPartsFromString(val)
const { mantissa: normalizedMantissa, exponent: normalizedExponent } =
normalize(mantissa, exponent)

const bytes = new Uint8Array(12)
writeInt64BE(bytes, normalizedMantissa, 0)
Expand All @@ -193,7 +211,6 @@ export class STNumber extends SerializedType {
*
* @returns String representation of the value
*/
// eslint-disable-next-line complexity -- required
toJSON(): string {
const b = this.bytes
if (!b || b?.length !== 12)
Expand All @@ -202,30 +219,58 @@ export class STNumber extends SerializedType {
// Signed 64-bit mantissa
const mantissa = readInt64BE(b, 0)
// Signed 32-bit exponent
const exponent = readInt32BE(b, 8)
let exponent = readInt32BE(b, 8)

// Special zero: XRPL encodes canonical zero as mantissa=0, exponent=DEFAULT_VALUE_EXPONENT.
if (mantissa === BigInt(0) && exponent === DEFAULT_VALUE_EXPONENT) {
return '0'
}
if (exponent === 0) return mantissa.toString()

// Use scientific notation for small/large exponents, decimal otherwise
if (exponent < -25 || exponent > -5) {
return `${mantissa}e${exponent}`
const isNegative = mantissa < BigInt(0)
let mantissaAbs = isNegative ? -mantissa : mantissa

// If mantissa < MIN_MANTISSA, it was shrunk for int64 serialization (mantissa > 2^63-1).
// Restore it for proper string rendering to match rippled's internal representation.
if (mantissaAbs !== BigInt(0) && mantissaAbs < MIN_MANTISSA) {
mantissaAbs *= BigInt(10)
exponent -= 1
}

// Decimal rendering for -25 <= exp <= -5
const isNegative = mantissa < BigInt(0)
const mantissaAbs = mantissa < BigInt(0) ? -mantissa : mantissa
// For large mantissa range (default), rangeLog = 18
const rangeLog = 18

// Use scientific notation for exponents that are too small or too large
// Condition from rippled: exponent != 0 AND (exponent < -(rangeLog + 10) OR exponent > -(rangeLog - 10))
// For rangeLog = 18: exponent != 0 AND (exponent < -28 OR exponent > -8)
if (
exponent !== 0 &&
(exponent < -(rangeLog + 10) || exponent > -(rangeLog - 10))
) {
// Strip trailing zeros from mantissa (matches rippled behavior)
let exp = exponent
while (
mantissaAbs !== BigInt(0) &&
mantissaAbs % BigInt(10) === BigInt(0) &&
exp < MAX_EXPONENT
) {
mantissaAbs /= BigInt(10)
exp += 1
}
const sign = isNegative ? '-' : ''
return `${sign}${mantissaAbs}e${exp}`
}

// Decimal rendering for -(rangeLog + 10) <= exponent <= -(rangeLog - 10)
// i.e., -28 <= exponent <= -8, or exponent == 0
const padPrefix = rangeLog + 12 // 30
const padSuffix = rangeLog + 8 // 26

const padPrefix = 27
const padSuffix = 23
const mantissaStr = mantissaAbs.toString()
const rawValue = '0'.repeat(padPrefix) + mantissaStr + '0'.repeat(padSuffix)
const OFFSET = exponent + 43
const integerPart = rawValue.slice(0, OFFSET).replace(/^0+/, '') || '0'
const fractionPart = rawValue.slice(OFFSET).replace(/0+$/, '')
const offset = exponent + padPrefix + rangeLog + 1 // exponent + 49

const integerPart = rawValue.slice(0, offset).replace(/^0+/, '') || '0'
const fractionPart = rawValue.slice(offset).replace(/0+$/, '')

return `${isNegative ? '-' : ''}${integerPart}${
fractionPart ? '.' + fractionPart : ''
Expand Down
38 changes: 38 additions & 0 deletions packages/ripple-binary-codec/test/fixtures/codec-fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -5127,6 +5127,44 @@
"TransactionType": "VaultCreate",
"TxnSignature": "A7C6C1EE9989F9F195A02BEA4DCFEBB887E4CA1F4D30083C84616E0FD1BCA4F4C1B84A6DA26A44B94FBBDA67FB603C78995361DEAF8120093959C639E9255702"
}
},
{
"binary": "120041240000001A6840000000009896807321ED5E83997E2D57EF5C32DFF74AAA836E5C149CA9E49711D220289E610975560EC474401BCB501B08FAA564E4DB82843E77024585827EC1C3FEB784F656215DC3B79C90CE0B387B33661219B074E8CCF965CF2824A92B79FFB9C4A9F988F7AB19B45502701B0E7661756C74206D65746164617461701E0E7368617265206D657461646174618114B67FFA466543506DF81A26FBA98CFB604DE5FE44930DE0B6B3A763FF9C0000004E00101401031800000000000000000000000055534400000000002B13011144920AF528764094A05ADFE3C3DD58B7",
"json": {
"TransactionType": "VaultCreate",
"Sequence": 26,
"Fee": "10000000",
"SigningPubKey": "ED5E83997E2D57EF5C32DFF74AAA836E5C149CA9E49711D220289E610975560EC4",
"TxnSignature": "1BCB501B08FAA564E4DB82843E77024585827EC1C3FEB784F656215DC3B79C90CE0B387B33661219B074E8CCF965CF2824A92B79FFB9C4A9F988F7AB19B45502",
"Data": "7661756C74206D65746164617461",
"MPTokenMetadata": "7368617265206D65746164617461",
"Account": "rHdyHxQMCKyoUFeR1GW7F6pt4AR7r6D4b4",
"AssetsMaximum": "9999999999999999e80",
"WithdrawalPolicy": 1,
"Asset": {
"currency": "USD",
"issuer": "rhvkFgXoWfrDJJHdV3hKTcqXQk7ChXtf18"
}
}
},
{
"binary": "120041240000002F6840000000009896807321ED65C3F14697756CF0952CE5B00E2374E4D91C7B74687B19C451317B67D52C3B4F7440733B3F7C63271A62E55335D4BB62448ED459ACF7BE03D624844F696406DB84A2D3FDF638F16DB4A3B6CB9BD463756003F795AA26AFCA9EB5BA53FF1ADFC4E50F701B0E7661756C74206D65746164617461701E0E7368617265206D65746164617461811499EBF31121DE67CD1015F13328E18EE2490A139D931122D7D8F56AFD68FFFFFFF500101401031800000000000000000000000055534400000000002FD289D349D05565C1D43CF5D694EECB5D8C3FF0",
"json": {
"TransactionType": "VaultCreate",
"Sequence": 47,
"Fee": "10000000",
"SigningPubKey": "ED65C3F14697756CF0952CE5B00E2374E4D91C7B74687B19C451317B67D52C3B4F",
"TxnSignature": "733B3F7C63271A62E55335D4BB62448ED459ACF7BE03D624844F696406DB84A2D3FDF638F16DB4A3B6CB9BD463756003F795AA26AFCA9EB5BA53FF1ADFC4E50F",
"Data": "7661756C74206D65746164617461",
"MPTokenMetadata": "7368617265206D65746164617461",
"Account": "rEp1sQdJbwDKmhMWmdJgx9ywnoGGr4oxKu",
"AssetsMaximum": "12347865.746832746",
"WithdrawalPolicy": 1,
"Asset": {
"currency": "USD",
"issuer": "rnMiy78qjm8KwantFnrfouvMGqzCR7apap"
}
}
}
],
"ledgerData": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe('Signing data', function () {
'B32A0D322D38281C81D4F49DCCDC260A81879B57',
// PrincipalRequested
'9E',
'00038D7EA4C68000FFFFFFF6',
'0DE0B6B3A7640000FFFFFFF3',
// signingAccount suffix
'BF9B4C3302798C111649BFA38DB60525C6E1021C',
].join(''),
Expand Down
Loading
Loading