Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 119 additions & 1 deletion packages/cli/src/rpc/modules/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {

import { BEACON_CLIENT_NOT_INITIALIZED, CONTENT_NOT_FOUND, INVALID_PARAMS } from '../error-code.js'
import { content_params } from '../schema/index.js'
import { callWithStackTrace, isValidId } from '../util.js'
import { Uint8toBooleanArray, bitToBooleanArray, callWithStackTrace, isValidId } from '../util.js'
import { middleware, validators } from '../validators.js'

import { BitArray } from '@chainsafe/ssz'
Expand All @@ -29,6 +29,7 @@ import type { Debugger } from 'debug'
import type { BeaconNetwork, HistoryNetwork, PortalNetwork, StateNetwork } from 'portalnetwork'
import type { GetEnrResult } from '../schema/types.js'


const methods = [
// state
'portal_stateAddEnr',
Expand All @@ -46,6 +47,7 @@ const methods = [
'portal_stateGetContent',
'portal_stateTraceGetContent',
'portal_stateOffer',
'portal_stateTraceOffer',
// history
'portal_historyRoutingTableInfo',
'portal_historyAddEnr',
Expand All @@ -56,6 +58,7 @@ const methods = [
'portal_historyFindNodes',
'portal_historyFindContent',
'portal_historyOffer',
'portal_historyTraceOffer',
'portal_historyRecursiveFindNodes',
'portal_historyGetContent',
'portal_historyTraceGetContent',
Expand All @@ -79,6 +82,7 @@ const methods = [
'portal_beaconDeleteEnr',
'portal_beaconLookupEnr',
'portal_beaconOffer',
'portal_beaconTraceOffer',
'portal_beaconOptimisticStateRoot',
'portal_beaconFinalizedStateRoot',

Expand Down Expand Up @@ -275,6 +279,20 @@ export class portal {
[content_params.ContentItems],
])

// portal_*TraceOffer
this.historyTraceOffer = middleware(this.historyTraceOffer.bind(this), 2, [
[validators.enr],
[content_params.ContentItems],
])
this.stateTraceOffer = middleware(this.stateTraceOffer.bind(this), 2, [
[validators.enr],
[content_params.ContentItems],
])
this.beaconTraceOffer = middleware(this.beaconTraceOffer.bind(this), 2, [
[validators.enr],
[content_params.ContentItems],
])

// portal_*Gossip
this.historyGossip = middleware(this.historyGossip.bind(this), 2, [
[validators.contentKey],
Expand Down Expand Up @@ -1223,6 +1241,106 @@ export class portal {
return res instanceof BitArray ? bytesToHex(res.uint8Array) : bytesToHex(res)
}

// portal_*TraceOffer
async historyTraceOffer(
params: [string, [string, string][]],
): Promise<{ success?: boolean[]; declined?: boolean; failed?: boolean }> {
const [enrHex, contentItems] = params
const contentKeys = contentItems.map((item) => hexToBytes(item[0] as PrefixedHexString))
const contentValues = contentItems.map((item) => hexToBytes(item[1] as PrefixedHexString))
const enr = ENR.decodeTxt(enrHex)

try {
if (this._history.routingTable.getWithPending(enr.nodeId)?.value === undefined) {
const res = await this._history.sendPing(enr)
if (res === undefined) {
return { failed: true }
}
}

const res = await this._history.sendOffer(enr, contentKeys, contentValues)
if (res === undefined || res === null || Array.isArray(res) && res.length === 0) {
return { declined: true }
}
if (res instanceof BitArray) {
return bitToBooleanArray(res, contentKeys.length)
}
if (res instanceof Uint8Array) {
return Uint8toBooleanArray(res, contentKeys.length)
}
return { failed: true }
} catch (error) {
this.logger(`historyTraceOffer failed: ${error}`)
return { failed: true }
}
}

async stateTraceOffer(
params: [string, [string, string][]],
): Promise<{ success?: boolean[]; declined?: boolean; failed?: boolean }> {
const [enrHex, contentItems] = params
const contentKeys = contentItems.map((item) => hexToBytes(item[0] as PrefixedHexString))
const contentValues = contentItems.map((item) => hexToBytes(item[1] as PrefixedHexString))
const enr = ENR.decodeTxt(enrHex)

try {
if (this._state.routingTable.getWithPending(enr.nodeId)?.value === undefined) {
const res = await this._state.sendPing(enr)
if (res === undefined) {
return { failed: true }
}
}

const res = await this._state.sendOffer(enr, contentKeys, contentValues)
if (res === undefined || res === null || Array.isArray(res) && res.length === 0) {
return { declined: true }
}
if (res instanceof BitArray) {
return bitToBooleanArray(res, contentKeys.length)
}
if (res instanceof Uint8Array) {
return Uint8toBooleanArray(res, contentKeys.length)
}
return { failed: true }
} catch (error) {
this.logger(`stateTraceOffer failed: ${error}`)
return { failed: true }
}
}

async beaconTraceOffer(
params: [string, [string, string][]],
): Promise<{ success?: boolean[]; declined?: boolean; failed?: boolean }> {
const [enrHex, contentItems] = params
const contentKeys = contentItems.map((item) => hexToBytes(item[0] as PrefixedHexString))
const contentValues = contentItems.map((item) => hexToBytes(item[1] as PrefixedHexString))
const enr = ENR.decodeTxt(enrHex)

try {
if (this._beacon.routingTable.getWithPending(enr.nodeId)?.value === undefined) {
const res = await this._beacon.sendPing(enr)
if (res === undefined) {
return { failed: true }
}
}

const res = await this._beacon.sendOffer(enr, contentKeys, contentValues)
if (res === undefined || res === null || Array.isArray(res) && res.length === 0) {
return { declined: true }
}
if (res instanceof BitArray) {
return bitToBooleanArray(res, contentKeys.length)
}
if (res instanceof Uint8Array) {
return Uint8toBooleanArray(res, contentKeys.length)
}
return { failed: true }
} catch (error) {
this.logger(`beaconTraceOffer failed: ${error}`)
return { failed: true }
}
}

// portal_*Gossip
async historyGossip(params: [string, string]) {
const [contentKey, content] = params
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/rpc/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export type FindNodeResult = NodesMessage
export type GetEnrResult = Enr
export type LookupEnrResult = Enr | undefined
export type OfferResult = number
export type OfferTrace = {
success?: boolean[]
declined?: boolean
failed?: boolean
}
export type SendOfferResult = RequestId
export type PingResult = PongMessage
export type RecursiveFindNodeResult = Enr[]
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/rpc/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { BitArray } from '@chainsafe/ssz'
import { INTERNAL_ERROR, type RpcError } from './types.js'
import { AcceptCode } from 'portalnetwork'

export const isValidId = (nodeId: string) => {
return /[^a-z0-9\s]+/.test(nodeId) || nodeId.length !== 64 ? false : true
Expand All @@ -22,3 +24,38 @@ export function callWithStackTrace(handler: Function, debug: boolean) {
}
}
}

/**
* Converts a BitArray (protocol v0) to a boolean array indicating which indexes are true.
* @param res The response from sendOffer as a BitArray
* @param contentKeysLength The number of content keys that were offered
* @returns Object with success array, declined flag, or failed flag
*/
export function bitToBooleanArray(resBitArray: BitArray, contentKeysLength: number): { success: boolean[] } {
const acceptedBits = resBitArray.getTrueBitIndexes()
const successArray = new Array(contentKeysLength).fill(false)
acceptedBits.forEach((index: number) => {
if (index < contentKeysLength) {
successArray[index] = true
}
})
return { success: successArray }
}

/**
* Converts a Uint8Array (protocol v1) to a boolean array indicating which indexes are true.
* @param res The response from sendOffer as a Uint8Array
* @param contentKeysLength The number of content keys that were offered
* @returns Object with success array, declined flag, or failed flag
*/
export function Uint8toBooleanArray(res: Uint8Array, contentKeysLength: number): { success: boolean[] } {
const successArray = new Array(contentKeysLength).fill(false)
for (let i = 0; i < contentKeysLength; i++) {
const byteIndex = Math.floor(i / 8)
const byte = res[byteIndex]
if (byte !== undefined) {
successArray[i] = AcceptCode.ACCEPT
}
}
return { success: successArray }
}
94 changes: 94 additions & 0 deletions packages/cli/test/rpc/portal/historyTraceOffer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterAll, assert, beforeAll, describe, it } from 'vitest'

import { startRpc } from '../util.js'

const method = 'portal_historyTraceOffer'
describe(`${method} tests`, () => {
let ul
let ul2
let rp
let rp2
beforeAll(async () => {
const { ultralight, rpc } = await startRpc({ networks: ['history'], rpcPort: 8545 })
const { ultralight: ultralight2, rpc: rpc2 } = await startRpc({
port: 9001,
rpcPort: 8546,
networks: ['history'],
})
ul = ultralight
ul2 = ultralight2
rp = rpc
rp2 = rpc2
})

it('should return declined when peer is not interested', async () => {
const enr = (await rp2.request('portal_historyNodeInfo', [])).result.enr
assert.exists(enr)

// Test with content keys that are unlikely to be accepted
const contentItems = [
['0x00' + '1'.repeat(62), '0x' + '2'.repeat(64)],
['0x00' + '3'.repeat(62), '0x' + '4'.repeat(64)],
]

try {
const res = await rp.request(method, [enr, contentItems])
assert.ok(res.result.declined === true || res.result.failed === true, 'should be declined or failed')
} catch (error) {
assert.ok(true, 'error is acceptable for far content')
}
}, 20000)

it('should return failed when peer is unreachable', async () => {
const invalidEnr = 'enr:-invalid'
const contentItems = [
['0x00' + '1'.repeat(62), '0x' + '2'.repeat(64)],
]

try {
const res = await rp.request(method, [invalidEnr, contentItems])
assert.equal(res.result?.failed, true, 'should be failed for invalid ENR')
} catch (error) {
assert.ok(true, 'error is acceptable for invalid ENR')
}
}, 10000)

it('should return success when content keys are accepted', async () => {
const enr = (await rp2.request('portal_historyNodeInfo', [])).result.enr
assert.exists(enr)

// Generate content keys that are close to node2's ID to increase acceptance probability
const contentKey1 = '0x00' + '5'.repeat(64)
const content1 = '0x' + '1'.repeat(128)
const contentKey2 = '0x00' + '6'.repeat(64)
const content2 = '0x' + '2'.repeat(128)

// Store content in node2
await rp2.request('portal_historyStore', [contentKey1, content1])
await rp2.request('portal_historyStore', [contentKey2, content2])
Comment on lines +66 to +68
Copy link
Collaborator

Choose a reason for hiding this comment

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

If node2 stores these contents first, it will reject them in the OFFER. This test is giving you a false positive because of the bug in the Uint8ToBooleanArray helper


// Make an offer from node1 to node2 with content that node2 might accept
const contentItems = [
[contentKey1, content1],
[contentKey2, content2],
]

try {
const res = await rp.request(method, [enr, contentItems])

// Should return success with boolean array indicating which keys were accepted
assert.ok(res.result.success !== undefined, 'should have success array')
assert.ok(Array.isArray(res.result.success), 'success should be an array')
const acceptedCount = res.result.success.filter((x: boolean) => x).length
assert.ok(acceptedCount > 0, 'at least one content key should be accepted')
} catch (error) {
console.log('Trace offer success error:', error)
assert.ok(false, 'error is unacceptable for success test')

Check failure on line 86 in packages/cli/test/rpc/portal/historyTraceOffer.spec.ts

View workflow job for this annotation

GitHub Actions / test-unit-cli (22)

test/rpc/portal/historyTraceOffer.spec.ts > portal_historyTraceOffer tests > should return success when content keys are accepted

AssertionError: error is unacceptable for success test: expected false to be truthy ❯ test/rpc/portal/historyTraceOffer.spec.ts:86:14
}
}, 20000)

afterAll(() => {
ul.kill()
ul2.kill()
})
})
4 changes: 2 additions & 2 deletions packages/portalnetwork/src/networks/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ export abstract class BaseNetwork {
* @param contentKeys content keys being offered as specified by the subnetwork
* @param content content being offered
*/
public sendOffer = async (enr: ENR, contentKeys: Uint8Array[], content?: Uint8Array[]) => {
public sendOffer = async (enr: ENR, contentKeys: Uint8Array[], content?: Uint8Array[]): Promise<BitArray | Uint8Array | undefined> => {
let version
try {
version = await this.portal.highestCommonVersion(enr)
Expand Down Expand Up @@ -671,7 +671,7 @@ export abstract class BaseNetwork {
}
} else {
for (const key of requestedKeys) {
let value = Uint8Array.from([])
let value: Uint8Array = Uint8Array.from([])
try {
value = hexToBytes((await this.get(key)) as PrefixedHexString)
requestedData.push(value)
Expand Down
Loading