Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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: 120 additions & 0 deletions packages/cli/src/rpc/modules/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,36 @@ import type { Debugger } from 'debug'
import type { BeaconNetwork, HistoryNetwork, PortalNetwork, StateNetwork } from 'portalnetwork'
import type { GetEnrResult } from '../schema/types.js'

/**
Copy link
Collaborator

Choose a reason for hiding this comment

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

can we move this to rpc/utils.ts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

thanks that's the info I was looking for 🙏

* Converts a BitArray response to a boolean array indicating which content keys were accepted
* @param res The response from sendOffer (can be BitArray, undefined, or empty array)
* @param contentKeysLength The number of content keys that were offered
* @returns Object with success array, declined flag, or failed flag
*/
function bitToBooleanArray(res: any, contentKeysLength: number): { success?: boolean[]; declined?: boolean; failed?: boolean } {
if (res === undefined) {
return { declined: true }
}

if (Array.isArray(res) && res.length === 0) {
return { declined: true }
}

// If res is a BitArray, convert it to boolean array
if (res !== undefined && res !== null && typeof res === 'object' && 'getTrueBitIndexes' in res) {
const acceptedBits = (res as any).getTrueBitIndexes()
const successArray = new Array(contentKeysLength).fill(false)
acceptedBits.forEach((index: number) => {
if (index < contentKeysLength) {
successArray[index] = true
}
})
return { success: successArray }
}

return { success: new Array(contentKeysLength).fill(true) }
}

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

Expand Down Expand Up @@ -275,6 +308,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 +1270,79 @@ 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)
return bitToBooleanArray(res, contentKeys.length)
Copy link
Collaborator

Choose a reason for hiding this comment

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

The response will only be a BitArray if running protocol V0

With V1 this type was changed to Uint8Array. The current state of Ultralight is still designed to support both.

But if the response is V1 you won't want to call this helper function, which would actually end up returning a full array of accepts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ok, let me see and update accordingly to support both

} 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)
return bitToBooleanArray(res, contentKeys.length)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same here as history method

} 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)
return bitToBooleanArray(res, contentKeys.length)
Copy link
Collaborator

Choose a reason for hiding this comment

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

same here as history method

} 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
96 changes: 96 additions & 0 deletions packages/cli/test/rpc/portal/historyTraceOffer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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')
if (res.result.success === true) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

res.result.success is a boolean array, so this part gets skipped

you've already asserted that it is not undefined, so you can just remove this conditional and let the other checks happen

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

afterAll(() => {
ul.kill()
ul2.kill()
})
})