feature NetworkID (#2216)

Added network id to the base transaction
Added network id to the server_info response
Added network id to the binary codec
Added network id to client and modify on client.connect()
Added network id to autofill when rippled_version is 1.11.0 or later or network is hooks testnet

Co-authored-by: Phu Pham <ppham@ripple.com>
Co-authored-by: pdp2121 <71317875+pdp2121@users.noreply.github.com>
Co-authored-by: Jackson Mills <aim4math@gmail.com>
Co-authored-by: Mayukha Vadari <mvadari@gmail.com>
This commit is contained in:
Denis Angell
2023-06-14 19:15:18 +00:00
committed by GitHub
parent b410bde412
commit 5b0989df51
12 changed files with 334 additions and 6 deletions

View File

@@ -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,

View File

@@ -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<void> {
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<void> {
return this.connection.connect()
return this.connection.connect().then(async () => {
await this.getServerInfo()
this.emit('connected')
})
}
/**

View File

@@ -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.

View File

@@ -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<string, unknown>): void {
) {
throw new ValidationError('BaseTransaction: invalid TxnSignature')
}
if (common.NetworkID !== undefined && typeof common.NetworkID !== 'number') {
throw new ValidationError('BaseTransaction: invalid NetworkID')
}
}
/**

View File

@@ -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<T extends Transaction>(
setValidAddresses(tx)
setTransactionFlagsToNumber(tx)
const promises: Array<Promise<void>> = []
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<T extends Transaction>(
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

View File

@@ -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<void> {
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',

View File

@@ -10,7 +10,7 @@ import type {
import { destroyServer, getFreePort } from './testUtils'
function createResponse(
export function createResponse(
request: { id: number | string },
response: Record<string, unknown>,
): string {

View File

@@ -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 = {

View File

@@ -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
}
}
}

View File

@@ -22,7 +22,7 @@ describe('mock rippled tests', function () {
}
await assertRejects(
testContext.client.request({ command: 'server_info' }),
testContext.client.request({ command: 'account_info' }),
RippledError,
)
})

View File

@@ -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',
)
})
})

View File

@@ -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)
}