Skip to content

Commit 500a842

Browse files
khancodedependabot[bot]Patel-Raj11ckeshavaachowdhry-ripple
authored
feat: add Single Asset Vault (XLS-65) (#3008)
* update HISTORY files * enable SAV amendment * add VaultCreate tx and update autofill * build(deps-dev): bump karma from 6.4.3 to 6.4.4 (#2753) * build(deps): bump @scure/bip39 from 1.5.4 to 1.6.0 (#3004) * Fix AccountRoot ledger object (#3010) * fix AccountRoot ledger object * update HISTORY.md * fix import and lint errors * request a validated ledger * build(deps-dev): bump react from 19.0.0 to 19.1.0 (#2968) * upgrade ws dependency (#2940) Co-authored-by: achowdhry-ripple <achowdhry@ripple.com> * build(deps-dev): bump webpack from 5.98.0 to 5.99.9 (#3015) Bumps [webpack](https://github.com/webpack/webpack) from 5.98.0 to 5.99.9. - [Release notes](https://github.com/webpack/webpack/releases) - [Commits](webpack/webpack@v5.98.0...v5.99.9) --- updated-dependencies: - dependency-name: webpack dependency-version: 5.99.9 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: achowdhry-ripple <achowdhry@ripple.com> Co-authored-by: Omar Khan <khancodegt@gmail.com> * add Vault ledger entry and base integ test * add rippled serialized type Number. update models & tests to support it * fix: add conditional check for `PermissionValue` definition (#3018) * add conditional check for PermissionValue * update HISTORY * remove release date * add unit test * ripple-binary-codec 2.4.1 patch release (#3019) * update HISTORY * update package.json files * update package-lock.json * test: Separate faucet tests from local integration tests (#2985) * refactor(tests): separate faucet tests from local integration tests * feat: add Faucet test workflow and documentation * fix: Add missing test:faucet script * fix: execute tests one time Co-authored-by: Raj Patel <rajp@ripple.com> * fix: remove docker steps from faucet test workflow * docs: update faucet tests section in CONTRIBUTING.md * fix: remove comment from contributing.md Co-authored-by: Raj Patel <rajp@ripple.com> * fix: remove test:all from root package Co-authored-by: Raj Patel <rajp@ripple.com> * Update CONTRIBUTING.md --------- Co-authored-by: Raj Patel <rajp@ripple.com> * update number test * debug * set alias Number to SerializedNumber in coreTypes * update fee in VaultCreate integ test * up the fee * up more * debug fee * update fee with comment * add vault_info RPC with integ test * remove unused var in test * use vault_id * update vaultInfo test * add owner and seq params to vault_info request * add DomainID to vault_info response * add XRPLNumber type and VaultSet tx * rename SerializedNumber to XRPLNumber * add VaultSet to integ test * refactor DEFAULT_VAULT_WITHDRAWAL_POLICY location * fix import bug * add VaultDeposit tx * add VaultDeposit to integ test * add VaultWithdraw tx * add VaultWithdraw to integ test * add VaultClawback tx * add VaultClawback to integ test * use 2 wallets for integ test * comment out VaultClawback tx in integ test * use IOU in integ test * add ClawbackAmount type and VaultClawback to integ test * add VaultDelete tx * remove isBigInt * add VaultDelete to integ test * rename to STNumber * comment out Amount in VaultClawback integ test * Revert "comment out Amount in VaultClawback integ test" This reverts commit 416c1e1. * comment out WithdrawalPolicy in VaultCreate integ test * refactor WithdrawalPolicy * cleanup * cleanup vars * update HISTORY * rename var to VAULT_DATA_MAX_BYTE_LENGTH * use STNumber class in static function * add VaultCreate to codec-fixtures * refactor unit tests * update HISTORY --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Raj Patel <rajp@ripple.com> Co-authored-by: Chenna Keshava B S <21219765+ckeshava@users.noreply.github.com> Co-authored-by: achowdhry-ripple <achowdhry@ripple.com> Co-authored-by: Achal Jhawar <35405812+achaljhawar@users.noreply.github.com>
1 parent 85521bd commit 500a842

35 files changed

+2043
-12
lines changed

.ci-config/rippled.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ fixInvalidTxFlags
190190
# 2.5.0 Amendments
191191
PermissionDelegation
192192
Batch
193+
SingleAssetVault
193194

194195
# This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode
195196
[voting]

packages/ripple-binary-codec/HISTORY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Added
6+
* Support for `Single Asset Vault` (XLS-65)
7+
* Adds new `STNumber` serialization type.
8+
59
## 2.4.1 (2025-6-18)
610

711
### Fixed

packages/ripple-binary-codec/src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Hash160 } from './hash-160'
77
import { Hash192 } from './hash-192'
88
import { Hash256 } from './hash-256'
99
import { Issue } from './issue'
10+
import { STNumber } from './st-number'
1011
import { PathSet } from './path-set'
1112
import { STArray } from './st-array'
1213
import { STObject } from './st-object'
@@ -29,6 +30,7 @@ const coreTypes: Record<string, typeof SerializedType> = {
2930
Hash192,
3031
Hash256,
3132
Issue,
33+
Number: STNumber,
3234
PathSet,
3335
STArray,
3436
STObject,
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { BinaryParser } from '../serdes/binary-parser'
2+
import { SerializedType } from './serialized-type'
3+
import { writeInt32BE, writeInt64BE, readInt32BE, readInt64BE } from '../utils'
4+
5+
/**
6+
* Constants for mantissa and exponent normalization per XRPL Number spec.
7+
* These define allowed magnitude for mantissa and exponent after normalization.
8+
*/
9+
const MIN_MANTISSA = BigInt('1000000000000000')
10+
const MAX_MANTISSA = BigInt('9999999999999999')
11+
const MIN_EXPONENT = -32768
12+
const MAX_EXPONENT = 32768
13+
const DEFAULT_VALUE_EXPONENT = -2147483648
14+
15+
/**
16+
* Extract mantissa, exponent, and sign from a number string.
17+
*
18+
* @param val - The string representing the number (may be integer, decimal, or scientific notation).
19+
* @returns Object containing mantissa (BigInt), exponent (number), and isNegative (boolean).
20+
* @throws Error if the string cannot be parsed as a valid number.
21+
*
22+
* Examples:
23+
* '123' -> { mantissa: 123n, exponent: 0, isNegative: false }
24+
* '-00123.45' -> { mantissa: -12345n, exponent: -2, isNegative: true }
25+
* '+7.1e2' -> { mantissa: 71n, exponent: -1 + 2 = 1, isNegative: false }
26+
*/
27+
function extractNumberPartsFromString(val: string): {
28+
mantissa: bigint
29+
exponent: number
30+
isNegative: boolean
31+
} {
32+
/**
33+
* Regex for parsing decimal/float/scientific number strings with optional sign, integer, decimal, and exponent parts.
34+
*
35+
* Pattern: /^([-+]?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/
36+
*
37+
* Breakdown:
38+
* 1. ([-+]?) - Optional '+' or '-' sign at the start.
39+
* 2. ([0-9]+) - Integer part: one or more digits (leading zeros allowed).
40+
* 3. (?:\.([0-9]+))? - Optional decimal point followed by one or more digits.
41+
* 4. (?:[eE]([+-]?[0-9]+))? - Optional exponent, starting with 'e' or 'E', optional sign, and digits.
42+
*
43+
* Notes:
44+
* - Leading zeros are accepted and normalized by code after parsing.
45+
* - Empty decimal ('123.') and missing integer ('.456') are NOT matched—must be fully specified.
46+
*/
47+
const regex = /^([-+]?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/
48+
const match = regex.exec(val)
49+
if (!match) throw new Error(`Unable to parse number from string: ${val}`)
50+
51+
const [, sign, intPart, fracPart, expPart] = match
52+
// Remove leading zeros (unless the entire intPart is zeros)
53+
const cleanIntPart = intPart.replace(/^0+(?=\d)/, '') || '0'
54+
55+
let mantissaStr = cleanIntPart
56+
let exponent = 0
57+
58+
if (fracPart) {
59+
mantissaStr += fracPart
60+
exponent -= fracPart.length
61+
}
62+
if (expPart) exponent += parseInt(expPart, 10)
63+
64+
let mantissa = BigInt(mantissaStr)
65+
if (sign === '-') mantissa = -mantissa
66+
const isNegative = mantissa < BigInt(0)
67+
68+
return { mantissa, exponent, isNegative }
69+
}
70+
71+
/**
72+
* Normalize the mantissa and exponent to XRPL constraints.
73+
*
74+
* Ensures that after normalization, the mantissa is between MIN_MANTISSA and MAX_MANTISSA (unless zero).
75+
* Adjusts the exponent as needed by shifting the mantissa left/right (multiplying/dividing by 10).
76+
*
77+
* @param mantissa - The unnormalized mantissa (BigInt).
78+
* @param exponent - The unnormalized exponent (number).
79+
* @returns An object with normalized mantissa and exponent.
80+
* @throws Error if the number cannot be normalized within allowed exponent range.
81+
*/
82+
function normalize(
83+
mantissa: bigint,
84+
exponent: number,
85+
): { mantissa: bigint; exponent: number } {
86+
let m = mantissa < BigInt(0) ? -mantissa : mantissa
87+
const isNegative = mantissa < BigInt(0)
88+
89+
while (m !== BigInt(0) && m < MIN_MANTISSA && exponent > MIN_EXPONENT) {
90+
exponent -= 1
91+
m *= BigInt(10)
92+
}
93+
while (m > MAX_MANTISSA) {
94+
if (exponent >= MAX_EXPONENT)
95+
throw new Error('Mantissa and exponent are too large')
96+
exponent += 1
97+
m /= BigInt(10)
98+
}
99+
if (isNegative) m = -m
100+
return { mantissa: m, exponent }
101+
}
102+
103+
/**
104+
* STNumber: Encodes XRPL's "Number" type.
105+
*
106+
* - Always encoded as 12 bytes: 8-byte signed mantissa, 4-byte signed exponent, both big-endian.
107+
* - Can only be constructed from a valid number string or another STNumber instance.
108+
*
109+
* Usage:
110+
* STNumber.from("1.2345e5")
111+
* STNumber.from("-123")
112+
* STNumber.fromParser(parser)
113+
*/
114+
export class STNumber extends SerializedType {
115+
/** 12 zero bytes, represents canonical zero. */
116+
static defaultBytes = new Uint8Array(12)
117+
118+
/**
119+
* Construct a STNumber from 12 bytes (8 for mantissa, 4 for exponent).
120+
* @param bytes - 12-byte Uint8Array
121+
* @throws Error if input is not a Uint8Array of length 12.
122+
*/
123+
constructor(bytes?: Uint8Array) {
124+
const used = bytes ?? STNumber.defaultBytes
125+
if (!(used instanceof Uint8Array) || used.length !== 12) {
126+
throw new Error(
127+
`STNumber must be constructed from a 12-byte Uint8Array, got ${used?.length}`,
128+
)
129+
}
130+
super(used)
131+
}
132+
133+
/**
134+
* Construct from a number string (or another STNumber).
135+
*
136+
* @param value - A string, or STNumber instance.
137+
* @returns STNumber instance.
138+
* @throws Error if not a string or STNumber.
139+
*/
140+
static from(value: unknown): STNumber {
141+
if (value instanceof STNumber) {
142+
return value
143+
}
144+
if (typeof value === 'string') {
145+
return STNumber.fromValue(value)
146+
}
147+
throw new Error(
148+
'STNumber.from: Only string or STNumber instance is supported',
149+
)
150+
}
151+
152+
/**
153+
* Construct from a number string (integer, decimal, or scientific notation).
154+
* Handles normalization to XRPL Number constraints.
155+
*
156+
* @param val - The number as a string (e.g. '1.23', '-123e5').
157+
* @returns STNumber instance
158+
* @throws Error if val is not a valid number string.
159+
*/
160+
static fromValue(val: string): STNumber {
161+
const { mantissa, exponent, isNegative } = extractNumberPartsFromString(val)
162+
let normalizedMantissa: bigint
163+
let normalizedExponent: number
164+
165+
if (mantissa === BigInt(0) && exponent === 0 && !isNegative) {
166+
normalizedMantissa = BigInt(0)
167+
normalizedExponent = DEFAULT_VALUE_EXPONENT
168+
} else {
169+
;({ mantissa: normalizedMantissa, exponent: normalizedExponent } =
170+
normalize(mantissa, exponent))
171+
}
172+
173+
const bytes = new Uint8Array(12)
174+
writeInt64BE(bytes, normalizedMantissa, 0)
175+
writeInt32BE(bytes, normalizedExponent, 8)
176+
return new STNumber(bytes)
177+
}
178+
179+
/**
180+
* Read a STNumber from a BinaryParser stream (12 bytes).
181+
* @param parser - BinaryParser positioned at the start of a number
182+
* @returns STNumber instance
183+
*/
184+
static fromParser(parser: BinaryParser): STNumber {
185+
return new STNumber(parser.read(12))
186+
}
187+
188+
/**
189+
* Convert this STNumber to a normalized string representation.
190+
* The output is decimal or scientific notation, depending on exponent range.
191+
* Follows XRPL convention: zero is "0", other values are normalized to a canonical string.
192+
*
193+
* @returns String representation of the value
194+
*/
195+
// eslint-disable-next-line complexity -- required
196+
toJSON(): string {
197+
const b = this.bytes
198+
if (!b || b.length !== 12)
199+
throw new Error('STNumber internal bytes not set or wrong length')
200+
201+
// Signed 64-bit mantissa
202+
const mantissa = readInt64BE(b, 0)
203+
// Signed 32-bit exponent
204+
const exponent = readInt32BE(b, 8)
205+
206+
// Special zero: XRPL encodes canonical zero as mantissa=0, exponent=DEFAULT_VALUE_EXPONENT.
207+
if (mantissa === BigInt(0) && exponent === DEFAULT_VALUE_EXPONENT) {
208+
return '0'
209+
}
210+
if (exponent === 0) return mantissa.toString()
211+
212+
// Use scientific notation for small/large exponents, decimal otherwise
213+
if (exponent < -25 || exponent > -5) {
214+
return `${mantissa}e${exponent}`
215+
}
216+
217+
// Decimal rendering for -25 <= exp <= -5
218+
const isNegative = mantissa < BigInt(0)
219+
const mantissaAbs = mantissa < BigInt(0) ? -mantissa : mantissa
220+
221+
const padPrefix = 27
222+
const padSuffix = 23
223+
const mantissaStr = mantissaAbs.toString()
224+
const rawValue = '0'.repeat(padPrefix) + mantissaStr + '0'.repeat(padSuffix)
225+
const OFFSET = exponent + 43
226+
const integerPart = rawValue.slice(0, OFFSET).replace(/^0+/, '') || '0'
227+
const fractionPart = rawValue.slice(OFFSET).replace(/0+$/, '')
228+
229+
return `${isNegative ? '-' : ''}${integerPart}${
230+
fractionPart ? '.' + fractionPart : ''
231+
}`
232+
}
233+
}

packages/ripple-binary-codec/src/types/st-object.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,13 @@ class STObject extends SerializedType {
151151
? STArray.from(xAddressDecoded[field.name], definitions)
152152
: field.type.name === 'UInt64'
153153
? UInt64.from(xAddressDecoded[field.name], field.name)
154-
: field.associatedType.from(xAddressDecoded[field.name])
154+
: field.associatedType?.from
155+
? field.associatedType.from(xAddressDecoded[field.name])
156+
: (() => {
157+
throw new Error(
158+
`Type ${field.type.name} for field ${field.name} is missing associatedType.from`,
159+
)
160+
})()
155161

156162
if (associatedValue == undefined) {
157163
throw new TypeError(

packages/ripple-binary-codec/src/utils.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,44 @@ export function writeUInt32BE(
5050
array[offset + 3] = value & 0xff
5151
}
5252

53+
/**
54+
* Writes a signed 32-bit integer to a Uint8Array at the specified offset (big-endian).
55+
*
56+
* @param array - The Uint8Array to write to.
57+
* @param value - The signed 32-bit integer to write.
58+
* @param offset - The offset at which to write.
59+
*/
60+
export function writeInt32BE(
61+
array: Uint8Array,
62+
value: number,
63+
offset: number,
64+
): void {
65+
new DataView(array.buffer, array.byteOffset, array.byteLength).setInt32(
66+
offset,
67+
value,
68+
false,
69+
)
70+
}
71+
72+
/**
73+
* Writes a signed 64-bit integer (BigInt) to a Uint8Array at the specified offset (big-endian).
74+
*
75+
* @param array - The Uint8Array to write to.
76+
* @param value - The signed 64-bit integer (BigInt) to write.
77+
* @param offset - The offset at which to write.
78+
*/
79+
export function writeInt64BE(
80+
array: Uint8Array,
81+
value: bigint,
82+
offset: number,
83+
): void {
84+
new DataView(array.buffer, array.byteOffset, array.byteLength).setBigInt64(
85+
offset,
86+
value,
87+
false,
88+
)
89+
}
90+
5391
/**
5492
* Reads an unsigned, big-endian 16-bit integer from the array at the specified offset.
5593
* @param array Uint8Array to read
@@ -68,6 +106,36 @@ export function readUInt32BE(array: Uint8Array, offset: number): string {
68106
return new DataView(array.buffer).getUint32(offset, false).toString(10)
69107
}
70108

109+
/**
110+
* Reads a signed 32-bit integer from a Uint8Array at the specified offset (big-endian).
111+
*
112+
* @param array - The Uint8Array to read from.
113+
* @param offset - The offset at which to start reading.
114+
* @returns The signed 32-bit integer.
115+
*/
116+
export function readInt32BE(array: Uint8Array, offset: number): number {
117+
return new DataView(
118+
array.buffer,
119+
array.byteOffset,
120+
array.byteLength,
121+
).getInt32(offset, false)
122+
}
123+
124+
/**
125+
* Reads a signed 64-bit integer (BigInt) from a Uint8Array at the specified offset (big-endian).
126+
*
127+
* @param array - The Uint8Array to read from.
128+
* @param offset - The offset at which to start reading.
129+
* @returns The signed 64-bit integer (BigInt).
130+
*/
131+
export function readInt64BE(array: Uint8Array, offset: number): bigint {
132+
return new DataView(
133+
array.buffer,
134+
array.byteOffset,
135+
array.byteLength,
136+
).getBigInt64(offset, false)
137+
}
138+
71139
/**
72140
* Compares two Uint8Array or ArrayBuffers
73141
* @param a first array to compare

packages/ripple-binary-codec/test/fixtures/codec-fixtures.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4924,6 +4924,17 @@
49244924
"Account": "rfmDuhDyLGgx94qiwf3YF8BUV5j6KSvE8",
49254925
"OracleDocumentID": 1234
49264926
}
4927+
},
4928+
{
4929+
"binary": "120041811401476926B590BA3245F63C829116A0A3AF7F382D0318000000000000000000000000555344000000000001476926B590BA3245F63C829116A0A3AF7F382D",
4930+
"json": {
4931+
"TransactionType": "VaultCreate",
4932+
"Account": "rfmDuhDyLGgx94qiwf3YF8BUV5j6KSvE8",
4933+
"Asset": {
4934+
"currency": "USD",
4935+
"issuer": "rfmDuhDyLGgx94qiwf3YF8BUV5j6KSvE8"
4936+
}
4937+
}
49274938
}
49284939
],
49294940
"ledgerData": [{

0 commit comments

Comments
 (0)