diff --git a/packages/ripple-binary-codec/src/enums/definitions.json b/packages/ripple-binary-codec/src/enums/definitions.json index 317e1feb..a59de992 100644 --- a/packages/ripple-binary-codec/src/enums/definitions.json +++ b/packages/ripple-binary-codec/src/enums/definitions.json @@ -321,6 +321,16 @@ "type": "UInt16" } ], + [ + "NetworkID", + { + "nth": 1, + "isVLEncoded": false, + "isSerialized": true, + "isSigningField": true, + "type": "UInt32" + } + ], [ "Flags", { @@ -2176,6 +2186,9 @@ "telCAN_NOT_QUEUE_BLOCKED": -389, "telCAN_NOT_QUEUE_FEE": -388, "telCAN_NOT_QUEUE_FULL": -387, + "telWRONG_NETWORK": -386, + "telREQUIRES_NETWORK_ID": -385, + "telNETWORK_ID_MAKES_TX_NON_CANONICAL": -384, "temMALFORMED": -299, "temBAD_AMOUNT": -298, diff --git a/packages/xrpl/src/client/index.ts b/packages/xrpl/src/client/index.ts index 24bad32f..27b2402e 100644 --- a/packages/xrpl/src/client/index.ts +++ b/packages/xrpl/src/client/index.ts @@ -205,6 +205,18 @@ class Client extends EventEmitter { */ public readonly maxFeeXRP: string + /** + * Network ID of the server this client is connected to + * + */ + public networkID: number | undefined + + /** + * Rippled Version used by the server this client is connected to + * + */ + public buildVersion: string | undefined + /** * Creates a new Client with a websocket connection to a rippled server. * @@ -230,8 +242,8 @@ class Client extends EventEmitter { this.emit('error', errorCode, errorMessage, data) }) - this.connection.on('connected', () => { - this.emit('connected') + this.connection.on('reconnect', () => { + this.connection.on('connected', () => this.emit('connected')) }) this.connection.on('disconnected', (code: number) => { @@ -568,6 +580,22 @@ class Client extends EventEmitter { return results } + /** + * Get networkID and buildVersion from server_info + */ + public async getServerInfo(): Promise { + try { + const response = await this.request({ + command: 'server_info', + }) + this.networkID = response.result.info.network_id ?? undefined + this.buildVersion = response.result.info.build_version + } catch (error) { + // eslint-disable-next-line no-console -- Print the error to console but allows client to be connected. + console.error(error) + } + } + /** * Tells the Client instance to connect to its rippled server. * @@ -588,7 +616,10 @@ class Client extends EventEmitter { * @category Network */ public async connect(): Promise { - return this.connection.connect() + return this.connection.connect().then(async () => { + await this.getServerInfo() + this.emit('connected') + }) } /** diff --git a/packages/xrpl/src/models/methods/serverInfo.ts b/packages/xrpl/src/models/methods/serverInfo.ts index a41f3333..97401ccd 100644 --- a/packages/xrpl/src/models/methods/serverInfo.ts +++ b/packages/xrpl/src/models/methods/serverInfo.ts @@ -136,6 +136,10 @@ export interface ServerInfoResponse extends BaseResponse { * overall network's load factor. */ load_factor?: number + /** + * The network id of the server. + */ + network_id?: number /** * Current multiplier to the transaction cost based on * load to this server. diff --git a/packages/xrpl/src/models/transactions/common.ts b/packages/xrpl/src/models/transactions/common.ts index 59abdebf..cbb7d4a4 100644 --- a/packages/xrpl/src/models/transactions/common.ts +++ b/packages/xrpl/src/models/transactions/common.ts @@ -157,6 +157,10 @@ export interface BaseTransaction { * account it says it is from. */ TxnSignature?: string + /** + * The network id of the transaction. + */ + NetworkID?: number } /** @@ -250,6 +254,9 @@ export function validateBaseTransaction(common: Record): void { ) { throw new ValidationError('BaseTransaction: invalid TxnSignature') } + if (common.NetworkID !== undefined && typeof common.NetworkID !== 'number') { + throw new ValidationError('BaseTransaction: invalid NetworkID') + } } /** diff --git a/packages/xrpl/src/sugar/autofill.ts b/packages/xrpl/src/sugar/autofill.ts index 8e3435ee..82766166 100644 --- a/packages/xrpl/src/sugar/autofill.ts +++ b/packages/xrpl/src/sugar/autofill.ts @@ -12,6 +12,13 @@ import getFeeXrp from './getFeeXrp' // Expire unconfirmed transactions after 20 ledger versions, approximately 1 minute, by default const LEDGER_OFFSET = 20 +// Sidechains are expected to have network IDs above this. +// Networks with ID above this restricted number are expected specify an accurate NetworkID field +// in every transaction to that chain to prevent replay attacks. +// Mainnet and testnet are exceptions. More context: https://github.com/XRPLF/rippled/pull/4370 +const RESTRICTED_NETWORKS = 1024 +const REQUIRED_NETWORKID_VERSION = '1.11.0' +const HOOKS_TESTNET_ID = 21338 interface ClassicAccountAndTag { classicAccount: string tag: number | false | undefined @@ -70,8 +77,10 @@ async function autofill( setValidAddresses(tx) setTransactionFlagsToNumber(tx) - const promises: Array> = [] + if (tx.NetworkID == null) { + tx.NetworkID = txNeedsNetworkID(this) ? this.networkID : undefined + } if (tx.Sequence == null) { promises.push(setNextValidSequenceNumber(this, tx)) } @@ -88,6 +97,101 @@ async function autofill( return Promise.all(promises).then(() => tx) } +/** + * Determines whether the source rippled version is not later than the target rippled version. + * Example usage: isNotLaterRippledVersion('1.10.0', '1.11.0') returns true. + * isNotLaterRippledVersion('1.10.0', '1.10.0-b1') returns false. + * + * @param source -- The source rippled version. + * @param target -- The target rippled version. + * @returns True if source is earlier than target, false otherwise. + */ +// eslint-disable-next-line max-lines-per-function, max-statements -- Disable for this helper functions. +function isNotLaterRippledVersion(source: string, target: string): boolean { + if (source === target) { + return true + } + const sourceDecomp = source.split('.') + const targetDecomp = target.split('.') + const sourceMajor = parseInt(sourceDecomp[0], 10) + const sourceMinor = parseInt(sourceDecomp[1], 10) + const targetMajor = parseInt(targetDecomp[0], 10) + const targetMinor = parseInt(targetDecomp[1], 10) + // Compare major version + if (sourceMajor !== targetMajor) { + return sourceMajor < targetMajor + } + // Compare minor version + if (sourceMinor !== targetMinor) { + return sourceMinor < targetMinor + } + const sourcePatch = sourceDecomp[2].split('-') + const targetPatch = targetDecomp[2].split('-') + + const sourcePatchVersion = parseInt(sourcePatch[0], 10) + const targetPatchVersion = parseInt(targetPatch[0], 10) + + // Compare patch version + if (sourcePatchVersion !== targetPatchVersion) { + return sourcePatchVersion < targetPatchVersion + } + + // Compare release version + if (sourcePatch.length !== targetPatch.length) { + return sourcePatch.length > targetPatch.length + } + + if (sourcePatch.length === 2) { + // Compare different release types + if (!sourcePatch[1][0].startsWith(targetPatch[1][0])) { + return sourcePatch[1] < targetPatch[1] + } + // Compare beta version + if (sourcePatch[1].startsWith('b')) { + return ( + parseInt(sourcePatch[1].slice(1), 10) < + parseInt(targetPatch[1].slice(1), 10) + ) + } + // Compare rc version + return ( + parseInt(sourcePatch[1].slice(2), 10) < + parseInt(targetPatch[1].slice(2), 10) + ) + } + + return false +} + +/** + * Determine if the transaction required a networkID to be valid. + * Transaction needs networkID if later than restricted ID and either the network is hooks testnet + * or build version is >= 1.11.0 + * + * @param client -- The connected client. + * @returns True if required networkID, false otherwise. + */ +function txNeedsNetworkID(client: Client): boolean { + if ( + client.networkID !== undefined && + client.networkID > RESTRICTED_NETWORKS + ) { + // TODO: remove the buildVersion logic when 1.11.0 is out and widely used. + // Issue: https://github.com/XRPLF/xrpl.js/issues/2339 + if ( + (client.buildVersion && + isNotLaterRippledVersion( + REQUIRED_NETWORKID_VERSION, + client.buildVersion, + )) || + client.networkID === HOOKS_TESTNET_ID + ) { + return true + } + } + return false +} + function setValidAddresses(tx: Transaction): void { validateAccountAddress(tx, 'Account', 'SourceTag') // eslint-disable-next-line @typescript-eslint/dot-notation -- Destination can exist on Transaction diff --git a/packages/xrpl/test/client/autofill.test.ts b/packages/xrpl/test/client/autofill.test.ts index cfb93f2c..65ba0d21 100644 --- a/packages/xrpl/test/client/autofill.test.ts +++ b/packages/xrpl/test/client/autofill.test.ts @@ -15,13 +15,32 @@ import { } from '../setupClient' import { assertRejects } from '../testUtils' +const NetworkID = 1025 const Fee = '10' const Sequence = 1432 const LastLedgerSequence = 2908734 +const HOOKS_TESTNET_ID = 21338 describe('client.autofill', function () { let testContext: XrplTestContext + async function setupMockRippledVersionAndID( + buildVersion: string, + networkID: number, + ): Promise { + await testContext.client.disconnect() + rippled.server_info.withNetworkId.result.info.build_version = buildVersion + rippled.server_info.withNetworkId.result.info.network_id = networkID + testContext.client.connection.on('connected', () => { + testContext.mockRippled?.addResponse( + 'server_info', + rippled.server_info.withNetworkId, + ) + }) + + await testContext.client.connect() + } + beforeEach(async () => { testContext = await setupClient() }) @@ -32,17 +51,116 @@ describe('client.autofill', function () { TransactionType: 'DepositPreauth', Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', Authorize: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + NetworkID, Fee, Sequence, LastLedgerSequence, } const txResult = await testContext.client.autofill(tx) + assert.strictEqual(txResult.NetworkID, NetworkID) assert.strictEqual(txResult.Fee, Fee) assert.strictEqual(txResult.Sequence, Sequence) assert.strictEqual(txResult.LastLedgerSequence, LastLedgerSequence) }) + it('ignores network ID if missing', async function () { + const tx: Payment = { + TransactionType: 'Payment', + Account: 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi', + Amount: '1234', + Destination: 'X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ', + Fee, + Sequence, + LastLedgerSequence, + } + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + assert.strictEqual(txResult.NetworkID, undefined) + }) + + // NetworkID is required in transaction for network > 1024 and from version 1.11.0 or later. + // More context: https://github.com/XRPLF/rippled/pull/4370 + it('overrides network ID if > 1024 and version is later than 1.11.0', async function () { + await setupMockRippledVersionAndID('1.11.1', 1025) + const tx: Payment = { + TransactionType: 'Payment', + Account: 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi', + Amount: '1234', + Destination: 'X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ', + Fee, + Sequence, + LastLedgerSequence, + } + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + assert.strictEqual(txResult.NetworkID, 1025) + }) + + // NetworkID is only required in transaction for version 1.11.0 or later. + // More context: https://github.com/XRPLF/rippled/pull/4370 + it('ignores network ID if > 1024 but version is earlier than 1.11.0', async function () { + await setupMockRippledVersionAndID('1.10.0', 1025) + const tx: Payment = { + TransactionType: 'Payment', + Account: 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi', + Amount: '1234', + Destination: 'X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ', + Fee, + Sequence, + LastLedgerSequence, + } + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + assert.strictEqual(txResult.NetworkID, undefined) + }) + + // NetworkID <= 1024 does not require a newtorkID in transaction. + // More context: https://github.com/XRPLF/rippled/pull/4370 + it('ignores network ID if <= 1024', async function () { + await setupMockRippledVersionAndID('1.11.1', 1023) + const tx: Payment = { + TransactionType: 'Payment', + Account: 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi', + Amount: '1234', + Destination: 'X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ', + Fee, + Sequence, + LastLedgerSequence, + } + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + assert.strictEqual(txResult.NetworkID, undefined) + }) + + // Hooks Testnet requires networkID in transaction regardless of version. + // More context: https://github.com/XRPLF/rippled/pull/4370 + it('overrides network ID for hooks testnet', async function () { + await setupMockRippledVersionAndID('1.10.1', HOOKS_TESTNET_ID) + const tx: Payment = { + TransactionType: 'Payment', + Account: 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi', + Amount: '1234', + Destination: 'X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ', + Fee, + Sequence, + LastLedgerSequence, + } + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + assert.strictEqual(txResult.NetworkID, HOOKS_TESTNET_ID) + }) + it('converts Account & Destination X-address to their classic address', async function () { const tx: Payment = { TransactionType: 'Payment', diff --git a/packages/xrpl/test/createMockRippled.ts b/packages/xrpl/test/createMockRippled.ts index 2bae9c17..bae75b6b 100644 --- a/packages/xrpl/test/createMockRippled.ts +++ b/packages/xrpl/test/createMockRippled.ts @@ -10,7 +10,7 @@ import type { import { destroyServer, getFreePort } from './testUtils' -function createResponse( +export function createResponse( request: { id: number | string }, response: Record, ): string { diff --git a/packages/xrpl/test/fixtures/rippled/index.ts b/packages/xrpl/test/fixtures/rippled/index.ts index 757f8fa0..1d48f12c 100644 --- a/packages/xrpl/test/fixtures/rippled/index.ts +++ b/packages/xrpl/test/fixtures/rippled/index.ts @@ -12,6 +12,7 @@ import iouPartialPayment from './partialPaymentIOU.json' import xrpPartialPayment from './partialPaymentXRP.json' import normalServerInfo from './serverInfo.json' import highLoadFactor from './serverInfoHighLoadFactor.json' +import withNetworkIDServerInfo from './serverInfoNetworkID.json' import consensusStream from './streams/consensusPhase.json' import ledgerStream from './streams/ledger.json' import manifestStream from './streams/manifest.json' @@ -84,6 +85,7 @@ const ledger_data = { const server_info = { normal: normalServerInfo, highLoadFactor, + withNetworkId: withNetworkIDServerInfo, } const tx = { diff --git a/packages/xrpl/test/fixtures/rippled/serverInfoNetworkID.json b/packages/xrpl/test/fixtures/rippled/serverInfoNetworkID.json new file mode 100644 index 00000000..2d9e45c4 --- /dev/null +++ b/packages/xrpl/test/fixtures/rippled/serverInfoNetworkID.json @@ -0,0 +1,31 @@ +{ + "id": 0, + "status": "success", + "type": "response", + "result": { + "info": { + "build_version": "1.11.0-rc2", + "complete_ledgers": "37621036-38327626", + "hostid": "JANE", + "io_latency_ms": 1, + "last_close": { + "converge_time_s": 2, + "proposers": 6 + }, + "load_factor": 1, + "network_id": 1, + "peers": 113, + "pubkey_node": "n9L6MAkAvZKakewLSJPkCKLxuSQ9jrYXJBd2L4fouhpXauyFh6ZM", + "server_state": "full", + "validated_ledger": { + "age": 0, + "base_fee_xrp": 0.00001, + "hash": "A219F66BB8C9992E80A3C93A5EA408CD54B8F47F2AC1246C271C495F833752BA", + "reserve_base_xrp": 10, + "reserve_inc_xrp": 2, + "seq": 38327626 + }, + "validation_quorum": 5 + } + } +} diff --git a/packages/xrpl/test/mockRippledTest.test.ts b/packages/xrpl/test/mockRippledTest.test.ts index 1c6f717e..f18d1a17 100644 --- a/packages/xrpl/test/mockRippledTest.test.ts +++ b/packages/xrpl/test/mockRippledTest.test.ts @@ -22,7 +22,7 @@ describe('mock rippled tests', function () { } await assertRejects( - testContext.client.request({ command: 'server_info' }), + testContext.client.request({ command: 'account_info' }), RippledError, ) }) diff --git a/packages/xrpl/test/models/baseTransaction.test.ts b/packages/xrpl/test/models/baseTransaction.test.ts index c21a387d..e3aa7af7 100644 --- a/packages/xrpl/test/models/baseTransaction.test.ts +++ b/packages/xrpl/test/models/baseTransaction.test.ts @@ -232,4 +232,17 @@ describe('BaseTransaction', function () { 'BaseTransaction: invalid Memos', ) }) + + it(`Handles invalid NetworkID`, function () { + const invalidNetworkID = { + Account: 'r97KeayHuEsDwyU1yPBVtMLLoQr79QcRFe', + TransactionType: 'Payment', + NetworkID: '1024', + } + assert.throws( + () => validateBaseTransaction(invalidNetworkID), + ValidationError, + 'BaseTransaction: invalid NetworkID', + ) + }) }) diff --git a/packages/xrpl/test/setupClient.ts b/packages/xrpl/test/setupClient.ts index 08ec1e91..5cd9a0af 100644 --- a/packages/xrpl/test/setupClient.ts +++ b/packages/xrpl/test/setupClient.ts @@ -5,6 +5,7 @@ import BroadcastClient from '../src/client/BroadcastClient' import createMockRippled, { type MockedWebSocketServer, } from './createMockRippled' +import rippled from './fixtures/rippled' import { destroyServer, getFreePort } from './testUtils' export interface XrplTestContext { @@ -29,6 +30,10 @@ async function setupMockRippledConnection( context.client.on('error', () => { // We must have an error listener attached for reconnect errors }) + context.mockRippled?.addResponse( + 'server_info', + rippled.server_info.withNetworkId, + ) return context.client.connect().then(() => context) }