diff --git a/.vscode/settings.json b/.vscode/settings.json index ef2c734c23..6e4555a206 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,8 @@ "cSpell.words": [ "altnet", "Autofills", + "Batchnet", + "bignumber", "Clawback", "hostid", "keypair", @@ -15,7 +17,8 @@ "secp256k1", "Setf", "Sidechains", - "xchain" + "xchain", + "xrplf" ], "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode", diff --git a/packages/ripple-binary-codec/HISTORY.md b/packages/ripple-binary-codec/HISTORY.md index 91d3ef8e9f..976ff85488 100644 --- a/packages/ripple-binary-codec/HISTORY.md +++ b/packages/ripple-binary-codec/HISTORY.md @@ -2,10 +2,13 @@ ## Unreleased +### Added +* Support for the `Batch` amendment (XLS-56). + ## 2.2.0 (2024-12-23) ### Added -* Support for the Multi-Purpose Token amendment (XLS-33) +* Support for the Multi-Purpose Token amendment (XLS-33). ## 2.1.0 (2024-06-03) diff --git a/packages/ripple-binary-codec/src/binary.ts b/packages/ripple-binary-codec/src/binary.ts index 8ca067d966..29dc1589e9 100644 --- a/packages/ripple-binary-codec/src/binary.ts +++ b/packages/ripple-binary-codec/src/binary.ts @@ -177,11 +177,50 @@ function multiSigningData( }) } +/** + * Interface describing fields required for a Batch signer + */ +interface BatchObject extends JsonObject { + flags: number + txIDs: string[] +} + +/** + * Serialize a signingClaim + * + * @param batch A Batch object to serialize + * @param opts.definitions Custom rippled types to use instead of the default. Used for sidechains and amendments. + * @returns the serialized object with appropriate prefix + */ +function signingBatchData(batch: BatchObject): Uint8Array { + if (batch.flags == null) { + throw Error("No field `flags'") + } + if (batch.txIDs == null) { + throw Error('No field `txIDs`') + } + const prefix = HashPrefix.batch + 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(flags) + bytesList.put(txIDsLength) + batch.txIDs.forEach((txID: string) => { + bytesList.put(coreTypes.Hash256.from(txID).toBytes()) + }) + + return bytesList.toBytes() +} + export { BinaryParser, BinarySerializer, BytesList, ClaimObject, + BatchObject, makeParser, serializeObject, readJSON, @@ -191,4 +230,5 @@ export { binaryToJSON, sha512Half, transactionID, + signingBatchData, } diff --git a/packages/ripple-binary-codec/src/enums/definitions.json b/packages/ripple-binary-codec/src/enums/definitions.json index 3b9a3ae577..e06ebcf3ce 100644 --- a/packages/ripple-binary-codec/src/enums/definitions.json +++ b/packages/ripple-binary-codec/src/enums/definitions.json @@ -913,20 +913,20 @@ [ "IssuerNode", { - "nth": 27, - "isVLEncoded": false, "isSerialized": true, "isSigningField": true, + "isVLEncoded": false, + "nth": 27, "type": "UInt64" } ], [ "SubjectNode", { - "nth": 28, - "isVLEncoded": false, "isSerialized": true, "isSigningField": true, + "isVLEncoded": false, + "nth": 28, "type": "UInt64" } ], @@ -1250,6 +1250,26 @@ "type": "Hash256" } ], + [ + "DomainID", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 34, + "type": "Hash256" + } + ], + [ + "ParentBatchID", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 35, + "type": "Hash256" + } + ], [ "hash", { @@ -1833,10 +1853,10 @@ [ "CredentialType", { - "nth": 31, - "isVLEncoded": true, "isSerialized": true, "isSigningField": true, + "isVLEncoded": true, + "nth": 31, "type": "Blob" } ], @@ -2013,13 +2033,23 @@ [ "Subject", { - "nth": 24, - "isVLEncoded": true, "isSerialized": true, "isSigningField": true, + "isVLEncoded": true, + "nth": 24, "type": "AccountID" } ], + [ + "Number", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 1, + "type": "Number" + } + ], [ "TransactionMetaData", { @@ -2313,10 +2343,30 @@ [ "Credential", { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, "nth": 33, + "type": "STObject" + } + ], + [ + "RawTransaction", + { + "isSerialized": true, + "isSigningField": true, "isVLEncoded": false, + "nth": 34, + "type": "STObject" + } + ], + [ + "BatchSigner", + { "isSerialized": true, "isSigningField": true, + "isVLEncoded": false, + "nth": 35, "type": "STObject" } ], @@ -2513,20 +2563,50 @@ [ "AuthorizeCredentials", { - "nth": 26, - "isVLEncoded": false, "isSerialized": true, "isSigningField": true, + "isVLEncoded": false, + "nth": 26, "type": "STArray" } ], [ "UnauthorizeCredentials", { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, "nth": 27, + "type": "STArray" + } + ], + [ + "AcceptedCredentials", + { + "isSerialized": true, + "isSigningField": true, "isVLEncoded": false, + "nth": 28, + "type": "STArray" + } + ], + [ + "RawTransactions", + { "isSerialized": true, "isSigningField": true, + "isVLEncoded": false, + "nth": 29, + "type": "STArray" + } + ], + [ + "BatchSigners", + { + "isSerialized": true, + "isSigningField": false, + "isVLEncoded": false, + "nth": 30, "type": "STArray" } ], @@ -2713,10 +2793,10 @@ [ "CredentialIDs", { - "nth": 5, - "isVLEncoded": true, "isSerialized": true, "isSigningField": true, + "isVLEncoded": true, + "nth": 5, "type": "Vector256" } ], @@ -2847,6 +2927,7 @@ "Amendments": 102, "Bridge": 105, "Check": 67, + "Credential": 129, "DID": 73, "DepositPreauth": 112, "DirectoryNode": 100, @@ -2861,8 +2942,8 @@ "NegativeUNL": 78, "Offer": 111, "Oracle": 128, - "Credential": 129, "PayChannel": 120, + "PermissionedDomain": 130, "RippleState": 114, "SignerList": 83, "Ticket": 84, @@ -2890,6 +2971,7 @@ "tecFAILED_PROCESSING": 105, "tecFROZEN": 137, "tecHAS_OBLIGATIONS": 151, + "tecHOOK_REJECTED": 153, "tecINCOMPLETE": 169, "tecINSUFFICIENT_FUNDS": 159, "tecINSUFFICIENT_PAYMENT": 161, @@ -2948,6 +3030,7 @@ "tecXCHAIN_SELF_COMMIT": 184, "tecXCHAIN_SENDING_ACCOUNT_MISMATCH": 179, "tecXCHAIN_WRONG_CHAIN": 176, + "tefALREADY": -198, "tefBAD_ADD_AUTH": -197, "tefBAD_AUTH": -196, @@ -2970,6 +3053,7 @@ "tefPAST_SEQ": -190, "tefTOO_BIG": -181, "tefWRONG_PRIOR": -189, + "telBAD_DOMAIN": -398, "telBAD_PATH_COUNT": -397, "telBAD_PUBLIC_KEY": -396, @@ -2987,6 +3071,7 @@ "telNO_DST_PARTIAL": -393, "telREQUIRES_NETWORK_ID": -385, "telWRONG_NETWORK": -386, + "temARRAY_EMPTY": -253, "temARRAY_TOO_LARGE": -252, "temBAD_AMM_TOKENS": -261, @@ -3024,6 +3109,7 @@ "temINVALID_ACCOUNT_ID": -268, "temINVALID_COUNT": -266, "temINVALID_FLAG": -276, + "temINVALID_INNER_BATCH": -250, "temMALFORMED": -299, "temREDUNDANT": -275, "temRIPPLE_EMPTY": -274, @@ -3036,6 +3122,7 @@ "temXCHAIN_BRIDGE_BAD_REWARD_AMOUNT": -255, "temXCHAIN_BRIDGE_NONDOOR_OWNER": -257, "temXCHAIN_EQUAL_DOOR_ACCOUNTS": -260, + "terFUNDS_SPENT": -98, "terINSUF_FEE_B": -97, "terLAST": -91, @@ -3049,10 +3136,12 @@ "terPRE_TICKET": -88, "terQUEUED": -89, "terRETRY": -99, + "tesSUCCESS": 0 }, "TRANSACTION_TYPES": { "AMMBid": 39, + "AMMClawback": 31, "AMMCreate": 35, "AMMDelete": 40, "AMMDeposit": 36, @@ -3060,12 +3149,13 @@ "AMMWithdraw": 37, "AccountDelete": 21, "AccountSet": 3, + "Batch": 64, "CheckCancel": 18, "CheckCash": 17, "CheckCreate": 16, "Clawback": 30, - "CredentialCreate": 58, "CredentialAccept": 59, + "CredentialCreate": 58, "CredentialDelete": 60, "DIDDelete": 50, "DIDSet": 49, @@ -3094,6 +3184,8 @@ "PaymentChannelClaim": 15, "PaymentChannelCreate": 13, "PaymentChannelFund": 14, + "PermissionedDomainDelete": 63, + "PermissionedDomainSet": 62, "SetFee": 101, "SetRegularKey": 5, "SignerListSet": 12, @@ -3123,6 +3215,7 @@ "LedgerEntry": 10002, "Metadata": 10004, "NotPresent": 0, + "Number": 9, "PathSet": 18, "STArray": 15, "STObject": 14, diff --git a/packages/ripple-binary-codec/src/hash-prefixes.ts b/packages/ripple-binary-codec/src/hash-prefixes.ts index 98035167bc..edfa6e97ad 100644 --- a/packages/ripple-binary-codec/src/hash-prefixes.ts +++ b/packages/ripple-binary-codec/src/hash-prefixes.ts @@ -35,6 +35,8 @@ const HashPrefix: Record = { proposal: bytes(0x50525000), // payment channel claim paymentChannelClaim: bytes(0x434c4d00), + // batch + batch: bytes(0x42434800), } export { HashPrefix } diff --git a/packages/ripple-binary-codec/src/index.ts b/packages/ripple-binary-codec/src/index.ts index d0e44b5bae..4ee74396c0 100644 --- a/packages/ripple-binary-codec/src/index.ts +++ b/packages/ripple-binary-codec/src/index.ts @@ -1,6 +1,6 @@ import { quality, binary, HashPrefix } from './coretypes' import { decodeLedgerData } from './ledger-hashes' -import { ClaimObject } from './binary' +import { ClaimObject, BatchObject } from './binary' import { JsonObject } from './types/serialized-type' import { XrplDefinitionsBase, @@ -15,6 +15,7 @@ const { signingData, signingClaimData, multiSigningData, + signingBatchData, binaryToJSON, serializeObject, } = binary @@ -110,6 +111,13 @@ function encodeForMultisigning( ) } +function encodeForSigningBatch(json: object): string { + if (typeof json !== 'object') { + throw new Error() + } + return bytesToHex(signingBatchData(json as BatchObject)) +} + /** * Encode a quality value * @@ -142,6 +150,7 @@ export { encodeForSigning, encodeForSigningClaim, encodeForMultisigning, + encodeForSigningBatch, encodeQuality, decodeQuality, decodeLedgerData, 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 22bab766cb..f7f9126c10 100644 --- a/packages/ripple-binary-codec/test/signing-data-encoding.test.ts +++ b/packages/ripple-binary-codec/test/signing-data-encoding.test.ts @@ -3,6 +3,7 @@ const { encodeForSigning, encodeForSigningClaim, encodeForMultisigning, + encodeForSigningBatch, } = require('../src') const normalDefinitions = require('../src/enums/definitions.json') @@ -244,4 +245,27 @@ describe('Signing data', function () { ].join(''), ) }) + + it('can create batch blob', function () { + const flags = 1 + const txIDs = [ + 'ABE4871E9083DF66727045D49DEEDD3A6F166EB7F8D1E92FE868F02E76B2C5CA', + '795AAC88B59E95C3497609749127E69F12958BC016C600C770AEEB1474C840B4', + ] + const json = { flags, txIDs } + const actual = encodeForSigningBatch(json) + expect(actual).toBe( + [ + // hash prefix + '42434800', + // flags + '00000001', + // txIds length + '00000002', + // txIds + 'ABE4871E9083DF66727045D49DEEDD3A6F166EB7F8D1E92FE868F02E76B2C5CA', + '795AAC88B59E95C3497609749127E69F12958BC016C600C770AEEB1474C840B4', + ].join(''), + ) + }) }) diff --git a/packages/xrpl/.eslintrc.js b/packages/xrpl/.eslintrc.js index 2321616dfa..9e4eabd1db 100644 --- a/packages/xrpl/.eslintrc.js +++ b/packages/xrpl/.eslintrc.js @@ -66,6 +66,7 @@ module.exports = { 'tsdoc/syntax': 'off', 'jsdoc/require-description-complete-sentence': 'off', 'import/prefer-default-export': 'off', + 'max-depth': ['warn', 3], }, overrides: [ { @@ -155,7 +156,6 @@ module.exports = { 'max-lines-per-function': ['off'], 'max-statements': ['off'], complexity: ['off'], - 'max-depth': ['warn', 3], }, }, ], diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index a290c38383..3b361008e6 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -11,7 +11,8 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr * Deprecated `setTransactionFlagsToNumber`. Start using convertTxFlagsToNumber instead ### Added -* Support for the `simulate` RPC ([XLS-69](https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0069-simulate)) +* Support for the `simulate` RPC ([XLS-69](https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0069-simulate)). +* Add support for `Batch` amendment (XLS-56). ## 4.1.0 (2024-12-23) diff --git a/packages/xrpl/package.json b/packages/xrpl/package.json index beb5408ec8..88e485aed0 100644 --- a/packages/xrpl/package.json +++ b/packages/xrpl/package.json @@ -64,7 +64,7 @@ "test": "jest --config=jest.config.unit.js --verbose false --silent=false", "test:integration": "TS_NODE_PROJECT=tsconfig.build.json jest --config=jest.config.integration.js --verbose false --silent=false --runInBand", "test:browser": "npm run build && npm run build:browserTests && karma start ./karma.config.js", - "test:watch": "jest --watch --verbose false --silent=false --runInBand ./test/**/*.test.ts --testPathIgnorePatterns=./test/integration --testPathIgnorePatterns=./test/fixtures", + "test:watch": "jest --watch --config=jest.config.unit.js --verbose false --silent=false", "format": "prettier --write '{src,test}/**/*.ts'", "lint": "eslint . --ext .ts --max-warnings 0", "perf": "./scripts/perf_test.sh", diff --git a/packages/xrpl/src/Wallet/batchSigner.ts b/packages/xrpl/src/Wallet/batchSigner.ts new file mode 100644 index 0000000000..e4fe4f5b7d --- /dev/null +++ b/packages/xrpl/src/Wallet/batchSigner.ts @@ -0,0 +1,222 @@ +import { bytesToHex } from '@xrplf/isomorphic/utils' +import BigNumber from 'bignumber.js' +import { decodeAccountID } from 'ripple-address-codec' +import { decode, encode, encodeForSigningBatch } from 'ripple-binary-codec' +import { sign } from 'ripple-keypairs' + +import { ValidationError } from '../errors' +import { Batch, Transaction, validate } from '../models' +import { BatchSigner, validateBatch } from '../models/transactions/batch' +import { hashSignedTx } from '../utils/hashes' + +import { Wallet } from '.' + +/** + * Sign a multi-account Batch transaction. + * + * @param wallet - Wallet instance. + * @param transaction - The Batch transaction to sign. + * @param opts - Additional options for regular key and multi-signing complexity. + * @param opts.batchAccount - The account submitting the inner Batch transaction, on behalf of which is this signature. + * @param opts.multisign - Specify true/false to use multisign or actual address (classic/x-address) to make multisign tx request. + * 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 -- TODO: refactor +export function signMultiBatch( + wallet: Wallet, + transaction: Batch, + opts: { batchAccount?: string; multisign?: boolean | string } = {}, +): void { + const batchAccount = opts.batchAccount ?? wallet.classicAddress + let multisignAddress: boolean | string = false + if (typeof opts.multisign === 'string') { + multisignAddress = opts.multisign + } else if (opts.multisign) { + multisignAddress = wallet.classicAddress + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- needed for JS + if (transaction.TransactionType !== 'Batch') { + throw new ValidationError('Must be a Batch transaction.') + } + + const involvedAccounts = transaction.RawTransactions.map( + (raw) => raw.RawTransaction.Account, + ) + if (!involvedAccounts.includes(batchAccount)) { + throw new ValidationError( + 'Must be signing for an address included in the Batch.', + ) + } + /* + * This will throw a more clear error for JS users if the supplied transaction has incorrect formatting + */ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validate does not accept Transaction type + validate(transaction as unknown as Record) + const fieldsToSign = { + flags: transaction.Flags, + txIDs: transaction.RawTransactions.map((rawTx) => + hashSignedTx(rawTx.RawTransaction), + ), + } + let batchSigner: BatchSigner + if (multisignAddress) { + batchSigner = { + BatchSigner: { + Account: batchAccount, + Signers: [ + { + Signer: { + Account: multisignAddress, + SigningPubKey: wallet.publicKey, + TxnSignature: sign( + encodeForSigningBatch(fieldsToSign), + wallet.privateKey, + ), + }, + }, + ], + }, + } + } else { + batchSigner = { + BatchSigner: { + Account: batchAccount, + SigningPubKey: wallet.publicKey, + TxnSignature: sign( + encodeForSigningBatch(fieldsToSign), + wallet.privateKey, + ), + }, + } + } + + // eslint-disable-next-line no-param-reassign -- okay for signing + transaction.BatchSigners = [batchSigner] +} + +/** + * Takes several transactions with BatchSigners fields (in object or blob form) and creates a + * single transaction with all BatchSigners that then gets signed and returned. + * + * @param transactions The transactions to combine `BatchSigners` values on. + * @returns A single signed Transaction which has all BatchSigners from transactions within it. + * @throws ValidationError if: + * - There were no transactions given to sign + * @category Signing + */ +export function combineBatchSigners( + transactions: Array, +): string { + if (transactions.length === 0) { + throw new ValidationError('There are 0 transactions to combine.') + } + + const decodedTransactions: Transaction[] = transactions.map((txOrBlob) => { + return getDecodedTransaction(txOrBlob) + }) + + decodedTransactions.forEach((tx) => { + if (tx.TransactionType !== 'Batch') { + throw new ValidationError('TransactionType must be `Batch`.') + } + /* + * This will throw a more clear error for JS users if any of the supplied transactions has incorrect formatting + */ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validate does not accept Transaction type + validateBatch(tx as unknown as Record) + if (tx.BatchSigners == null || tx.BatchSigners.length === 0) { + throw new ValidationError( + 'For combining Batch transaction signatures, all transactions must include a BatchSigners field containing an array of signatures.', + ) + } + + if (tx.TxnSignature != null || tx.Signers != null) { + throw new ValidationError('Batch transaction must be unsigned.') + } + }) + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- checked above + const batchTransactions = decodedTransactions as Batch[] + + validateBatchTransactionEquivalence(batchTransactions) + + return encode(getTransactionWithAllBatchSigners(batchTransactions)) +} + +/** + * The transactions should all be equal except for the 'Signers' field. + * + * @param transactions - An array of Transactions which are expected to be equal other than 'Signers'. + * @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), + ), + }) + if ( + transactions.slice(1).some( + (tx) => + JSON.stringify({ + flags: tx.Flags, + transactionIDs: tx.RawTransactions.map((rawTx) => + hashSignedTx(rawTx.RawTransaction), + ), + }) !== exampleTransaction, + ) + ) { + throw new ValidationError( + 'Flags and transaction hashes are not the same for all provided transactions.', + ) + } +} + +function getTransactionWithAllBatchSigners(transactions: Batch[]): Batch { + // 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) + .sort(compareBatchSigners) + + return { ...transactions[0], BatchSigners: sortedSigners } +} + +/** + * If presented in binary form, the BatchSigners array must be sorted based on + * the numeric value of the signer addresses, with the lowest value first. + * (If submitted as JSON, the submit_multisigned method handles this automatically.) + * https://xrpl.org/multi-signing.html. + * + * @param left - A BatchSigner to compare with. + * @param right - A second BatchSigner to compare with. + * @returns 1 if left \> right, 0 if left = right, -1 if left \< right, and null if left or right are NaN. + */ +function compareBatchSigners(left: BatchSigner, right: BatchSigner): number { + return addressToBigNumber(left.BatchSigner.Account).comparedTo( + addressToBigNumber(right.BatchSigner.Account), + ) +} + +// copied from signer.ts +// TODO: refactor +const NUM_BITS_IN_HEX = 16 + +function addressToBigNumber(address: string): BigNumber { + const hex = bytesToHex(decodeAccountID(address)) + return new BigNumber(hex, NUM_BITS_IN_HEX) +} + +function getDecodedTransaction(txOrBlob: Transaction | string): Transaction { + if (typeof txOrBlob === 'object') { + // We need this to handle X-addresses in multisigning + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We are casting here to get strong typing + return decode(encode(txOrBlob)) as unknown as Transaction + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We are casting here to get strong typing + return decode(txOrBlob) as unknown as Transaction +} diff --git a/packages/xrpl/src/Wallet/defaultFaucets.ts b/packages/xrpl/src/Wallet/defaultFaucets.ts index bf5c38ae51..9c8c079f52 100644 --- a/packages/xrpl/src/Wallet/defaultFaucets.ts +++ b/packages/xrpl/src/Wallet/defaultFaucets.ts @@ -14,11 +14,13 @@ export interface FaucetWallet { export enum FaucetNetwork { Testnet = 'faucet.altnet.rippletest.net', Devnet = 'faucet.devnet.rippletest.net', + Batchnet = 'batch.faucet.nerdnest.xyz', } export const FaucetNetworkPaths: Record = { [FaucetNetwork.Testnet]: '/accounts', [FaucetNetwork.Devnet]: '/accounts', + [FaucetNetwork.Batchnet]: '/accounts', } /** @@ -36,6 +38,10 @@ export function getFaucetHost(client: Client): FaucetNetwork | undefined { return FaucetNetwork.Testnet } + if (connectionUrl.includes('batchnet')) { + return FaucetNetwork.Batchnet + } + if (connectionUrl.includes('sidechain-net2')) { throw new XRPLFaucetError( 'Cannot fund an account on an issuing chain. Accounts must be created via the bridge.', diff --git a/packages/xrpl/src/Wallet/index.ts b/packages/xrpl/src/Wallet/index.ts index c5ce5baca5..db1f4a313b 100644 --- a/packages/xrpl/src/Wallet/index.ts +++ b/packages/xrpl/src/Wallet/index.ts @@ -24,6 +24,8 @@ import { import ECDSA from '../ECDSA' import { ValidationError } from '../errors' import { Transaction, validate } from '../models/transactions' +import { GlobalFlags } from '../models/transactions/common' +import { hasFlag } from '../models/utils/flags' import { ensureClassicAddress } from '../sugar/utils' import { omitBy } from '../utils/collections' import { hashSignedTx } from '../utils/hashes/hashLedger' @@ -367,6 +369,7 @@ export class Wallet { * @param this - Wallet instance. * @param transaction - A transaction to be signed offline. * @param multisign - Specify true/false to use multisign or actual address (classic/x-address) to make multisign tx request. + * The actual address is only needed in the case of regular key usage. * @returns A signed transaction. * @throws ValidationError if the transaction is already signed or does not encode/decode to same result. * @throws XrplError if the issued currency being signed is XRP ignoring case. @@ -381,7 +384,7 @@ export class Wallet { hash: string } { let multisignAddress: boolean | string = false - if (typeof multisign === 'string' && multisign.startsWith('X')) { + if (typeof multisign === 'string') { multisignAddress = multisign } else if (multisign) { multisignAddress = this.classicAddress @@ -407,6 +410,9 @@ export class Wallet { */ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validate does not accept Transaction type validate(tx as unknown as Record) + if (hasFlag(tx, GlobalFlags.tfInnerBatchTxn)) { + throw new ValidationError('Cannot sign a Batch inner transaction.') + } const txToSignAndEncode = { ...tx } diff --git a/packages/xrpl/src/client/RequestManager.ts b/packages/xrpl/src/client/RequestManager.ts index 79712140ac..f91ef9c55f 100644 --- a/packages/xrpl/src/client/RequestManager.ts +++ b/packages/xrpl/src/client/RequestManager.ts @@ -175,6 +175,7 @@ export default class RequestManager { * @param response - The response to handle. * @throws ResponseFormatError if the response format is invalid, RippledError if rippled returns an error. */ + // eslint-disable-next-line complexity -- handling a response is complex public handleResponse( response: Partial | ErrorResponse>, ): void { @@ -195,7 +196,9 @@ export default class RequestManager { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We know this must be true const errorResponse = response as Partial const error = new RippledError( - errorResponse.error_message ?? errorResponse.error, + errorResponse.error_message ?? + errorResponse.error_exception ?? + errorResponse.error, errorResponse, ) this.reject(response.id, error) diff --git a/packages/xrpl/src/client/connection.ts b/packages/xrpl/src/client/connection.ts index 3214b557e2..7173bc45c3 100644 --- a/packages/xrpl/src/client/connection.ts +++ b/packages/xrpl/src/client/connection.ts @@ -349,7 +349,6 @@ export class Connection extends EventEmitter { try { this.requestManager.handleResponse(data) } catch (error) { - // eslint-disable-next-line max-depth -- okay here if (error instanceof Error) { this.emit('error', 'badMessage', error.message, message) } else { diff --git a/packages/xrpl/src/client/index.ts b/packages/xrpl/src/client/index.ts index 91186d99bf..86976b6ec1 100644 --- a/packages/xrpl/src/client/index.ts +++ b/packages/xrpl/src/client/index.ts @@ -67,6 +67,8 @@ import { setLatestValidatedLedgerSequence, checkAccountDeleteBlockers, txNeedsNetworkID, + autofillBatchTxn, + handleDeliverMax, } from '../sugar/autofill' import { formatBalances } from '../sugar/balances' import { @@ -662,7 +664,6 @@ class Client extends EventEmitter { * @throws ValidationError If Amount and DeliverMax fields are not identical in a Payment Transaction */ - // eslint-disable-next-line complexity -- handling Payment transaction API v2 requires more logic public async autofill( transaction: T, signersCount?: number, @@ -688,33 +689,11 @@ class Client extends EventEmitter { if (tx.TransactionType === 'AccountDelete') { promises.push(checkAccountDeleteBlockers(this, tx)) } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- ignore type-assertions on the DeliverMax property - // @ts-expect-error -- DeliverMax property exists only at the RPC level, not at the protocol level - if (tx.TransactionType === 'Payment' && tx.DeliverMax != null) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- This is a valid null check for Amount - if (tx.Amount == null) { - // If only DeliverMax is provided, use it to populate the Amount field - // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- ignore type-assertions on the DeliverMax property - // @ts-expect-error -- DeliverMax property exists only at the RPC level, not at the protocol level - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- DeliverMax is a known RPC-level property - tx.Amount = tx.DeliverMax - } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- ignore type-assertions on the DeliverMax property - // @ts-expect-error -- DeliverMax property exists only at the RPC level, not at the protocol level - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- This is a valid null check for Amount - if (tx.Amount != null && tx.Amount !== tx.DeliverMax) { - return Promise.reject( - new ValidationError( - 'PaymentTransaction: Amount and DeliverMax fields must be identical when both are provided', - ), - ) - } - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- ignore type-assertions on the DeliverMax property - // @ts-expect-error -- DeliverMax property exists only at the RPC level, not at the protocol level - delete tx.DeliverMax + if (tx.TransactionType === 'Batch') { + promises.push(autofillBatchTxn(this, tx)) + } + if (tx.TransactionType === 'Payment') { + handleDeliverMax(tx) } return Promise.all(promises).then(() => tx) diff --git a/packages/xrpl/src/models/ledger/Credential.ts b/packages/xrpl/src/models/ledger/Credential.ts index 7716409ece..50cf83ba0b 100644 --- a/packages/xrpl/src/models/ledger/Credential.ts +++ b/packages/xrpl/src/models/ledger/Credential.ts @@ -1,8 +1,8 @@ -import { GlobalFlags } from '../transactions/common' +import { GlobalFlagsInterface } from '../transactions/common' import { BaseLedgerEntry, HasPreviousTxnID } from './BaseLedgerEntry' -export interface CredentialFlags extends GlobalFlags { +export interface CredentialFlags extends GlobalFlagsInterface { lsfAccepted?: boolean } diff --git a/packages/xrpl/src/models/methods/baseMethod.ts b/packages/xrpl/src/models/methods/baseMethod.ts index 85dcf90efb..f264c2bd32 100644 --- a/packages/xrpl/src/models/methods/baseMethod.ts +++ b/packages/xrpl/src/models/methods/baseMethod.ts @@ -53,6 +53,7 @@ export interface ErrorResponse { error: string error_code?: string error_message?: string + error_exception?: string request: Request api_version?: number } diff --git a/packages/xrpl/src/models/transactions/AMMDeposit.ts b/packages/xrpl/src/models/transactions/AMMDeposit.ts index 2dd8d27e39..884d06ac4c 100644 --- a/packages/xrpl/src/models/transactions/AMMDeposit.ts +++ b/packages/xrpl/src/models/transactions/AMMDeposit.ts @@ -3,7 +3,7 @@ import { Amount, Currency, IssuedCurrencyAmount } from '../common' import { BaseTransaction, - GlobalFlags, + GlobalFlagsInterface, isAmount, isCurrency, isIssuedCurrency, @@ -24,7 +24,7 @@ export enum AMMDepositFlags { tfTwoAssetIfEmpty = 0x00800000, } -export interface AMMDepositFlagsInterface extends GlobalFlags { +export interface AMMDepositFlagsInterface extends GlobalFlagsInterface { tfLPToken?: boolean tfSingleAsset?: boolean tfTwoAsset?: boolean diff --git a/packages/xrpl/src/models/transactions/AMMWithdraw.ts b/packages/xrpl/src/models/transactions/AMMWithdraw.ts index fcce5912b3..bc668afff1 100644 --- a/packages/xrpl/src/models/transactions/AMMWithdraw.ts +++ b/packages/xrpl/src/models/transactions/AMMWithdraw.ts @@ -3,7 +3,7 @@ import { Amount, Currency, IssuedCurrencyAmount } from '../common' import { BaseTransaction, - GlobalFlags, + GlobalFlagsInterface, isAmount, isCurrency, isIssuedCurrency, @@ -25,7 +25,7 @@ export enum AMMWithdrawFlags { tfLimitLPToken = 0x00400000, } -export interface AMMWithdrawFlagsInterface extends GlobalFlags { +export interface AMMWithdrawFlagsInterface extends GlobalFlagsInterface { tfLPToken?: boolean tfWithdrawAll?: boolean tfOneAssetWithdrawAll?: boolean diff --git a/packages/xrpl/src/models/transactions/MPTokenAuthorize.ts b/packages/xrpl/src/models/transactions/MPTokenAuthorize.ts index 4453f571e7..c796677492 100644 --- a/packages/xrpl/src/models/transactions/MPTokenAuthorize.ts +++ b/packages/xrpl/src/models/transactions/MPTokenAuthorize.ts @@ -6,7 +6,7 @@ import { Account, validateOptionalField, isAccount, - GlobalFlags, + GlobalFlagsInterface, } from './common' /** @@ -32,7 +32,7 @@ export enum MPTokenAuthorizeFlags { * * @category Transaction Flags */ -export interface MPTokenAuthorizeFlagsInterface extends GlobalFlags { +export interface MPTokenAuthorizeFlagsInterface extends GlobalFlagsInterface { tfMPTUnauthorize?: boolean } diff --git a/packages/xrpl/src/models/transactions/MPTokenIssuanceCreate.ts b/packages/xrpl/src/models/transactions/MPTokenIssuanceCreate.ts index 6566e4ce05..c84f7ffa33 100644 --- a/packages/xrpl/src/models/transactions/MPTokenIssuanceCreate.ts +++ b/packages/xrpl/src/models/transactions/MPTokenIssuanceCreate.ts @@ -3,7 +3,7 @@ import { isHex, INTEGER_SANITY_CHECK, isFlagEnabled } from '../utils' import { BaseTransaction, - GlobalFlags, + GlobalFlagsInterface, validateBaseTransaction, validateOptionalField, isString, @@ -58,7 +58,8 @@ export enum MPTokenIssuanceCreateFlags { * * @category Transaction Flags */ -export interface MPTokenIssuanceCreateFlagsInterface extends GlobalFlags { +export interface MPTokenIssuanceCreateFlagsInterface + extends GlobalFlagsInterface { tfMPTCanLock?: boolean tfMPTRequireAuth?: boolean tfMPTCanEscrow?: boolean diff --git a/packages/xrpl/src/models/transactions/MPTokenIssuanceSet.ts b/packages/xrpl/src/models/transactions/MPTokenIssuanceSet.ts index 7e1c723382..fcfc5dd1ea 100644 --- a/packages/xrpl/src/models/transactions/MPTokenIssuanceSet.ts +++ b/packages/xrpl/src/models/transactions/MPTokenIssuanceSet.ts @@ -9,7 +9,7 @@ import { Account, validateOptionalField, isAccount, - GlobalFlags, + GlobalFlagsInterface, } from './common' /** @@ -34,7 +34,7 @@ export enum MPTokenIssuanceSetFlags { * * @category Transaction Flags */ -export interface MPTokenIssuanceSetFlagsInterface extends GlobalFlags { +export interface MPTokenIssuanceSetFlagsInterface extends GlobalFlagsInterface { tfMPTLock?: boolean tfMPTUnlock?: boolean } diff --git a/packages/xrpl/src/models/transactions/NFTokenCreateOffer.ts b/packages/xrpl/src/models/transactions/NFTokenCreateOffer.ts index 9575d1b6be..ae42ca4874 100644 --- a/packages/xrpl/src/models/transactions/NFTokenCreateOffer.ts +++ b/packages/xrpl/src/models/transactions/NFTokenCreateOffer.ts @@ -4,7 +4,7 @@ import { isFlagEnabled } from '../utils' import { BaseTransaction, - GlobalFlags, + GlobalFlagsInterface, validateBaseTransaction, isAmount, parseAmountValue, @@ -33,7 +33,7 @@ export enum NFTokenCreateOfferFlags { * * @category Transaction Flags */ -export interface NFTokenCreateOfferFlagsInterface extends GlobalFlags { +export interface NFTokenCreateOfferFlagsInterface extends GlobalFlagsInterface { tfSellNFToken?: boolean } diff --git a/packages/xrpl/src/models/transactions/NFTokenMint.ts b/packages/xrpl/src/models/transactions/NFTokenMint.ts index 1bd7947929..a376b53a0b 100644 --- a/packages/xrpl/src/models/transactions/NFTokenMint.ts +++ b/packages/xrpl/src/models/transactions/NFTokenMint.ts @@ -4,7 +4,7 @@ import { isHex } from '../utils' import { Account, BaseTransaction, - GlobalFlags, + GlobalFlagsInterface, isAccount, validateBaseTransaction, validateOptionalField, @@ -50,7 +50,7 @@ export enum NFTokenMintFlags { * * @category Transaction Flags */ -export interface NFTokenMintFlagsInterface extends GlobalFlags { +export interface NFTokenMintFlagsInterface extends GlobalFlagsInterface { tfBurnable?: boolean tfOnlyXRP?: boolean tfTrustLine?: boolean diff --git a/packages/xrpl/src/models/transactions/XChainModifyBridge.ts b/packages/xrpl/src/models/transactions/XChainModifyBridge.ts index 841960b8f0..4825106b79 100644 --- a/packages/xrpl/src/models/transactions/XChainModifyBridge.ts +++ b/packages/xrpl/src/models/transactions/XChainModifyBridge.ts @@ -2,7 +2,7 @@ import { Amount, XChainBridge } from '../common' import { BaseTransaction, - GlobalFlags, + GlobalFlagsInterface, isAmount, isXChainBridge, validateBaseTransaction, @@ -26,7 +26,7 @@ export enum XChainModifyBridgeFlags { * * @category Transaction Flags */ -export interface XChainModifyBridgeFlagsInterface extends GlobalFlags { +export interface XChainModifyBridgeFlagsInterface extends GlobalFlagsInterface { /** Clears the MinAccountCreateAmount of the bridge. */ tfClearAccountCreateAmount?: boolean } diff --git a/packages/xrpl/src/models/transactions/accountSet.ts b/packages/xrpl/src/models/transactions/accountSet.ts index 1d4c9078a5..8a74c51e6e 100644 --- a/packages/xrpl/src/models/transactions/accountSet.ts +++ b/packages/xrpl/src/models/transactions/accountSet.ts @@ -3,6 +3,7 @@ import { ValidationError } from '../../errors' import { Account, BaseTransaction, + GlobalFlagsInterface, isAccount, validateBaseTransaction, validateOptionalField, @@ -112,7 +113,7 @@ export enum AccountSetTfFlags { * // } * ``` */ -export interface AccountSetFlagsInterface { +export interface AccountSetFlagsInterface extends GlobalFlagsInterface { tfRequireDestTag?: boolean tfOptionalDestTag?: boolean tfRequireAuth?: boolean diff --git a/packages/xrpl/src/models/transactions/batch.ts b/packages/xrpl/src/models/transactions/batch.ts new file mode 100644 index 0000000000..6448e5a0e8 --- /dev/null +++ b/packages/xrpl/src/models/transactions/batch.ts @@ -0,0 +1,147 @@ +import { ValidationError } from '../../errors' +import { Signer } from '../common' + +import { + BaseTransaction, + GlobalFlagsInterface, + isArray, + isObject, + isString, + validateBaseTransaction, + validateOptionalField, + validateRequiredField, +} from './common' +import type { SubmittableTransaction } from './transaction' + +/** + * Enum representing values of {@link Batch} transaction flags. + * + * @category Transaction Flags + */ +export enum BatchFlags { + tfAllOrNothing = 0x00010000, + tfOnlyOne = 0x00020000, + tfUntilFailure = 0x00040000, + tfIndependent = 0x00080000, +} + +/** + * Map of flags to boolean values representing {@link Batch} transaction + * flags. + * + * @category Transaction Flags + */ +export interface BatchFlagsInterface extends GlobalFlagsInterface { + tfAllOrNothing?: boolean + tfOnlyOne?: boolean + tfUntilFailure?: boolean + tfIndependent?: boolean +} + +export type BatchInnerTransaction = SubmittableTransaction & { + Fee?: '0' + + SigningPubKey?: '' + + TxnSignature?: never + + Signers?: never + + LastLedgerSequence?: never +} + +export interface BatchSigner { + BatchSigner: { + Account: string + + SigningPubKey?: string + + TxnSignature?: string + + Signers?: Signer[] + } +} + +/** + * @category Transaction Models + */ +export interface Batch extends BaseTransaction { + TransactionType: 'Batch' + + BatchSigners?: BatchSigner[] + + RawTransactions: Array<{ + RawTransaction: BatchInnerTransaction + }> +} + +/** + * Verify the form and type of a Batch at runtime. + * + * @param tx - A Batch Transaction. + * @throws When the Batch is malformed. + */ +// eslint-disable-next-line max-lines-per-function -- needed here due to the complexity +export function validateBatch(tx: Record): void { + validateBaseTransaction(tx) + + validateRequiredField(tx, 'RawTransactions', isArray) + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- checked above + const rawTransactions = tx.RawTransactions as unknown[] + rawTransactions.forEach((rawTxObj, index) => { + if (!isObject(rawTxObj)) { + throw new ValidationError( + `Batch: RawTransactions[${index}] is not object.`, + ) + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- checked above + const rawTxRecord = rawTxObj as Record + validateRequiredField(rawTxRecord, 'RawTransaction', isObject, { + paramName: `RawTransactions[${index}].RawTransaction`, + txType: 'Batch', + }) + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- checked above + const rawTx = rawTxRecord.RawTransaction as Record + if (rawTx.TransactionType === 'Batch') { + throw new ValidationError( + `Batch: RawTransactions[${index}] is a Batch transaction. Cannot nest Batch transactions.`, + ) + } + }) + // Full validation of each `RawTransaction` object is done in `validate` to avoid dependency cycles + + validateOptionalField(tx, 'BatchSigners', isArray) + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- checked above + const batchSigners = tx.BatchSigners as unknown[] | undefined + batchSigners?.forEach((signerObj, index) => { + if (!isObject(signerObj)) { + throw new ValidationError(`Batch: BatchSigners[${index}] is not object.`) + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- checked above + const signerRecord = signerObj as Record + validateRequiredField(signerRecord, 'BatchSigner', isObject, { + paramName: `BatchSigners[${index}].BatchSigner`, + txType: 'Batch', + }) + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- checked above + const signer = signerRecord.BatchSigner as Record + validateRequiredField(signer, 'Account', isString, { + paramName: `BatchSigners[${index}].Account`, + txType: 'Batch', + }) + validateOptionalField(signer, 'SigningPubKey', isString, { + paramName: `BatchSigners[${index}].SigningPubKey`, + txType: 'Batch', + }) + validateOptionalField(signer, 'TxnSignature', isString, { + paramName: `BatchSigners[${index}].TxnSignature`, + txType: 'Batch', + }) + validateOptionalField(signer, 'Signers', isArray, { + paramName: `BatchSigners[${index}].Signers`, + txType: 'Batch', + }) + }) +} diff --git a/packages/xrpl/src/models/transactions/common.ts b/packages/xrpl/src/models/transactions/common.ts index d82625355f..a2bcf6a858 100644 --- a/packages/xrpl/src/models/transactions/common.ts +++ b/packages/xrpl/src/models/transactions/common.ts @@ -208,31 +208,57 @@ export function isXChainBridge(input: unknown): input is XChainBridge { ) } +/** + * Verify the form and type of an Object at runtime. + * + * @param input - The object to check the form and type of. + * @returns Whether the Object is properly formed. + */ +export function isObject(input: unknown): input is object { + return typeof input === 'object' +} + +/** + * Verify the form and type of an Array at runtime. + * + * @param input - The object to check the form and type of. + * @returns Whether the Array is properly formed. + */ +export function isArray(input: unknown): boolean { + return Array.isArray(input) +} + /* eslint-disable @typescript-eslint/restrict-template-expressions -- tx.TransactionType is checked before any calls */ /** * Verify the form and type of a required type for a transaction at runtime. * - * @param tx - The transaction input to check the form and type of. - * @param paramName - The name of the transaction parameter. + * @param tx - The object input to check the form and type of. + * @param param - The object parameter. * @param checkValidity - The function to use to check the type. + * @param errorOpts - Extra values to make the error message easier to understand. + * @param errorOpts.txType - The transaction type throwing the error. + * @param errorOpts.paramName - The name of the parameter in the transaction with the error. * @throws */ +// eslint-disable-next-line max-params -- helper function export function validateRequiredField( tx: Record, - paramName: string, + param: string, checkValidity: (inp: unknown) => boolean, + errorOpts: { + txType?: string + paramName?: string + } = {}, ): void { - if (tx[paramName] == null) { - throw new ValidationError( - `${tx.TransactionType}: missing field ${paramName}`, - ) + const paramNameStr = errorOpts.paramName ?? param + const txType = errorOpts.txType ?? tx.TransactionType + if (tx[param] == null) { + throw new ValidationError(`${txType}: missing field ${paramNameStr}`) } - if (!checkValidity(tx[paramName])) { - throw new ValidationError( - `${tx.TransactionType}: invalid field ${paramName}`, - ) + if (!checkValidity(tx[param])) { + throw new ValidationError(`${txType}: invalid field ${paramNameStr}`) } } @@ -240,26 +266,39 @@ export function validateRequiredField( * Verify the form and type of an optional type for a transaction at runtime. * * @param tx - The transaction input to check the form and type of. - * @param paramName - The name of the transaction parameter. + * @param param - The object parameter. * @param checkValidity - The function to use to check the type. + * @param errorOpts - Extra values to make the error message easier to understand. + * @param errorOpts.txType - The transaction type throwing the error. + * @param errorOpts.paramName - The name of the parameter in the transaction with the error. * @throws */ +// eslint-disable-next-line max-params -- helper function export function validateOptionalField( tx: Record, - paramName: string, + param: string, checkValidity: (inp: unknown) => boolean, + errorOpts: { + txType?: string + paramName?: string + } = {}, ): void { - if (tx[paramName] !== undefined && !checkValidity(tx[paramName])) { - throw new ValidationError( - `${tx.TransactionType}: invalid field ${paramName}`, - ) + const paramNameStr = errorOpts.paramName ?? param + const txType = errorOpts.txType ?? tx.TransactionType + if (tx[param] !== undefined && !checkValidity(tx[param])) { + throw new ValidationError(`${txType}: invalid field ${paramNameStr}`) } } /* eslint-enable @typescript-eslint/restrict-template-expressions -- checked before */ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -- no global flags right now, so this is fine -export interface GlobalFlags {} +export enum GlobalFlags { + tfInnerBatchTxn = 0x40000000, +} + +export interface GlobalFlagsInterface { + tfInnerBatchTxn?: boolean +} /** * Every transaction has the same set of common fields. @@ -292,7 +331,7 @@ export interface BaseTransaction { */ AccountTxnID?: string /** Set of bit-flags for this transaction. */ - Flags?: number | GlobalFlags + Flags?: number | GlobalFlagsInterface /** * Highest ledger index this transaction can appear in. Specifying this field * places a strict upper limit on how long the transaction can wait to be @@ -355,7 +394,9 @@ export function validateBaseTransaction(common: Record): void { } if (!TRANSACTION_TYPES.includes(common.TransactionType)) { - throw new ValidationError('BaseTransaction: Unknown TransactionType') + throw new ValidationError( + `BaseTransaction: Unknown TransactionType ${common.TransactionType}`, + ) } validateRequiredField(common, 'Account', isString) diff --git a/packages/xrpl/src/models/transactions/index.ts b/packages/xrpl/src/models/transactions/index.ts index 956f61a344..101e905626 100644 --- a/packages/xrpl/src/models/transactions/index.ts +++ b/packages/xrpl/src/models/transactions/index.ts @@ -106,3 +106,4 @@ export { XChainModifyBridgeFlags, XChainModifyBridgeFlagsInterface, } from './XChainModifyBridge' +export { Batch } from './batch' diff --git a/packages/xrpl/src/models/transactions/metadata.ts b/packages/xrpl/src/models/transactions/metadata.ts index 75551d1a06..3d6a6db53c 100644 --- a/packages/xrpl/src/models/transactions/metadata.ts +++ b/packages/xrpl/src/models/transactions/metadata.ts @@ -88,6 +88,8 @@ export interface TransactionMetadataBase { delivered_amount?: Amount | MPTAmount | 'unavailable' TransactionIndex: number TransactionResult: string + + ParentBatchID?: string } export type TransactionMetadata = diff --git a/packages/xrpl/src/models/transactions/offerCreate.ts b/packages/xrpl/src/models/transactions/offerCreate.ts index 782e635499..5870730e6a 100644 --- a/packages/xrpl/src/models/transactions/offerCreate.ts +++ b/packages/xrpl/src/models/transactions/offerCreate.ts @@ -3,7 +3,7 @@ import { Amount } from '../common' import { BaseTransaction, - GlobalFlags, + GlobalFlagsInterface, validateBaseTransaction, isAmount, } from './common' @@ -78,7 +78,7 @@ export enum OfferCreateFlags { * // } * ``` */ -export interface OfferCreateFlagsInterface extends GlobalFlags { +export interface OfferCreateFlagsInterface extends GlobalFlagsInterface { tfPassive?: boolean tfImmediateOrCancel?: boolean tfFillOrKill?: boolean diff --git a/packages/xrpl/src/models/transactions/payment.ts b/packages/xrpl/src/models/transactions/payment.ts index 25f3dc8974..d8e1bdf24d 100644 --- a/packages/xrpl/src/models/transactions/payment.ts +++ b/packages/xrpl/src/models/transactions/payment.ts @@ -5,7 +5,7 @@ import { isFlagEnabled } from '../utils' import { BaseTransaction, isAmount, - GlobalFlags, + GlobalFlagsInterface, validateBaseTransaction, isAccount, validateRequiredField, @@ -83,7 +83,7 @@ export enum PaymentFlags { * // } * ``` */ -export interface PaymentFlagsInterface extends GlobalFlags { +export interface PaymentFlagsInterface extends GlobalFlagsInterface { /** * Do not use the default path; only use paths included in the Paths field. * This is intended to force the transaction to take arbitrage opportunities. diff --git a/packages/xrpl/src/models/transactions/paymentChannelClaim.ts b/packages/xrpl/src/models/transactions/paymentChannelClaim.ts index f99368b388..78a9c3b570 100644 --- a/packages/xrpl/src/models/transactions/paymentChannelClaim.ts +++ b/packages/xrpl/src/models/transactions/paymentChannelClaim.ts @@ -2,7 +2,7 @@ import { ValidationError } from '../../errors' import { BaseTransaction, - GlobalFlags, + GlobalFlagsInterface, validateBaseTransaction, validateCredentialsList, } from './common' @@ -72,7 +72,8 @@ export enum PaymentChannelClaimFlags { * // } * ``` */ -export interface PaymentChannelClaimFlagsInterface extends GlobalFlags { +export interface PaymentChannelClaimFlagsInterface + extends GlobalFlagsInterface { /** * Clear the channel's Expiration time. (Expiration is different from the * channel's immutable CancelAfter time.) Only the source address of the diff --git a/packages/xrpl/src/models/transactions/transaction.ts b/packages/xrpl/src/models/transactions/transaction.ts index e774b2c41a..73cf2718a0 100644 --- a/packages/xrpl/src/models/transactions/transaction.ts +++ b/packages/xrpl/src/models/transactions/transaction.ts @@ -14,6 +14,7 @@ import { AMMDelete, validateAMMDelete } from './AMMDelete' import { AMMDeposit, validateAMMDeposit } from './AMMDeposit' import { AMMVote, validateAMMVote } from './AMMVote' import { AMMWithdraw, validateAMMWithdraw } from './AMMWithdraw' +import { Batch, validateBatch } from './batch' import { CheckCancel, validateCheckCancel } from './checkCancel' import { CheckCash, validateCheckCash } from './checkCash' import { CheckCreate, validateCheckCreate } from './checkCreate' @@ -122,6 +123,7 @@ export type SubmittableTransaction = | AMMWithdraw | AccountDelete | AccountSet + | Batch | CheckCancel | CheckCash | CheckCreate @@ -291,6 +293,19 @@ export function validate(transaction: Record): void { validateAccountSet(tx) break + case 'Batch': + validateBatch(tx) + // This is done here to avoid issues with dependency cycles + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- okay here + // @ts-expect-error -- already checked + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- already checked above + tx.RawTransactions.forEach((innerTx: Record) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- already checked above + validate(innerTx.RawTransaction as Record) + }) + break + case 'CheckCancel': validateCheckCancel(tx) break diff --git a/packages/xrpl/src/models/transactions/trustSet.ts b/packages/xrpl/src/models/transactions/trustSet.ts index f584261af3..e05d091e3e 100644 --- a/packages/xrpl/src/models/transactions/trustSet.ts +++ b/packages/xrpl/src/models/transactions/trustSet.ts @@ -3,7 +3,7 @@ import { IssuedCurrencyAmount } from '../common' import { BaseTransaction, - GlobalFlags, + GlobalFlagsInterface, isAmount, validateBaseTransaction, } from './common' @@ -72,7 +72,7 @@ export enum TrustSetFlags { * // } * ``` */ -export interface TrustSetFlagsInterface extends GlobalFlags { +export interface TrustSetFlagsInterface extends GlobalFlagsInterface { /** * Authorize the other party to hold currency issued by this account. (No * effect unless using the asfRequireAuth AccountSet flag.) Cannot be unset. diff --git a/packages/xrpl/src/models/utils/flags.ts b/packages/xrpl/src/models/utils/flags.ts index c2a88662a7..eff6f7c689 100644 --- a/packages/xrpl/src/models/utils/flags.ts +++ b/packages/xrpl/src/models/utils/flags.ts @@ -7,6 +7,8 @@ import { import { AccountSetTfFlags } from '../transactions/accountSet' import { AMMDepositFlags } from '../transactions/AMMDeposit' import { AMMWithdrawFlags } from '../transactions/AMMWithdraw' +import { BatchFlags } from '../transactions/batch' +import { GlobalFlags } from '../transactions/common' import { MPTokenAuthorizeFlags } from '../transactions/MPTokenAuthorize' import { MPTokenIssuanceCreateFlags } from '../transactions/MPTokenIssuanceCreate' import { MPTokenIssuanceSetFlags } from '../transactions/MPTokenIssuanceSet' @@ -49,6 +51,7 @@ const txToFlag = { AccountSet: AccountSetTfFlags, AMMDeposit: AMMDepositFlags, AMMWithdraw: AMMWithdrawFlags, + Batch: BatchFlags, MPTokenAuthorize: MPTokenAuthorizeFlags, MPTokenIssuanceCreate: MPTokenIssuanceCreateFlags, MPTokenIssuanceSet: MPTokenIssuanceSetFlags, @@ -115,7 +118,15 @@ export function convertTxFlagsToNumber(tx: Transaction): number { }, 0) } - return 0 + return Object.keys(tx.Flags).reduce((resultFlags, flag) => { + if (GlobalFlags[flag] == null) { + throw new ValidationError( + `Invalid flag ${flag}. Valid flags are ${JSON.stringify(GlobalFlags)}`, + ) + } + + return tx.Flags?.[flag] ? resultFlags | GlobalFlags[flag] : resultFlags + }, 0) } /** @@ -146,3 +157,21 @@ export function parseTransactionFlags(tx: Transaction): object { return booleanFlagMap } + +/** + * Determines whether a transaction has a certain flag enabled. + * + * @param tx The transaction. + * @param flag The flag to check. + * @returns Whether `flag` is enabled on `tx`. + */ +export function hasFlag(tx: Transaction, flag: number): boolean { + if (tx.Flags == null) { + return false + } + if (typeof tx.Flags === 'number') { + return isFlagEnabled(tx.Flags, flag) + } + const txFlagNum = convertTxFlagsToNumber(tx) + return isFlagEnabled(txFlagNum, flag) +} diff --git a/packages/xrpl/src/sugar/autofill.ts b/packages/xrpl/src/sugar/autofill.ts index ca0757611d..3352a74da5 100644 --- a/packages/xrpl/src/sugar/autofill.ts +++ b/packages/xrpl/src/sugar/autofill.ts @@ -1,10 +1,12 @@ +/* eslint-disable max-lines -- lots of helper functions needed for autofill */ import BigNumber from 'bignumber.js' import { xAddressToClassicAddress, isValidXAddress } from 'ripple-address-codec' import { type Client } from '..' import { ValidationError, XrplError } from '../errors' import { AccountInfoRequest, AccountObjectsRequest } from '../models/methods' -import { Transaction } from '../models/transactions' +import { Batch, Payment, Transaction } from '../models/transactions' +import { GlobalFlags } from '../models/transactions/common' import { xrpToDrops } from '../utils' import getFeeXrp from './getFeeXrp' @@ -207,6 +209,20 @@ function convertToClassicAddress(tx: Transaction, fieldName: string): void { } } +// Helper function to get the next valid sequence number for an account. +async function getNextValidSequenceNumber( + client: Client, + account: string, +): Promise { + const request: AccountInfoRequest = { + command: 'account_info', + account, + ledger_index: 'current', + } + const data = await client.request(request) + return data.result.account_data.Sequence +} + /** * Sets the next valid sequence number for a transaction. * @@ -219,14 +235,8 @@ export async function setNextValidSequenceNumber( client: Client, tx: Transaction, ): Promise { - const request: AccountInfoRequest = { - command: 'account_info', - account: tx.Account, - ledger_index: 'current', - } - const data = await client.request(request) // eslint-disable-next-line no-param-reassign, require-atomic-updates -- param reassign is safe with no race condition - tx.Sequence = data.result.account_data.Sequence + tx.Sequence = await getNextValidSequenceNumber(client, tx.Account) } /** @@ -274,13 +284,16 @@ export async function calculateFeePerTransactionType( scaleValue(netFeeDrops, 33 + fulfillmentBytesSize / 16), ) baseFee = product.dp(0, BigNumber.ROUND_CEIL) - } - - if ( + } else if ( tx.TransactionType === 'AccountDelete' || tx.TransactionType === 'AMMCreate' ) { baseFee = await fetchAccountDeleteFee(client) + } else if (tx.TransactionType === 'Batch') { + baseFee = BigNumber.sum( + baseFee.times(2), + baseFee.times(tx.RawTransactions.length + (tx.BatchSigners?.length ?? 0)), + ) } /* @@ -359,3 +372,119 @@ export async function checkAccountDeleteBlockers( resolve() }) } +/** + * Replaces Amount with DeliverMax if needed. + * + * @param tx - The transaction object. + * @throws ValidationError if Amount and DeliverMax are both provided but do not match. + */ +export function handleDeliverMax(tx: Payment): void { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- ignore type-assertions on the DeliverMax property + // @ts-expect-error -- DeliverMax property exists only at the RPC level, not at the protocol level + if (tx.DeliverMax != null) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- needed here + if (tx.Amount == null) { + // If only DeliverMax is provided, use it to populate the Amount field + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- ignore type-assertions on the DeliverMax property + // @ts-expect-error -- DeliverMax property exists only at the RPC level, not at the protocol level + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, no-param-reassign -- known RPC-level property + tx.Amount = tx.DeliverMax + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- ignore type-assertions on the DeliverMax property + // @ts-expect-error -- DeliverMax property exists only at the RPC level, not at the protocol level + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- needed here + if (tx.Amount != null && tx.Amount !== tx.DeliverMax) { + throw new ValidationError( + 'PaymentTransaction: Amount and DeliverMax fields must be identical when both are provided', + ) + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- ignore type-assertions on the DeliverMax property + // @ts-expect-error -- DeliverMax property exists only at the RPC level, not at the protocol level + // eslint-disable-next-line no-param-reassign -- needed here + delete tx.DeliverMax + } +} + +/** + * Autofills all the relevant `x` fields. + * + * @param client - The client object. + * @param tx - The transaction object. + * @returns A promise that resolves with void if there are no blockers, or rejects with an XrplError if there are blockers. + */ +// eslint-disable-next-line complexity, max-lines-per-function, max-statements -- needed here, lots to check +export async function autofillBatchTxn( + client: Client, + tx: Batch, +): Promise { + const accountSequences: Record = {} + + for await (const rawTxn of tx.RawTransactions) { + const txn = rawTxn.RawTransaction + + // Flag processing + /* eslint-disable no-bitwise -- needed here for flag parsing */ + if (txn.Flags == null) { + txn.Flags = GlobalFlags.tfInnerBatchTxn + } else if (typeof txn.Flags === 'number') { + if (!((txn.Flags & GlobalFlags.tfInnerBatchTxn) === 0)) { + txn.Flags |= GlobalFlags.tfInnerBatchTxn + } + } else if (!txn.Flags.tfInnerBatchTxn) { + txn.Flags.tfInnerBatchTxn = true + } + /* eslint-enable no-bitwise */ + + // Sequence processing + if (txn.Sequence == null && txn.TicketSequence == null) { + if (txn.Account in accountSequences) { + txn.Sequence = accountSequences[txn.Account] + accountSequences[txn.Account] += 1 + } else { + const nextSequence = await getNextValidSequenceNumber( + client, + txn.Account, + ) + const sequence = + txn.Account === tx.Account ? nextSequence + 1 : nextSequence + accountSequences[txn.Account] = sequence + 1 + txn.Sequence = sequence + } + } + + if (txn.Fee == null) { + txn.Fee = '0' + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JS check + } else if (txn.Fee !== '0') { + throw new XrplError('Must have `Fee of "0" in inner Batch transaction.') + } + + if (txn.SigningPubKey == null) { + txn.SigningPubKey = '' + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JS check + } else if (txn.SigningPubKey !== '') { + throw new XrplError( + 'Must have `SigningPubKey` of "" in inner Batch transaction.', + ) + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- needed for JS + if (txn.TxnSignature != null) { + throw new XrplError( + 'Must not have `TxnSignature` in inner Batch transaction.', + ) + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- needed for JS + if (txn.Signers != null) { + throw new XrplError('Must not have `Signers` in inner Batch transaction.') + } + + if (txn.NetworkID == null) { + txn.NetworkID = txNeedsNetworkID(client) ? client.networkID : undefined + } + } +} diff --git a/packages/xrpl/src/sugar/submit.ts b/packages/xrpl/src/sugar/submit.ts index b14f68d945..c87954642f 100644 --- a/packages/xrpl/src/sugar/submit.ts +++ b/packages/xrpl/src/sugar/submit.ts @@ -175,7 +175,6 @@ function isSigned(transaction: SubmittableTransaction | string): boolean { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- we know that tx.Signers is an array of Signers const signers = tx.Signers as Signer[] for (const signer of signers) { - // eslint-disable-next-line max-depth -- necessary for checking if signer is signed if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- necessary check signer.Signer.SigningPubKey == null || @@ -283,7 +282,7 @@ export function getLastLedgerSequence( transaction: Transaction | string, ): number | null { const tx = typeof transaction === 'string' ? decode(transaction) : transaction - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- converts LastLedgSeq to number if present. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- converts LastLedgerSeq to number if present. return tx.LastLedgerSequence as number | null } diff --git a/packages/xrpl/src/utils/hashes/hashLedger.ts b/packages/xrpl/src/utils/hashes/hashLedger.ts index e9cb9329b6..0788e1ee4d 100644 --- a/packages/xrpl/src/utils/hashes/hashLedger.ts +++ b/packages/xrpl/src/utils/hashes/hashLedger.ts @@ -12,6 +12,8 @@ import { APIVersion } from '../../models' import { LedgerEntry } from '../../models/ledger' import { LedgerVersionMap } from '../../models/ledger/Ledger' import { Transaction, TransactionMetadata } from '../../models/transactions' +import { GlobalFlags } from '../../models/transactions/common' +import { hasFlag } from '../../models/utils/flags' import HashPrefix from './HashPrefix' import sha512Half from './sha512Half' @@ -66,7 +68,7 @@ function addLengthPrefix(hex: string): string { * * @param tx - A transaction to hash. Tx may be in binary blob form. Tx must be signed. * @returns A hash of tx. - * @throws ValidationError if the Transaction is unsigned.\ + * @throws ValidationError if the Transaction is unsigned. * @category Utilities */ export function hashSignedTx(tx: Transaction | string): string { @@ -84,7 +86,8 @@ export function hashSignedTx(tx: Transaction | string): string { if ( txObject.TxnSignature === undefined && txObject.Signers === undefined && - txObject.SigningPubKey === undefined + txObject.SigningPubKey === undefined && + !hasFlag(txObject, GlobalFlags.tfInnerBatchTxn) ) { throw new ValidationError('The transaction must be signed to hash it.') } diff --git a/packages/xrpl/test/client/autofill.test.ts b/packages/xrpl/test/client/autofill.test.ts index 8c2d9b5ec8..653c010711 100644 --- a/packages/xrpl/test/client/autofill.test.ts +++ b/packages/xrpl/test/client/autofill.test.ts @@ -6,6 +6,7 @@ import { EscrowFinish, Payment, Transaction, + Batch, } from '../../src' import { ValidationError } from '../../src/errors' import rippled from '../fixtures/rippled' @@ -435,4 +436,86 @@ describe('client.autofill', function () { assert.strictEqual(txResult.Sequence, 23) assert.strictEqual(txResult.LastLedgerSequence, 9038234) }) + + it('should autofill Batch transaction with single account', async function () { + const tx: Batch = { + TransactionType: 'Batch', + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + RawTransactions: [ + { + RawTransaction: { + TransactionType: 'DepositPreauth', + Flags: 0x40000000, + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + Authorize: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + }, + }, + { + RawTransaction: { + TransactionType: 'DepositPreauth', + Flags: 0x40000000, + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + Authorize: 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn', + }, + }, + ], + Fee, + Sequence, + LastLedgerSequence, + } + testContext.mockRippled!.addResponse('account_info', { + status: 'success', + type: 'response', + result: { + account_data: { + Sequence: 23, + }, + }, + }) + const txResult = await testContext.client.autofill(tx) + txResult.RawTransactions.forEach((rawTxOuter, index) => { + const rawTx = rawTxOuter.RawTransaction + assert.strictEqual(rawTx.Sequence, 23 + index + 1) + }) + }) + + it('should autofill Batch transaction with single account', async function () { + const tx: Transaction = { + TransactionType: 'Batch', + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + RawTransactions: [ + { + RawTransaction: { + TransactionType: 'DepositPreauth', + Flags: 0x40000000, + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + Authorize: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + }, + }, + { + RawTransaction: { + TransactionType: 'DepositPreauth', + Flags: 0x40000000, + Account: 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn', + Authorize: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + }, + }, + ], + Fee, + Sequence, + LastLedgerSequence, + } + testContext.mockRippled!.addResponse('account_info', { + status: 'success', + type: 'response', + result: { + account_data: { + Sequence: 23, + }, + }, + }) + const txResult = await testContext.client.autofill(tx) + assert.strictEqual(txResult.RawTransactions[0].RawTransaction.Sequence, 24) + assert.strictEqual(txResult.RawTransactions[1].RawTransaction.Sequence, 23) + }) }) diff --git a/packages/xrpl/test/integration/submitAndWait.test.ts b/packages/xrpl/test/integration/submitAndWait.test.ts index bae32f7d0c..7d1b3a3982 100644 --- a/packages/xrpl/test/integration/submitAndWait.test.ts +++ b/packages/xrpl/test/integration/submitAndWait.test.ts @@ -59,7 +59,6 @@ describe('client.submitAndWait', function () { retries = 0 break } catch (err) { - // eslint-disable-next-line max-depth -- Necessary if (!(err instanceof Error)) { throw err } @@ -69,7 +68,7 @@ describe('client.submitAndWait', function () { const errorCode = matches?.groups?.errorCode // Retry if another transaction finished before this one - // eslint-disable-next-line max-depth -- Testing + if (['tefPAST_SEQ', 'tefMAX_LEDGER'].includes(errorCode || '')) { // eslint-disable-next-line no-await-in-loop, no-promise-executor-return -- We are waiting on retries await new Promise((resolve) => setTimeout(resolve, 1000)) diff --git a/packages/xrpl/test/integration/transactions/batch.test.ts b/packages/xrpl/test/integration/transactions/batch.test.ts new file mode 100644 index 0000000000..3ca8d1b7f0 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/batch.test.ts @@ -0,0 +1,113 @@ +import { Batch, Wallet } from '../../../src' +import { BatchFlags } from '../../../src/models/transactions/batch' +import { signMultiBatch } from '../../../src/Wallet/batchSigner' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { + generateFundedWallet, + testTransaction, + verifySubmittedTransaction, +} from '../utils' + +// how long before each test case times out +const TIMEOUT = 20000 + +describe('Batch', function () { + let testContext: XrplIntegrationTestContext + let destination: Wallet + let wallet2: Wallet + + async function testBatchTransaction( + batch: Batch, + wallet: Wallet, + retry?: { + count: number + delayMs: number + }, + ): Promise { + await testTransaction(testContext.client, batch, wallet, retry) + const promises: Array> = [] + for (const rawTx of batch.RawTransactions) { + promises.push( + verifySubmittedTransaction(testContext.client, rawTx.RawTransaction), + ) + } + await Promise.all(promises) + } + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + wallet2 = await generateFundedWallet(testContext.client) + destination = await generateFundedWallet(testContext.client) + }, TIMEOUT) + afterAll(async () => teardownClient(testContext)) + + it( + 'base', + async () => { + const tx: Batch = { + TransactionType: 'Batch', + Account: testContext.wallet.classicAddress, + Flags: BatchFlags.tfAllOrNothing, + RawTransactions: [ + { + RawTransaction: { + TransactionType: 'Payment', + Account: testContext.wallet.classicAddress, + Destination: destination.classicAddress, + Amount: '10000000', + }, + }, + { + RawTransaction: { + TransactionType: 'Payment', + Account: testContext.wallet.classicAddress, + Destination: destination.classicAddress, + Amount: '10000000', + }, + }, + ], + } + const autofilled = await testContext.client.autofill(tx) + await testBatchTransaction(autofilled, testContext.wallet) + }, + TIMEOUT, + ) + + it( + 'batch multisign', + async () => { + const tx: Batch = { + TransactionType: 'Batch', + Account: testContext.wallet.classicAddress, + Flags: BatchFlags.tfAllOrNothing, + RawTransactions: [ + { + RawTransaction: { + TransactionType: 'Payment', + Account: testContext.wallet.classicAddress, + Destination: destination.classicAddress, + Amount: '10000000', + }, + }, + { + RawTransaction: { + TransactionType: 'Payment', + Account: wallet2.classicAddress, + Destination: destination.classicAddress, + Amount: '10000000', + }, + }, + ], + } + const autofilled = await testContext.client.autofill(tx) + signMultiBatch(wallet2, autofilled) + await testBatchTransaction(autofilled, testContext.wallet) + }, + TIMEOUT, + ) +}) diff --git a/packages/xrpl/test/models/Batch.test.ts b/packages/xrpl/test/models/Batch.test.ts new file mode 100644 index 0000000000..e2e8618985 --- /dev/null +++ b/packages/xrpl/test/models/Batch.test.ts @@ -0,0 +1,163 @@ +import { assert } from 'chai' + +import { validate } from '../../src' +import { validateBatch } from '../../src/models/transactions/batch' +import { assertTxValidationError } from '../testUtils' + +/** + * Batch Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('Batch', function () { + let tx: any + + beforeEach(function () { + tx = { + Account: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + BatchSigners: [ + { + BatchSigner: { + Account: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + SigningPubKey: + '02691AC5AE1C4C333AE5DF8A93BDC495F0EEBFC6DB0DA7EB6EF808F3AFC006E3FE', + TxnSignature: + '30450221008E595499C334127A23190F61FB9ADD8B8C501D543E37945B11FABB66B097A6130220138C908E8C4929B47E994A46D611FAC17AB295CFB8D9E0828B32F2947B97394B', + }, + }, + ], + Flags: 1, + RawTransactions: [ + { + RawTransaction: { + Account: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Amount: '5000000', + Destination: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Fee: '0', + NetworkID: 21336, + Sequence: 0, + SigningPubKey: '', + TransactionType: 'Payment', + }, + }, + { + RawTransaction: { + Account: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Amount: '1000000', + Destination: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Fee: '0', + NetworkID: 21336, + Sequence: 0, + SigningPubKey: '', + TransactionType: 'Payment', + }, + }, + ], + TransactionType: 'Batch', + } + }) + + it('verifies valid Batch', function () { + assert.doesNotThrow(() => validateBatch(tx)) + assert.doesNotThrow(() => validate(tx)) + }) + + it('verifies single-account Batch', function () { + tx = { + Account: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Flags: 1, + RawTransactions: [ + { + RawTransaction: { + Account: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Amount: '5000000', + Destination: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Fee: '0', + NetworkID: 21336, + Sequence: 0, + SigningPubKey: '', + TransactionType: 'Payment', + }, + }, + { + RawTransaction: { + Account: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Amount: '1000000', + Destination: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Fee: '0', + NetworkID: 21336, + Sequence: 0, + SigningPubKey: '', + TransactionType: 'Payment', + }, + }, + ], + TransactionType: 'Batch', + } + assert.doesNotThrow(() => validateBatch(tx)) + assert.doesNotThrow(() => validate(tx)) + }) + + it('throws w/ invalid BatchSigners', function () { + tx.BatchSigners = 0 + assertTxValidationError( + tx, + validateBatch, + 'Batch: invalid field BatchSigners', + ) + }) + + it('throws w/ missing RawTransactions', function () { + delete tx.RawTransactions + assertTxValidationError( + tx, + validateBatch, + 'Batch: missing field RawTransactions', + ) + }) + + it('throws w/ invalid RawTransactions', function () { + tx.RawTransactions = 0 + assertTxValidationError( + tx, + validateBatch, + 'Batch: invalid field RawTransactions', + ) + }) + + it('throws w/ invalid RawTransactions object', function () { + tx.RawTransactions = [0] + assertTxValidationError( + tx, + validateBatch, + 'Batch: RawTransactions[0] is not object', + ) + }) + + it('throws w/ invalid RawTransactions.RawTransaction object', function () { + tx.RawTransactions = [{ RawTransaction: 0 }] + assertTxValidationError( + tx, + validateBatch, + 'Batch: invalid field RawTransactions[0].RawTransaction', + ) + }) + + it('throws w/ nested Batch', function () { + tx.RawTransactions = [{ RawTransaction: tx }] + assertTxValidationError( + tx, + validateBatch, + 'Batch: RawTransactions[0] is a Batch transaction. Cannot nest Batch transactions.', + ) + }) + + it('throws w/ non-object in BatchSigner list', function () { + tx.BatchSigners = [1] + assertTxValidationError( + tx, + validateBatch, + 'Batch: BatchSigners[0] is not object.', + ) + }) +}) diff --git a/packages/xrpl/test/testUtils.ts b/packages/xrpl/test/testUtils.ts index ee922a63e4..71342382de 100644 --- a/packages/xrpl/test/testUtils.ts +++ b/packages/xrpl/test/testUtils.ts @@ -5,7 +5,12 @@ import net from 'net' import { assert } from 'chai' import omit from 'lodash/omit' -import { rippleTimeToUnixTime, unixTimeToRippleTime } from '../src' +import { + rippleTimeToUnixTime, + unixTimeToRippleTime, + validate, + ValidationError, +} from '../src' import addresses from './fixtures/addresses.json' @@ -52,6 +57,22 @@ export function assertResultMatch( ) } +/** + * Check that a transaction error validation fails properly. + * + * @param tx The transaction that should fail validation. + * @param validateTx The transaction-specific validation function (e.g. `validatePayment`). + * @param errorMessage The error message that should be included in the error. + */ +export function assertTxValidationError( + tx: any, + validateTx: (tx: any) => void, + errorMessage: string, +): void { + assert.throws(() => validateTx(tx), ValidationError, errorMessage) + assert.throws(() => validate(tx), ValidationError, errorMessage) +} + /** * Check that the promise rejects with an expected error. * diff --git a/packages/xrpl/test/utils/hashes.test.ts b/packages/xrpl/test/utils/hashes.test.ts index daa42301d4..edc19575fa 100644 --- a/packages/xrpl/test/utils/hashes.test.ts +++ b/packages/xrpl/test/utils/hashes.test.ts @@ -10,6 +10,7 @@ import { Transaction, ValidationError, } from '../../src' +import { BatchInnerTransaction } from '../../src/models/transactions/batch' import { hashStateTree, hashTxTree, @@ -211,4 +212,22 @@ describe('Hashes', function () { 'CA4562711E4679FE9317DD767871E90A404C7A8B84FAFD35EC2CF0231F1F6DAF', ) }) + + it('hashSignedTx - batch transaction', function () { + const transaction: BatchInnerTransaction = { + Account: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Amount: '1000000', + Destination: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Fee: '0', + Flags: 0x40000000, + Sequence: 470, + SigningPubKey: '', + TransactionType: 'Payment', + } + + assert.equal( + hashSignedTx(transaction), + '9EDF5DB29F536DD3919037F1E8A72B040D075571A10C9000294C57B5ECEEA791', + ) + }) }) diff --git a/packages/xrpl/test/wallet/authorizeChannel.test.ts b/packages/xrpl/test/wallet/authorizeChannel.test.ts index f8fd1cb9ca..9681dbe152 100644 --- a/packages/xrpl/test/wallet/authorizeChannel.test.ts +++ b/packages/xrpl/test/wallet/authorizeChannel.test.ts @@ -3,27 +3,29 @@ import { assert } from 'chai' import { ECDSA, Wallet } from '../../src' import { authorizeChannel } from '../../src/Wallet/authorizeChannel' -it('authorizeChannel succeeds with secp256k1 seed', function () { - const secpWallet = Wallet.fromSeed('snGHNrPbHrdUcszeuDEigMdC1Lyyd', { - algorithm: ECDSA.secp256k1, - }) - const channelId = - '5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3' - const amount = '1000000' +describe('authorizeChannel', function () { + it('authorizeChannel succeeds with secp256k1 seed', function () { + const secpWallet = Wallet.fromSeed('snGHNrPbHrdUcszeuDEigMdC1Lyyd', { + algorithm: ECDSA.secp256k1, + }) + const channelId = + '5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3' + const amount = '1000000' - assert.equal( - authorizeChannel(secpWallet, channelId, amount), - '304402204E7052F33DDAFAAA55C9F5B132A5E50EE95B2CF68C0902F61DFE77299BC893740220353640B951DCD24371C16868B3F91B78D38B6F3FD1E826413CDF891FA8250AAC', - ) -}) + assert.equal( + authorizeChannel(secpWallet, channelId, amount), + '304402204E7052F33DDAFAAA55C9F5B132A5E50EE95B2CF68C0902F61DFE77299BC893740220353640B951DCD24371C16868B3F91B78D38B6F3FD1E826413CDF891FA8250AAC', + ) + }) -it('authorizeChannel succeeds with ed25519 seed', function () { - const edWallet = Wallet.fromSeed('sEdSuqBPSQaood2DmNYVkwWTn1oQTj2') - const channelId = - '5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3' - const amount = '1000000' - assert.equal( - authorizeChannel(edWallet, channelId, amount), - '7E1C217A3E4B3C107B7A356E665088B4FBA6464C48C58267BEF64975E3375EA338AE22E6714E3F5E734AE33E6B97AAD59058E1E196C1F92346FC1498D0674404', - ) + it('authorizeChannel succeeds with ed25519 seed', function () { + const edWallet = Wallet.fromSeed('sEdSuqBPSQaood2DmNYVkwWTn1oQTj2') + const channelId = + '5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3' + const amount = '1000000' + assert.equal( + authorizeChannel(edWallet, channelId, amount), + '7E1C217A3E4B3C107B7A356E665088B4FBA6464C48C58267BEF64975E3375EA338AE22E6714E3F5E734AE33E6B97AAD59058E1E196C1F92346FC1498D0674404', + ) + }) }) diff --git a/packages/xrpl/test/wallet/batchSigner.test.ts b/packages/xrpl/test/wallet/batchSigner.test.ts new file mode 100644 index 0000000000..b431fada9f --- /dev/null +++ b/packages/xrpl/test/wallet/batchSigner.test.ts @@ -0,0 +1,370 @@ +import { assert } from 'chai' + +import { + Batch, + decode, + ECDSA, + encode, + ValidationError, + Wallet, +} from '../../src' +import { + BatchFlags, + BatchInnerTransaction, + BatchSigner, +} from '../../src/models/transactions/batch' +import { + combineBatchSigners, + signMultiBatch, +} from '../../src/Wallet/batchSigner' + +const secpWallet = Wallet.fromSeed('spkcsko6Ag3RbCSVXV2FJ8Pd4Zac1', { + algorithm: ECDSA.secp256k1, +}) +const edWallet = Wallet.fromSeed('spkcsko6Ag3RbCSVXV2FJ8Pd4Zac1', { + algorithm: ECDSA.ed25519, +}) +const submitWallet = Wallet.fromSeed('sEd7HmQFsoyj5TAm6d98gytM9LJA1MF', { + algorithm: ECDSA.ed25519, +}) +const regkeyWallet = Wallet.fromSeed('sEdStM1pngFcLQqVfH3RQcg2Qr6ov9e', { + algorithm: ECDSA.ed25519, +}) +const otherWallet = Wallet.generate() + +const nonBatchTx = { + TransactionType: 'Payment', + Account: 'rJy554HmWFFJQGnRfZuoo8nV97XSMq77h7', + Destination: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Amount: '1000', +} + +interface RawTransaction { + RawTransaction: BatchInnerTransaction +} + +describe('Wallet batch operations', function () { + describe('signMultiBatch', function () { + let transaction: Batch + + beforeEach(() => { + transaction = { + Account: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Flags: 1, + RawTransactions: [ + { + RawTransaction: { + Account: 'rJy554HmWFFJQGnRfZuoo8nV97XSMq77h7', + Flags: 0x40000000, + Amount: '5000000', + Destination: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Fee: '0', + Sequence: 215, + SigningPubKey: '', + TransactionType: 'Payment', + }, + }, + { + RawTransaction: { + Account: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Flags: 0x40000000, + Amount: '1000000', + Destination: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Fee: '0', + Sequence: 470, + SigningPubKey: '', + TransactionType: 'Payment', + }, + }, + ], + TransactionType: 'Batch', + } + }) + it('succeeds with secp256k1 seed', function () { + signMultiBatch(secpWallet, transaction) + const expected = [ + { + BatchSigner: { + Account: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + SigningPubKey: + '02691AC5AE1C4C333AE5DF8A93BDC495F0EEBFC6DB0DA7EB6EF808F3AFC006E3FE', + TxnSignature: + '304402207E8238D3D2B24B98BA925D69DDAFA3E7D07F85C8ABF1C040B3D1BEBE2C36E92B02200C122F7F3F86AB8FF89207539CAFB4613D665FF336796F99283ED94C66FB3094', + }, + }, + ] + assert.property(transaction, 'BatchSigners') + assert.strictEqual( + JSON.stringify(transaction.BatchSigners), + JSON.stringify(expected), + ) + }) + + it('succeeds with ed25519 seed', function () { + signMultiBatch(edWallet, transaction) + const expected = [ + { + BatchSigner: { + Account: 'rJy554HmWFFJQGnRfZuoo8nV97XSMq77h7', + SigningPubKey: + 'ED3CC3D14FD80C213BC92A98AFE13A405A030F845EDCFD5E395286A6E9E62BA638', + TxnSignature: + '744FF09C11399F3AC1484F909A92F2D836EA979CB7655BC8F6BC3793F18892F92A16FE41C60EDCD6C2B757FF85D179F1589824ECA397EEA208B94C9D108CDF0A', + }, + }, + ] + assert.property(transaction, 'BatchSigners') + assert.strictEqual( + JSON.stringify(transaction.BatchSigners), + JSON.stringify(expected), + ) + }) + + it('succeeds with a different account', function () { + signMultiBatch(regkeyWallet, transaction, { + batchAccount: edWallet.address, + }) + const expected = [ + { + BatchSigner: { + Account: 'rJy554HmWFFJQGnRfZuoo8nV97XSMq77h7', + SigningPubKey: + 'ED37D3F048B7F1E680B0A97F70C7843160B9F25D6398D07E68B9A2C83AA8E1B156', + TxnSignature: + 'E53E2821CE46C98638E46CA0E6DB712CE45CEC45A697830A5028873D2BA51E1FA008F20526AC16B609401E2F1F8938AE60603223BC9D82A0221CFA5E58C90807', + }, + }, + ] + assert.property(transaction, 'BatchSigners') + assert.strictEqual( + JSON.stringify(transaction.BatchSigners), + JSON.stringify(expected), + ) + }) + + it('succeeds with multisign', function () { + signMultiBatch(regkeyWallet, transaction, { + batchAccount: edWallet.address, + multisign: true, + }) + const expected = [ + { + BatchSigner: { + Account: 'rJy554HmWFFJQGnRfZuoo8nV97XSMq77h7', + Signers: [ + { + Signer: { + Account: 'rwRNeznwHzdfYeKWpevYmax2NSDioyeEtT', + SigningPubKey: + 'ED37D3F048B7F1E680B0A97F70C7843160B9F25D6398D07E68B9A2C83AA8E1B156', + TxnSignature: + 'E53E2821CE46C98638E46CA0E6DB712CE45CEC45A697830A5028873D2BA51E1FA008F20526AC16B609401E2F1F8938AE60603223BC9D82A0221CFA5E58C90807', + }, + }, + ], + }, + }, + ] + assert.property(transaction, 'BatchSigners') + assert.strictEqual( + JSON.stringify(transaction.BatchSigners), + JSON.stringify(expected), + ) + }) + + it('succeeds with multisign + regular key', function () { + signMultiBatch(regkeyWallet, transaction, { + batchAccount: edWallet.address, + multisign: submitWallet.address, + }) + const expected = [ + { + BatchSigner: { + Account: 'rJy554HmWFFJQGnRfZuoo8nV97XSMq77h7', + Signers: [ + { + Signer: { + Account: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + SigningPubKey: + 'ED37D3F048B7F1E680B0A97F70C7843160B9F25D6398D07E68B9A2C83AA8E1B156', + TxnSignature: + 'E53E2821CE46C98638E46CA0E6DB712CE45CEC45A697830A5028873D2BA51E1FA008F20526AC16B609401E2F1F8938AE60603223BC9D82A0221CFA5E58C90807', + }, + }, + ], + }, + }, + ] + assert.property(transaction, 'BatchSigners') + assert.strictEqual( + JSON.stringify(transaction.BatchSigners), + JSON.stringify(expected), + ) + }) + + it('fails with not-included account', function () { + assert.throws( + () => signMultiBatch(otherWallet, transaction), + ValidationError, + 'Must be signing for an address included in the Batch.', + ) + }) + + it('fails with non-Batch transaction', function () { + assert.throws( + // @ts-expect-error - needed for JS/codecov + () => signMultiBatch(otherWallet, nonBatchTx), + ValidationError, + 'Must be a Batch transaction.', + ) + }) + }) + + describe('combineBatchSigners', function () { + let tx1: Batch + let tx2: Batch + const originalTx: Batch = { + Account: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Flags: BatchFlags.tfAllOrNothing, + LastLedgerSequence: 14973, + NetworkID: 21336, + RawTransactions: [ + { + RawTransaction: { + Account: 'rJy554HmWFFJQGnRfZuoo8nV97XSMq77h7', + Amount: '5000000', + Destination: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Fee: '0', + Sequence: 215, + SigningPubKey: '', + TransactionType: 'Payment', + }, + }, + { + RawTransaction: { + Account: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Amount: '1000000', + Destination: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Fee: '0', + Sequence: 470, + SigningPubKey: '', + TransactionType: 'Payment', + }, + }, + ], + Sequence: 215, + TransactionType: 'Batch', + } + let expectedValid: BatchSigner[] + + beforeEach(() => { + tx1 = { ...originalTx } + tx2 = { ...originalTx } + signMultiBatch(edWallet, tx1) + signMultiBatch(secpWallet, tx2) + expectedValid = (tx1.BatchSigners ?? []).concat(tx2.BatchSigners ?? []) + }) + + it('combines valid transactions', function () { + const result = combineBatchSigners([tx1, tx2]) + assert.deepEqual(decode(result).BatchSigners, expectedValid) + }) + + it('combines valid serialized transactions', function () { + const result = combineBatchSigners([encode(tx1), encode(tx2)]) + assert.deepEqual(decode(result).BatchSigners, expectedValid) + }) + + it('sorts the signers', function () { + const result = combineBatchSigners([tx2, tx1]) + assert.deepEqual(decode(result).BatchSigners, expectedValid) + }) + + it('removes signer for Batch submitter', function () { + // add a third inner transaction from the transaction submitter + const rawTx3: RawTransaction = { + RawTransaction: { + Account: 'rJCxK2hX9tDMzbnn3cg1GU2g19Kfmhzxkp', + Amount: '1000000', + Destination: 'rPMh7Pi9ct699iZUTWaytJUoHcJ7cgyziK', + Fee: '0', + Flags: 0x40000000, + Sequence: 470, + SigningPubKey: '', + TransactionType: 'Payment', + }, + } + const rawTxs = originalTx.RawTransactions.concat(rawTx3) + + // set up all the transactions again (repeat what's done in `beforeEach`) + const newTx = { + ...originalTx, + RawTransactions: rawTxs, + } + tx1 = { ...newTx } + tx2 = { ...newTx } + const tx3 = { ...newTx } + signMultiBatch(edWallet, tx1) + signMultiBatch(secpWallet, tx2) + signMultiBatch(submitWallet, tx3) + + // run test + const result = combineBatchSigners([tx1, tx2, tx3]) + const expected = (tx1.BatchSigners ?? []).concat(tx2.BatchSigners ?? []) + assert.deepEqual(decode(result).BatchSigners, expected) + }) + + it('fails with no transactions provided', function () { + assert.throws( + () => combineBatchSigners([]), + ValidationError, + 'There are 0 transactions to combine.', + ) + }) + + it('fails with non-Batch transaction provided', function () { + assert.throws( + // @ts-expect-error - needed for JS/codecov + () => combineBatchSigners([tx1, tx2, nonBatchTx]), + ValidationError, + 'TransactionType must be `Batch`.', + ) + }) + + it('fails with no BatchSigners provided in a transaction', function () { + const badTx1 = { ...tx1 } + delete badTx1.BatchSigners + assert.throws( + () => combineBatchSigners([badTx1, tx2]), + ValidationError, + 'For combining Batch transaction signatures, all transactions must include a BatchSigners field containing an array of signatures.', + ) + + badTx1.BatchSigners = [] + assert.throws( + () => combineBatchSigners([badTx1, tx2]), + ValidationError, + 'For combining Batch transaction signatures, all transactions must include a BatchSigners field containing an array of signatures.', + ) + }) + + it('fails with signed inner transaction', function () { + assert.throws( + () => combineBatchSigners([secpWallet.sign(tx1).tx_blob, tx2]), + ValidationError, + 'Batch transaction must be unsigned.', + ) + }) + + it('fails with different inner transactions', function () { + const badTx2 = { ...tx2 } + badTx2.Flags = BatchFlags.tfIndependent + signMultiBatch(secpWallet, tx2) + assert.throws( + () => combineBatchSigners([tx1, badTx2]), + ValidationError, + 'Flags and transaction hashes are not the same for all provided transactions.', + ) + }) + }) +}) diff --git a/packages/xrpl/test/wallet/index.test.ts b/packages/xrpl/test/wallet/index.test.ts index bbb454108f..5647c8df93 100644 --- a/packages/xrpl/test/wallet/index.test.ts +++ b/packages/xrpl/test/wallet/index.test.ts @@ -598,6 +598,17 @@ describe('Wallet', function () { }) }) + it('sign with regular address for multisignAddress', async function () { + const signature = wallet.sign( + REQUEST_FIXTURES.signAs as Transaction, + wallet.address, + ) + assert.deepEqual(signature, { + tx_blob: RESPONSE_FIXTURES.signAs.signedTransaction, + hash: 'D8CF5FC93CFE5E131A34599AFB7CE186A5B8D1B9F069E35F4634AD3B27837E35', + }) + }) + it('sign with X Address and tag for multisignAddress', async function () { const signature = wallet.sign( REQUEST_FIXTURES.signAs as Transaction,