Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
44 changes: 16 additions & 28 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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

### Changed
* Add XLS-56 Batch V1_1 support to `signingBatchData` / `encodeForSigningBatch` ([XRPLF/rippled#6446](https://github.com/XRPLF/rippled/pull/6446)). Tracks an unmerged rippled PR; the wire format may still change.

## 2.8.0 (2026-06-04)

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion packages/ripple-binary-codec/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ripple-binary-codec",
"version": "2.8.0",
"version": "2.9.0-beta.1",
"description": "XRP Ledger binary codec",
"files": [
"dist/*",
Expand Down
46 changes: 41 additions & 5 deletions packages/ripple-binary-codec/src/binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,41 +178,77 @@ function multiSigningData(
}

/**
* Interface describing fields required for a Batch signer
* @property flags - Flags indicating Batch transaction properties
* @property txIDs - Array of transaction IDs included in the Batch
* Fields required to serialize an XLS-56 Batch V1_1 signing payload.
*
* @property account - The outer Batch transaction's `Account`.
* @property sequence - The outer Batch's sequence value (`Sequence`, or the
* `TicketSequence` value when a ticket is used).
* @property flags - Flags indicating Batch transaction properties.
* @property txIDs - Array of inner transaction IDs included in the Batch.
* @property batchAccount - The `BatchSigner.Account` the signature binds to.
* @property signerAccount - For a multi-signed `BatchSigner`, the inner
* `Signers` entry account, appended after `batchAccount`.
*/
interface BatchObject extends JsonObject {
account: string
sequence: number
flags: number
txIDs: string[]
batchAccount?: string
signerAccount?: string
}

/**
* Serialize a signingClaim
* Serialize an XLS-56 Batch V1_1 signing payload:
* HashPrefix.batch | account | sequence | flags | txIDCount | txIDs[]
* [ | batchAccount [ | signerAccount ] ]
*
* @param batch A Batch object to serialize.
* @returns the serialized object with appropriate prefix
*/
function signingBatchData(batch: BatchObject): Uint8Array {
if (batch.account == null) {
throw Error('No field `account`')
}
if (batch.sequence == null) {
throw Error('No field `sequence`')
}
if (batch.flags == null) {
throw Error("No field `flags'")
throw Error('No field `flags`')
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (batch.txIDs == null) {
throw Error('No field `txIDs`')
}
const prefix = HashPrefix.batch
const account = coreTypes.AccountID.from(batch.account).toBytes()
const sequence = coreTypes.UInt32.from(batch.sequence).toBytes()
const flags = coreTypes.UInt32.from(batch.flags).toBytes()
const txIDsLength = coreTypes.UInt32.from(batch.txIDs.length).toBytes()

const bytesList = new BytesList()

bytesList.put(prefix)
bytesList.put(account)
bytesList.put(sequence)
bytesList.put(flags)
bytesList.put(txIDsLength)
batch.txIDs.forEach((txID: string) => {
bytesList.put(coreTypes.Hash256.from(txID).toBytes())
})

if (batch.batchAccount != null) {
bytesList.put(coreTypes.AccountID.from(batch.batchAccount).toBytes())
}
// The wire format is positional, so `signerAccount` must follow `batchAccount`.
// Reject `signerAccount` without `batchAccount` to avoid binding the signature
// to the wrong account.
if (batch.signerAccount != null) {
Comment thread
depthfirst-app[bot] marked this conversation as resolved.
if (batch.batchAccount == null) {
throw Error('Field `signerAccount` requires `batchAccount`')
}
bytesList.put(coreTypes.AccountID.from(batch.signerAccount).toBytes())
}

return bytesList.toBytes()
}

Expand Down
63 changes: 56 additions & 7 deletions packages/ripple-binary-codec/test/signing-data-encoding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,25 +306,74 @@ describe('Signing data', function () {
)
})

it('can create batch blob', function () {
const flags = 1
const txIDs = [
'ABE4871E9083DF66727045D49DEEDD3A6F166EB7F8D1E92FE868F02E76B2C5CA',
'795AAC88B59E95C3497609749127E69F12958BC016C600C770AEEB1474C840B4',
]
const json = { flags, txIDs }
it('can create batch blob for a single-signed BatchSigner', function () {
const json = {
account: 'rNCFjv8Ek5oDrNiMJ3pw6eLLFtMjZLJnf2',
sequence: 5,
flags: 1,
txIDs: [
'ABE4871E9083DF66727045D49DEEDD3A6F166EB7F8D1E92FE868F02E76B2C5CA',
'795AAC88B59E95C3497609749127E69F12958BC016C600C770AEEB1474C840B4',
],
// The BatchSigner.Account the signature is bound to (XLS-56 V1_1).
batchAccount: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp',
}
const actual = encodeForSigningBatch(json)
expect(actual).toBe(
[
// hash prefix
'42434800',
// outer account
'95F14B0E44F78A264E41713C64B5F89242540EE2',
// outer sequence
'00000005',
// flags
'00000001',
// txIds length
'00000002',
// txIds
'ABE4871E9083DF66727045D49DEEDD3A6F166EB7F8D1E92FE868F02E76B2C5CA',
'795AAC88B59E95C3497609749127E69F12958BC016C600C770AEEB1474C840B4',
// batch signer account
'C1D81FB31C42392BA1570431F1CBCBEEBBEF50E1',
].join(''),
)
})

it('can create batch blob for a multi-signed BatchSigner', function () {
const json = {
account: 'rNCFjv8Ek5oDrNiMJ3pw6eLLFtMjZLJnf2',
sequence: 5,
flags: 1,
txIDs: [
'ABE4871E9083DF66727045D49DEEDD3A6F166EB7F8D1E92FE868F02E76B2C5CA',
'795AAC88B59E95C3497609749127E69F12958BC016C600C770AEEB1474C840B4',
],
// The BatchSigner.Account the signature is bound to.
batchAccount: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp',
// The inner Signers entry account for a multi-signed BatchSigner.
signerAccount: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh',
}
const actual = encodeForSigningBatch(json)
expect(actual).toBe(
[
// hash prefix
'42434800',
// outer account
'95F14B0E44F78A264E41713C64B5F89242540EE2',
// outer sequence
'00000005',
// flags
'00000001',
// txIds length
'00000002',
// txIds
'ABE4871E9083DF66727045D49DEEDD3A6F166EB7F8D1E92FE868F02E76B2C5CA',
'795AAC88B59E95C3497609749127E69F12958BC016C600C770AEEB1474C840B4',
// batch signer account
'C1D81FB31C42392BA1570431F1CBCBEEBBEF50E1',
// inner signer account
'B5F762798A53D543A014CAF8B297CFF8F2F937E8',
].join(''),
)
})
Expand Down
3 changes: 3 additions & 0 deletions packages/xrpl/HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr

## Unreleased

### Changed
* Add XLS-56 Batch V1_1 support to `signMultiBatch` and `combineBatchSigners` ([XRPLF/rippled#6446](https://github.com/XRPLF/rippled/pull/6446)).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: A WIP tag will be helpful to identify that this PR has not been finalized yet.


## 5.0.0 (2026-06-05)

### BREAKING CHANGES:
Expand Down
55 changes: 50 additions & 5 deletions packages/xrpl/src/Wallet/batchSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ function constructBatchSignerObject(
return batchSigner
}

/**
* Resolve the sequence value bound into a Batch signature: the `Sequence` when
* non-zero, otherwise the `TicketSequence` value (or 0).
*
* @param transaction - The Batch transaction being signed.
* @returns The sequence value to bind into the signature.
*/
function getBatchSeqValue(transaction: Batch): number {
const sequence = transaction.Sequence ?? 0
if (sequence !== 0) {
return sequence
}
return transaction.TicketSequence ?? 0
}

/**
* Sign a multi-account Batch transaction.
*
Expand All @@ -56,6 +71,7 @@ function constructBatchSignerObject(
* The actual address is only needed in the case of regular key usage.
* @throws ValidationError if the transaction is malformed.
*/
// eslint-disable-next-line max-lines-per-function -- cohesive signing routine
export function signMultiBatch(
wallet: Wallet,
transaction: Batch,
Expand All @@ -79,19 +95,33 @@ export function signMultiBatch(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validate does not accept Transaction type
validate(transaction as unknown as Record<string, unknown>)

const involvedAccounts = new Set(
transaction.RawTransactions.map((raw) => raw.RawTransaction.Account),
)
// An account must sign the Batch if it submits an inner transaction or is the
// `Counterparty` of one.
const involvedAccounts = new Set<string>()
transaction.RawTransactions.forEach((raw) => {
involvedAccounts.add(raw.RawTransaction.Account)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This format does not account for potential Delegate of the inner transaction. Please refer to line 427 in this file: https://github.com/XRPLF/rippled/pull/6446/changes#diff-a9cdff3331a64b75a44d24094b4699fd27c8e4fdc52537eb97f881ce8b28c2db

raw.RawTransaction.Delegate ?? raw.RawTransaction.Account would be more appropriate here.

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Counterparty only exists on some inner tx types
const counterparty = (raw.RawTransaction as Record<string, unknown>)
.Counterparty
if (typeof counterparty === 'string') {
involvedAccounts.add(counterparty)
}
})
if (!involvedAccounts.has(batchAccount)) {
throw new ValidationError(
'Must be signing for an address submitting a transaction in the Batch.',
)
}
const fieldsToSign = {
account: transaction.Account,
sequence: getBatchSeqValue(transaction),
flags: transaction.Flags,
txIDs: transaction.RawTransactions.map((rawTx) =>
hashSignedTx(rawTx.RawTransaction),
),
batchAccount,
// Multi-signed batch signers also bind the inner signer account.
...(multisignAddress ? { signerAccount: multisignAddress } : {}),
}
const signature = sign(encodeForSigningBatch(fieldsToSign), wallet.privateKey)

Expand Down Expand Up @@ -186,13 +216,28 @@ function validateBatchTransactionEquivalence(transactions: Batch[]): void {
}

function getTransactionWithAllBatchSigners(transactions: Batch[]): Batch {
const outerAccount = transactions[0].Account

// Signers must be sorted in the combined transaction - See compareSigners' documentation for more details
const sortedSigners: BatchSigner[] = transactions
.flatMap((tx) => tx.BatchSigners ?? [])
.filter((signer) => signer.BatchSigner.Account !== transactions[0].Account)
// A batch signer cannot be the outer account (rippled: temBAD_SIGNER).
.filter((signer) => signer.BatchSigner.Account !== outerAccount)
Comment thread
depthfirst-app[bot] marked this conversation as resolved.
.sort((signer1, signer2) =>
compareSigners(signer1.BatchSigner, signer2.BatchSigner),
)

return { ...transactions[0], BatchSigners: sortedSigners }
// BatchSigners must be strictly ascending and unique by account, so
// de-duplicate when combining fragments that share a signer.
const dedupedSigners: BatchSigner[] = []
let lastAccount = ''
for (const signer of sortedSigners) {
const account = signer.BatchSigner.Account
if (account !== lastAccount) {
dedupedSigners.push(signer)
lastAccount = account
}
}

return { ...transactions[0], BatchSigners: dedupedSigners }
}
Loading
Loading