ripple-binary-codec
Version:
XRP Ledger binary codec
234 lines (210 loc) • 8.18 kB
text/typescript
import { BinaryParser } from '../serdes/binary-parser'
import { SerializedType } from './serialized-type'
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_EXPONENT = -32768
const MAX_EXPONENT = 32768
const DEFAULT_VALUE_EXPONENT = -2147483648
/**
* Extract mantissa, exponent, and sign from a number string.
*
* @param val - The string representing the number (may be integer, decimal, or scientific notation).
* @returns Object containing mantissa (BigInt), exponent (number), and isNegative (boolean).
* @throws Error if the string cannot be parsed as a valid number.
*
* Examples:
* '123' -> { mantissa: 123n, exponent: 0, isNegative: false }
* '-00123.45' -> { mantissa: -12345n, exponent: -2, isNegative: true }
* '+7.1e2' -> { mantissa: 71n, exponent: -1 + 2 = 1, isNegative: false }
*/
function extractNumberPartsFromString(val: string): {
mantissa: bigint
exponent: number
isNegative: boolean
} {
/**
* Regex for parsing decimal/float/scientific number strings with optional sign, integer, decimal, and exponent parts.
*
* Pattern: /^([-+]?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/
*
* Breakdown:
* 1. ([-+]?) - Optional '+' or '-' sign at the start.
* 2. ([0-9]+) - Integer part: one or more digits (leading zeros allowed).
* 3. (?:\.([0-9]+))? - Optional decimal point followed by one or more digits.
* 4. (?:[eE]([+-]?[0-9]+))? - Optional exponent, starting with 'e' or 'E', optional sign, and digits.
*
* Notes:
* - Leading zeros are accepted and normalized by code after parsing.
* - Empty decimal ('123.') and missing integer ('.456') are NOT matched—must be fully specified.
*/
const regex = /^([-+]?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/
const match = regex.exec(val)
if (!match) throw new Error(`Unable to parse number from string: ${val}`)
const [, sign, intPart, fracPart, expPart] = match
// Remove leading zeros (unless the entire intPart is zeros)
const cleanIntPart = intPart.replace(/^0+(?=\d)/, '') || '0'
let mantissaStr = cleanIntPart
let exponent = 0
if (fracPart) {
mantissaStr += fracPart
exponent -= fracPart.length
}
if (expPart) exponent += parseInt(expPart, 10)
let mantissa = BigInt(mantissaStr)
if (sign === '-') mantissa = -mantissa
const isNegative = mantissa < BigInt(0)
return { mantissa, exponent, isNegative }
}
/**
* Normalize the mantissa and exponent to XRPL constraints.
*
* Ensures that after normalization, the mantissa is between MIN_MANTISSA and MAX_MANTISSA (unless zero).
* Adjusts the exponent as needed by shifting the mantissa left/right (multiplying/dividing by 10).
*
* @param mantissa - The unnormalized mantissa (BigInt).
* @param exponent - The unnormalized exponent (number).
* @returns An object with normalized mantissa and exponent.
* @throws Error if the number cannot be normalized within allowed exponent range.
*/
function normalize(
mantissa: bigint,
exponent: number,
): { mantissa: bigint; exponent: number } {
let m = mantissa < BigInt(0) ? -mantissa : mantissa
const isNegative = mantissa < BigInt(0)
while (m !== BigInt(0) && 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')
exponent += 1
m /= BigInt(10)
}
if (isNegative) m = -m
return { mantissa: m, exponent }
}
/**
* STNumber: Encodes XRPL's "Number" type.
*
* - Always encoded as 12 bytes: 8-byte signed mantissa, 4-byte signed exponent, both big-endian.
* - Can only be constructed from a valid number string or another STNumber instance.
*
* Usage:
* STNumber.from("1.2345e5")
* STNumber.from("-123")
* STNumber.fromParser(parser)
*/
export class STNumber extends SerializedType {
/** 12 zero bytes, represents canonical zero. */
static defaultBytes = new Uint8Array(12)
/**
* Construct a STNumber from 12 bytes (8 for mantissa, 4 for exponent).
* @param bytes - 12-byte Uint8Array
* @throws Error if input is not a Uint8Array of length 12.
*/
constructor(bytes?: Uint8Array) {
const used = bytes ?? STNumber.defaultBytes
if (!(used instanceof Uint8Array) || used.length !== 12) {
throw new Error(
`STNumber must be constructed from a 12-byte Uint8Array, got ${used?.length}`,
)
}
super(used)
}
/**
* Construct from a number string (or another STNumber).
*
* @param value - A string, or STNumber instance.
* @returns STNumber instance.
* @throws Error if not a string or STNumber.
*/
static from(value: unknown): STNumber {
if (value instanceof STNumber) {
return value
}
if (typeof value === 'string') {
return STNumber.fromValue(value)
}
throw new Error(
'STNumber.from: Only string or STNumber instance is supported',
)
}
/**
* Construct from a number string (integer, decimal, or scientific notation).
* Handles normalization to XRPL Number constraints.
*
* @param val - The number as a string (e.g. '1.23', '-123e5').
* @returns STNumber instance
* @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 bytes = new Uint8Array(12)
writeInt64BE(bytes, normalizedMantissa, 0)
writeInt32BE(bytes, normalizedExponent, 8)
return new STNumber(bytes)
}
/**
* Read a STNumber from a BinaryParser stream (12 bytes).
* @param parser - BinaryParser positioned at the start of a number
* @returns STNumber instance
*/
static fromParser(parser: BinaryParser): STNumber {
return new STNumber(parser.read(12))
}
/**
* Convert this STNumber to a normalized string representation.
* The output is decimal or scientific notation, depending on exponent range.
* Follows XRPL convention: zero is "0", other values are normalized to a canonical string.
*
* @returns String representation of the value
*/
// eslint-disable-next-line complexity -- required
toJSON(): string {
const b = this.bytes
if (!b || b.length !== 12)
throw new Error('STNumber internal bytes not set or wrong length')
// Signed 64-bit mantissa
const mantissa = readInt64BE(b, 0)
// Signed 32-bit exponent
const 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}`
}
// Decimal rendering for -25 <= exp <= -5
const isNegative = mantissa < BigInt(0)
const mantissaAbs = mantissa < BigInt(0) ? -mantissa : mantissa
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+$/, '')
return `${isNegative ? '-' : ''}${integerPart}${
fractionPart ? '.' + fractionPart : ''
}`
}
}