diff --git a/package-lock.json b/package-lock.json index 68a6c86838..e821f58d5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19208,7 +19208,7 @@ } }, "packages/ripple-binary-codec": { - "version": "2.8.0", + "version": "2.9.0-batch.1", "license": "ISC", "dependencies": { "@xrplf/isomorphic": "^1.0.2", @@ -19241,7 +19241,7 @@ } }, "packages/xrpl": { - "version": "5.0.0", + "version": "5.1.0-batch.1", "license": "ISC", "dependencies": { "@scure/bip32": "^2.0.1", @@ -19252,7 +19252,7 @@ "eventemitter3": "^5.0.1", "fast-json-stable-stringify": "^2.1.0", "ripple-address-codec": "^5.0.1", - "ripple-binary-codec": "^2.8.0", + "ripple-binary-codec": "^2.9.0-batch.1", "ripple-keypairs": "^3.0.0" }, "devDependencies": { @@ -19273,37 +19273,11 @@ "node": ">=20.19.0" } }, - "packages/xrpl/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "packages/xrpl/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "packages/xrpl/node_modules/@xrplf/secret-numbers/node_modules/ripple-keypairs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ripple-keypairs/-/ripple-keypairs-2.0.0.tgz", "integrity": "sha512-b5rfL2EZiffmklqZk1W+dvSy97v3V/C7936WxCCgDynaGPp7GE6R2XO7EU9O2LlM/z95rj870IylYnOQs+1Rag==", + "extraneous": true, "license": "ISC", "dependencies": { "@noble/curves": "^1.0.0", diff --git a/packages/ripple-binary-codec/HISTORY.md b/packages/ripple-binary-codec/HISTORY.md index 53c242361e..19bb492750 100644 --- a/packages/ripple-binary-codec/HISTORY.md +++ b/packages/ripple-binary-codec/HISTORY.md @@ -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 diff --git a/packages/ripple-binary-codec/package.json b/packages/ripple-binary-codec/package.json index bc484aaed8..4e8d489323 100644 --- a/packages/ripple-binary-codec/package.json +++ b/packages/ripple-binary-codec/package.json @@ -1,6 +1,6 @@ { "name": "ripple-binary-codec", - "version": "2.8.0", + "version": "2.9.0-batch.1", "description": "XRP Ledger binary codec", "files": [ "dist/*", diff --git a/packages/ripple-binary-codec/src/binary.ts b/packages/ripple-binary-codec/src/binary.ts index f2a9055ae1..e9c8760b1b 100644 --- a/packages/ripple-binary-codec/src/binary.ts +++ b/packages/ripple-binary-codec/src/binary.ts @@ -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`') } 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) { + if (batch.batchAccount == null) { + throw Error('Field `signerAccount` requires `batchAccount`') + } + bytesList.put(coreTypes.AccountID.from(batch.signerAccount).toBytes()) + } + return bytesList.toBytes() } diff --git a/packages/ripple-binary-codec/test/signing-data-encoding.test.ts b/packages/ripple-binary-codec/test/signing-data-encoding.test.ts index a90127fc65..4624bbd314 100644 --- a/packages/ripple-binary-codec/test/signing-data-encoding.test.ts +++ b/packages/ripple-binary-codec/test/signing-data-encoding.test.ts @@ -306,18 +306,63 @@ 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 @@ -325,6 +370,10 @@ describe('Signing data', function () { // txIds 'ABE4871E9083DF66727045D49DEEDD3A6F166EB7F8D1E92FE868F02E76B2C5CA', '795AAC88B59E95C3497609749127E69F12958BC016C600C770AEEB1474C840B4', + // batch signer account + 'C1D81FB31C42392BA1570431F1CBCBEEBBEF50E1', + // inner signer account + 'B5F762798A53D543A014CAF8B297CFF8F2F937E8', ].join(''), ) }) diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index 87e7cf64a6..6944dbbaf7 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -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)). + ## 5.0.0 (2026-06-05) ### BREAKING CHANGES: diff --git a/packages/xrpl/package.json b/packages/xrpl/package.json index 1af068bc21..9ffc857108 100644 --- a/packages/xrpl/package.json +++ b/packages/xrpl/package.json @@ -1,6 +1,6 @@ { "name": "xrpl", - "version": "5.0.0", + "version": "5.1.0-batch.1", "license": "ISC", "description": "A TypeScript/JavaScript API for interacting with the XRP Ledger in Node.js and the browser", "files": [ @@ -30,7 +30,7 @@ "eventemitter3": "^5.0.1", "fast-json-stable-stringify": "^2.1.0", "ripple-address-codec": "^5.0.1", - "ripple-binary-codec": "^2.8.0", + "ripple-binary-codec": "^2.9.0-batch.1", "ripple-keypairs": "^3.0.0" }, "devDependencies": { diff --git a/packages/xrpl/src/Wallet/batchSigner.ts b/packages/xrpl/src/Wallet/batchSigner.ts index d0d9b5e135..fd70be11f6 100644 --- a/packages/xrpl/src/Wallet/batchSigner.ts +++ b/packages/xrpl/src/Wallet/batchSigner.ts @@ -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. * @@ -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, @@ -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) - 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() + transaction.RawTransactions.forEach((raw) => { + involvedAccounts.add(raw.RawTransaction.Account) + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Counterparty only exists on some inner tx types + const counterparty = (raw.RawTransaction as Record) + .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) @@ -155,6 +185,26 @@ export function combineBatchSigners( return encode(getTransactionWithAllBatchSigners(batchTransactions)) } +/** + * Builds a comparison key over every field bound into a Batch signature + * (XLS-56 V1_1): the outer account, sequence value, flags, and inner + * transaction IDs. Fragments that disagree on any of these were signed over + * different payloads and cannot be combined. + * + * @param tx - The Batch transaction to derive the key from. + * @returns A stable string key for equivalence comparison. + */ +function getBatchEquivalenceKey(tx: Batch): string { + return JSON.stringify({ + account: tx.Account, + sequence: getBatchSeqValue(tx), + flags: tx.Flags, + transactionIDs: tx.RawTransactions.map((rawTx) => + hashSignedTx(rawTx.RawTransaction), + ), + }) +} + /** * The transactions should all be equal except for the 'Signers' field. * @@ -162,37 +212,41 @@ export function combineBatchSigners( * @throws ValidationError if the transactions are not equal in any field other than 'Signers'. */ function validateBatchTransactionEquivalence(transactions: Batch[]): void { - const exampleTransaction = JSON.stringify({ - flags: transactions[0].Flags, - transactionIDs: transactions[0].RawTransactions.map((rawTx) => - hashSignedTx(rawTx.RawTransaction), - ), - }) + const exampleTransaction = getBatchEquivalenceKey(transactions[0]) if ( - transactions.slice(1).some( - (tx) => - JSON.stringify({ - flags: tx.Flags, - transactionIDs: tx.RawTransactions.map((rawTx) => - hashSignedTx(rawTx.RawTransaction), - ), - }) !== exampleTransaction, - ) + transactions + .slice(1) + .some((tx) => getBatchEquivalenceKey(tx) !== exampleTransaction) ) { throw new ValidationError( - 'Flags and transaction hashes are not the same for all provided transactions.', + 'Account, sequence, flags, and transaction hashes must be the same for all provided transactions.', ) } } 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) .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 } } diff --git a/packages/xrpl/test/wallet/batchSigner.test.ts b/packages/xrpl/test/wallet/batchSigner.test.ts index c5428ca9d6..94690280eb 100644 --- a/packages/xrpl/test/wallet/batchSigner.test.ts +++ b/packages/xrpl/test/wallet/batchSigner.test.ts @@ -89,7 +89,7 @@ describe('Wallet batch operations', function () { SigningPubKey: '02691AC5AE1C4C333AE5DF8A93BDC495F0EEBFC6DB0DA7EB6EF808F3AFC006E3FE', TxnSignature: - '304402207E8238D3D2B24B98BA925D69DDAFA3E7D07F85C8ABF1C040B3D1BEBE2C36E92B02200C122F7F3F86AB8FF89207539CAFB4613D665FF336796F99283ED94C66FB3094', + '304502210098890858AA57D6515D7C523FE076FA97BFA87DA666A87B4A7CF44249181DC1DC02201B90E513FE2F45D41FB31850F463C0ECBA8F5126B1AF431B67C4004CA0DD8042', }, }, ] @@ -109,7 +109,7 @@ describe('Wallet batch operations', function () { SigningPubKey: 'ED3CC3D14FD80C213BC92A98AFE13A405A030F845EDCFD5E395286A6E9E62BA638', TxnSignature: - '744FF09C11399F3AC1484F909A92F2D836EA979CB7655BC8F6BC3793F18892F92A16FE41C60EDCD6C2B757FF85D179F1589824ECA397EEA208B94C9D108CDF0A', + '27B496F0C1F2C4789A0E6CF25265069980190C786053CF5D6C066C07E21D632A6EB87C56275109A8542EEDE782FDC5591EA51FAF28C3FCFCF35BCE960F1D8601', }, }, ] @@ -131,7 +131,7 @@ describe('Wallet batch operations', function () { SigningPubKey: 'ED37D3F048B7F1E680B0A97F70C7843160B9F25D6398D07E68B9A2C83AA8E1B156', TxnSignature: - 'E53E2821CE46C98638E46CA0E6DB712CE45CEC45A697830A5028873D2BA51E1FA008F20526AC16B609401E2F1F8938AE60603223BC9D82A0221CFA5E58C90807', + '046315C731DF089E08EB6662251F12B22938ED462F66BC561A847A87DF6B3C9AC811D9EC5971EDEC2BA96C959BDE883CD838B7EF6460A47AD9B71518F1A2A00B', }, }, ] @@ -158,7 +158,7 @@ describe('Wallet batch operations', function () { SigningPubKey: 'ED37D3F048B7F1E680B0A97F70C7843160B9F25D6398D07E68B9A2C83AA8E1B156', TxnSignature: - 'E53E2821CE46C98638E46CA0E6DB712CE45CEC45A697830A5028873D2BA51E1FA008F20526AC16B609401E2F1F8938AE60603223BC9D82A0221CFA5E58C90807', + '8FCA6C1056C2146DC13F4D10BA297335A82F562D837FA3C65D75DCDC87540F61428B7370FCC1DE4D83B6FA1A00A18CD9283E7B08089091ED84CC3E4A8B43F00F', }, }, ], @@ -188,7 +188,7 @@ describe('Wallet batch operations', function () { SigningPubKey: 'ED37D3F048B7F1E680B0A97F70C7843160B9F25D6398D07E68B9A2C83AA8E1B156', TxnSignature: - 'E53E2821CE46C98638E46CA0E6DB712CE45CEC45A697830A5028873D2BA51E1FA008F20526AC16B609401E2F1F8938AE60603223BC9D82A0221CFA5E58C90807', + 'D80D4195BF67D5CB12CA225D04DA4D00AC77250803671E09DF61F1695A831FAD6BF820F335DD2D8CFE16DA55CFC2E64AEC8A1429524E6CDB6C36B7AEA717C700', }, }, ], @@ -365,7 +365,25 @@ describe('Wallet batch operations', function () { assert.throws( () => combineBatchSigners([tx1, badTx2]), ValidationError, - 'Flags and transaction hashes are not the same for all provided transactions.', + 'Account, sequence, flags, and transaction hashes must be the same for all provided transactions.', + ) + }) + + it('fails with different outer Account signed', function () { + const badTx2 = { ...tx2, Account: 'rJy554HmWFFJQGnRfZuoo8nV97XSMq77h7' } + assert.throws( + () => combineBatchSigners([tx1, badTx2]), + ValidationError, + 'Account, sequence, flags, and transaction hashes must be the same for all provided transactions.', + ) + }) + + it('fails with different Sequence signed', function () { + const badTx2 = { ...tx2, Sequence: 216 } + assert.throws( + () => combineBatchSigners([tx1, badTx2]), + ValidationError, + 'Account, sequence, flags, and transaction hashes must be the same for all provided transactions.', ) }) })