diff --git a/packages/cli/scripts/portalClient.ts b/packages/cli/scripts/portalClient.ts new file mode 100644 index 000000000..81920a3bc --- /dev/null +++ b/packages/cli/scripts/portalClient.ts @@ -0,0 +1,171 @@ +import { SignableENR } from '@chainsafe/enr' +import { keys } from '@libp2p/crypto' +import { multiaddr } from '@multiformats/multiaddr' +import { UltralightProvider } from '../../portalnetwork/src/client/provider' +import { TransportLayer, NetworkId } from '../../portalnetwork/src/index' +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' + +const args = await yargs(hideBin(process.argv)) + .option('method', { + describe: 'Portal Network method to call', + type: 'string', + required: true, + }) + .option('params', { + describe: 'Parameters for the method (JSON string)', + type: 'string', + default: '[]', + }) + .option('port', { + describe: 'Port number for the node', + type: 'number', + default: 9090, + }) + .example('$0 --method portal_statePing --params "[\\"enr:-...\\"]"', 'Ping a state network node') + .strict() + .argv + + +const NETWORK_IDS = { + STATE: '0x500a', + HISTORY: '0x500b', + BEACON: '0x500c', +} + +const MESSAGE_TYPES = { + PING: 0, + PONG: 1, + FINDNODES: 2, + NODES: 3, + TALKREQ: 4, + TALKRESP: 5, +} + +async function createNode(port: number): Promise { + const privateKey = await keys.generateKeyPair('secp256k1') + const enr = SignableENR.createFromPrivateKey(privateKey) + const nodeAddr = multiaddr(`/ip4/127.0.0.1/udp/${port}`) + enr.setLocationMultiaddr(nodeAddr) + + const node = await UltralightProvider.create({ + transport: TransportLayer.NODE, + supportedNetworks: [ + { networkId: NetworkId.HistoryNetwork }, + { networkId: NetworkId.StateNetwork }, + ], + config: { + enr, + bindAddrs: { ip4: nodeAddr }, + privateKey, + }, + + }) + + return node +} + +async function sendNetworkMessage(node: UltralightProvider, networkId: NetworkId, messageType: number, payload: any = {}): Promise { + console.log(`Sending message type ${messageType} to network ${networkId}:`, payload) + + await new Promise(resolve => setTimeout(resolve, 5000)) + + const serializedPayload = new TextEncoder().encode(JSON.stringify({ + type: messageType, + ...payload + })) + + try { + const response = await node.portal.sendPortalNetworkMessage( + node.portal.discv5.enr.toENR(), + serializedPayload, + networkId + ) + return response + } catch (error) { + console.error('Failed to send network message:', error) + throw error + } +} + +async function executeMethod(node: UltralightProvider, method: string, params: any[]) { + try { + const [prefix, methodName] = method.split('_') + + if (prefix === 'portal') { + + if (methodName === 'statePing') { + return await sendNetworkMessage(node, NETWORK_IDS.STATE as NetworkId, MESSAGE_TYPES.PING) + } else if (methodName === 'historyPing') { + return await sendNetworkMessage(node, NETWORK_IDS.HISTORY as NetworkId, MESSAGE_TYPES.PING) + } + + const historyNetwork = node.portal.network()[NetworkId.HistoryNetwork] + const stateNetwork = node.portal.network()[NetworkId.StateNetwork] + + if (historyNetwork && methodName.startsWith('history')) { + const networkMethod = methodName.replace('history', '').toLowerCase() + if (typeof historyNetwork[networkMethod] === 'function') { + return await historyNetwork[networkMethod](...params) + } + } + + if (stateNetwork && methodName.startsWith('state')) { + const networkMethod = methodName.replace('state', '').toLowerCase() + if (typeof stateNetwork[networkMethod] === 'function') { + return await stateNetwork[networkMethod](...params) + } + } + + if (typeof node.portal[methodName] === 'function') { + return await node.portal[methodName](...params) + } + + throw new Error(`Unknown method: ${methodName}`) + } + + throw new Error(`Invalid method prefix: ${prefix}. Must be 'portal'`) + } catch (error) { + console.error('Error executing method:', error) + throw error + } +} + +async function main() { + let node: UltralightProvider | undefined + try { + console.log('Creating Portal Network node...') + node = await createNode(args.port) + + console.log('Starting Portal Network node...') + await node.portal.start() + + console.log('Waiting for node to be ready...') + await new Promise(resolve => setTimeout(resolve, 5000)) + + console.log(`Node started on port ${args.port}`) + + node.portal.enableLog('*Portal*,*uTP*,*discv5*') + + node.portal.on('SendTalkReq', (nodeId, requestId, payload) => + console.log('Sent talk request:', { nodeId, requestId, payload })) + node.portal.on('SendTalkResp', (nodeId, requestId, payload) => + console.log('Received talk response:', { nodeId, requestId, payload })) + + const params = JSON.parse(args.params) + await executeMethod(node, args.method, params) + + process.on('SIGINT', async () => { + console.log('Shutting down node...') + await node?.portal.stop() + process.exit(0) + }) + + } catch (error) { + console.error('Error:', error) + await node?.portal?.stop?.() + process.exit(1) + } +} + +main().catch(console.error) \ No newline at end of file diff --git a/packages/portalnetwork/EXAMPLES.md b/packages/portalnetwork/EXAMPLES.md new file mode 100644 index 000000000..359f5e23a --- /dev/null +++ b/packages/portalnetwork/EXAMPLES.md @@ -0,0 +1,50 @@ +# Starting a Portal Network Client + +This describes the usage and functionality of the Portal Network client script, which enables interaction with Portal Network nodes. + +## Usage + +The script is invoked using the following format: + +```bash +npx tsx examples/src/index.ts --method --params [--port ] +``` + +### Options + +| Option | Description | Type | Default | +|------------|------------------------------------------|---------|-----------| +| `--method` | Portal Network method to call | String | Required | +| `--params` | Parameters for the method (as JSON) | String | `[]` | +| `--port` | Port number for the node | Number | `9090` | + +--- + + +### Supported Networks + +The client supports two Portal Network types: +- State Network (0x500a) +- History Network (0x500b) + +### Message Types + +The following message types are supported: +- PING +- PONG +- FINDNODES +- NODES +- TALKREQ +- TALKRESP + +## Examples + +1. Store data in history network: +```bash +npx tsx examples/src/index.ts --method portal_historyStore --params '["hello world"]' +``` + +2. Custom port configuration: +```bash +npx tsx examples/src/index.ts --method portal_statePing --params '["enr:-..."]' --port 9091 +``` \ No newline at end of file diff --git a/packages/portalnetwork/README.md b/packages/portalnetwork/README.md index c8c68f07f..50c76c949 100644 --- a/packages/portalnetwork/README.md +++ b/packages/portalnetwork/README.md @@ -2,10 +2,12 @@ A Typescript library for interacting with the Portal Network -See [API](./docs/modules.md) for more details +See [API](./docs/modules.html) for more details See [Architecture](./diagrams/ARCHITECTURE.md) for architectural concepts +See [Examples](./EXAMPLES.md) for different ways to interact with Portal Network nodes. + ## Routing Table Management The Portal Network module currently supports two overlay routing tables for use with the below two subnetworks: diff --git a/packages/portalnetwork/examples/src/config.ts b/packages/portalnetwork/examples/src/config.ts new file mode 100644 index 000000000..643b142a0 --- /dev/null +++ b/packages/portalnetwork/examples/src/config.ts @@ -0,0 +1,36 @@ +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' + +export interface PortalConfig { + method: string + params: string + port: number +} + +export const defaultConfig: PortalConfig = { + method: '', + params: '[]', + port: 9090, +} + +export function parseArgs(argv: string[]): Promise { + return yargs(hideBin(argv)) + .option('method', { + describe: 'Portal Network method to call', + type: 'string', + required: true, + }) + .option('params', { + describe: 'Parameters for the method (JSON string)', + type: 'string', + default: '[]', + }) + .option('port', { + describe: 'Port number for the node', + type: 'number', + default: 9090, + }) + .example('$0 --method portal_statePing --params "[\\"enr:-...\\"]"', 'Ping a state network node') + .strict() + .parse() as Promise +} \ No newline at end of file diff --git a/packages/portalnetwork/examples/src/index.ts b/packages/portalnetwork/examples/src/index.ts new file mode 100644 index 000000000..0cab0d251 --- /dev/null +++ b/packages/portalnetwork/examples/src/index.ts @@ -0,0 +1,14 @@ +import { parseArgs } from './config.js' +import { runPortalClient } from './portalClient.js' + +export async function main() { + try { + const config = await parseArgs(process.argv) + await runPortalClient(config) + } catch (error) { + console.error('Error:', error) + process.exit(1) + } +} + +main().catch(console.error) \ No newline at end of file diff --git a/packages/portalnetwork/examples/src/portalClient.ts b/packages/portalnetwork/examples/src/portalClient.ts new file mode 100644 index 000000000..a2bafb9ff --- /dev/null +++ b/packages/portalnetwork/examples/src/portalClient.ts @@ -0,0 +1,184 @@ +import { SignableENR } from '@chainsafe/enr' +import { keys } from '@libp2p/crypto' +import { multiaddr } from '@multiformats/multiaddr' +import { PortalNetwork, NetworkId, TransportLayer } from '../../../portalnetwork/src' +import { PortalConfig } from './config' + +const NETWORK_IDS = { + STATE: '0x500a', + HISTORY: '0x500b', + BEACON: '0x500c', +} + +const MESSAGE_TYPES = { + PING: 0, + PONG: 1, + FINDNODES: 2, + NODES: 3, + TALKREQ: 4, + TALKRESP: 5, +} + +export async function createNode(port: number): Promise { + const privateKey = await keys.generateKeyPair('secp256k1') + const enr = SignableENR.createFromPrivateKey(privateKey) + const nodeAddr = multiaddr(`/ip4/127.0.0.1/udp/${port}`) + enr.setLocationMultiaddr(nodeAddr) + + const node = await PortalNetwork.create({ + transport: TransportLayer.NODE, + supportedNetworks: [ + { networkId: NetworkId.HistoryNetwork }, + { networkId: NetworkId.StateNetwork }, + ], + config: { + enr, + bindAddrs: { ip4: nodeAddr }, + privateKey, + }, + }) + + return node +} + +export async function sendNetworkMessage(node: PortalNetwork, networkId: NetworkId, messageType: number, payload: any = {}): Promise { + console.log(`Sending message type ${messageType} to network ${networkId}:`, payload) + + await new Promise(resolve => setTimeout(resolve, 5000)) + + const serializedPayload = new TextEncoder().encode(JSON.stringify({ + type: messageType, + ...payload + })) + + try { + const response = await node.sendPortalNetworkMessage( + node.discv5.enr.toENR(), + serializedPayload, + networkId + ) + return response + } catch (error) { + console.error('Failed to send network message:', error) + throw error + } +} + +export async function executeMethod(node: PortalNetwork, method: string, params: any[]) { + try { + const [prefix, methodName] = method.split('_') + + if (prefix === 'portal') { + + if (methodName === 'statePing') { + return await sendNetworkMessage(node, NETWORK_IDS.STATE as NetworkId, MESSAGE_TYPES.PING) + } else if (methodName === 'historyPing') { + return await sendNetworkMessage(node, NETWORK_IDS.HISTORY as NetworkId, MESSAGE_TYPES.PING) + } + + const historyNetwork = node.networks[NetworkId.HistoryNetwork] + const stateNetwork = node.networks[NetworkId.StateNetwork] + + if (historyNetwork && methodName.startsWith('history')) { + const networkMethod = methodName.replace('history', '').toLowerCase() + if (typeof historyNetwork[networkMethod] === 'function') { + return await historyNetwork[networkMethod](...params) + } + } + + if (stateNetwork && methodName.startsWith('state')) { + const networkMethod = methodName.replace('state', '').toLowerCase() + if (typeof stateNetwork[networkMethod] === 'function') { + return await stateNetwork[networkMethod](...params) + } + } + + if (typeof node[methodName] === 'function') { + return await node[methodName](...params) + } + + throw new Error(`Unknown method: ${methodName}`) + } + + throw new Error(`Invalid method prefix: ${prefix}. Must be 'portal'`) + } catch (error) { + console.error('Error executing method:', error) + throw error + } +} + +export async function runPortalClient(config: PortalConfig): Promise { + let node: PortalNetwork | undefined + try { + console.log('Creating Portal Network node...') + node = await createNode(config.port) + + console.log('Starting Portal Network node...') + await node.start() + + console.log('Waiting for node to be ready...') + await new Promise(resolve => setTimeout(resolve, 5000)) + + console.log(`Node started on port ${config.port}`) + + node.enableLog('*Portal*,*uTP*,*discv5*') + + // Register SIGINT handler early + process.on('SIGINT', async () => { + console.log('Shutting down node...') + await node?.stop() + process.exit(0) + }) + + node.on('SendTalkReq', (nodeId, requestId, payload) => + console.log('Sent talk request:', { nodeId, requestId, payload })) + node.on('SendTalkResp', (nodeId, requestId, payload) => + console.log('Received talk response:', { nodeId, requestId, payload })) + + const params = JSON.parse(config.params) + await executeMethod(node, config.method, params) + + } catch (error) { + console.error('Error:', error) + await node?.stop?.() + throw error + } +} + + +// export async function runPortalClient(config: PortalConfig): Promise { +// let node: PortalNetwork | undefined +// try { +// console.log('Creating Portal Network node...') +// node = await createNode(config.port) + +// console.log('Starting Portal Network node...') +// await node.start() + +// console.log('Waiting for node to be ready...') +// await new Promise(resolve => setTimeout(resolve, 5000)) + +// console.log(`Node started on port ${config.port}`) + +// node.enableLog('*Portal*,*uTP*,*discv5*') + +// node.on('SendTalkReq', (nodeId, requestId, payload) => +// console.log('Sent talk request:', { nodeId, requestId, payload })) +// node.on('SendTalkResp', (nodeId, requestId, payload) => +// console.log('Received talk response:', { nodeId, requestId, payload })) + +// const params = JSON.parse(config.params) +// await executeMethod(node, config.method, params) + +// process.on('SIGINT', async () => { +// console.log('Shutting down node...') +// await node?.stop() +// process.exit(0) +// }) + +// } catch (error) { +// console.error('Error:', error) +// await node?.stop?.() +// throw error +// } +// } diff --git a/packages/portalnetwork/src/client/provider.ts b/packages/portalnetwork/src/client/provider.ts index 5bc8fbd29..8a9eadd45 100644 --- a/packages/portalnetwork/src/client/provider.ts +++ b/packages/portalnetwork/src/client/provider.ts @@ -4,6 +4,7 @@ import type { PortalNetworkOpts } from './types' import { hexToBytes } from '@ethereumjs/util' import { formatBlockResponse, formatResponse } from '../util/helpers.js' +import { DEFAULT_OPTS } from '../util/config.js' const ERROR_CODES = { UNSUPPORTED_METHOD: 4200, @@ -34,7 +35,12 @@ export class UltralightProvider { } static async create(opts: Partial): Promise { - const portal = await PortalNetwork.create(opts) + const finalOpts = { + ...DEFAULT_OPTS, + ...opts, + } + + const portal = await PortalNetwork.create(finalOpts) return new UltralightProvider(portal) } diff --git a/packages/portalnetwork/src/util/bootnodes.ts b/packages/portalnetwork/src/util/bootnodes.ts new file mode 100644 index 000000000..61a3fd14b --- /dev/null +++ b/packages/portalnetwork/src/util/bootnodes.ts @@ -0,0 +1,16 @@ + +export const DEFAULT_BOOTNODES = { + mainnet: [ + "enr:-IS4QFV_wTNknw7qiCGAbHf6LxB-xPQCktyrCEZX-b-7PikMOIKkBg-frHRBkfwhI3XaYo_T-HxBYmOOQGNwThkBBHYDgmlkgnY0gmlwhKRc9_OJc2VjcDI1NmsxoQKHPt5CQ0D66ueTtSUqwGjfhscU_LiwS28QvJ0GgJFd-YN1ZHCCE4k", + "enr:-IS4QDpUz2hQBNt0DECFm8Zy58Hi59PF_7sw780X3qA0vzJEB2IEd5RtVdPUYZUbeg4f0LMradgwpyIhYUeSxz2Tfa8DgmlkgnY0gmlwhKRc9_OJc2VjcDI1NmsxoQJd4NAVKOXfbdxyjSOUJzmA4rjtg43EDeEJu1f8YRhb_4N1ZHCCE4o", + "enr:-IS4QGG6moBhLW1oXz84NaKEHaRcim64qzFn1hAG80yQyVGNLoKqzJe887kEjthr7rJCNlt6vdVMKMNoUC9OCeNK-EMDgmlkgnY0gmlwhKRc9-KJc2VjcDI1NmsxoQLJhXByb3LmxHQaqgLDtIGUmpANXaBbFw3ybZWzGqb9-IN1ZHCCE4k", + "enr:-IS4QA5hpJikeDFf1DD1_Le6_ylgrLGpdwn3SRaneGu9hY2HUI7peHep0f28UUMzbC0PvlWjN8zSfnqMG07WVcCyBhADgmlkgnY0gmlwhKRc9-KJc2VjcDI1NmsxoQJMpHmGj1xSP1O-Mffk_jYIHVcg6tY5_CjmWVg1gJEsPIN1ZHCCE4o", + "enr:-IS4QJBALBigZVoKyz-NDBV8z34-pkVHU9yMxa6qXEqhCKYxOs5Psw6r5ueFOnBDOjsmgMGpC3Qjyr41By34wab1sKIBgmlkgnY0gmlwhKEjVaWJc2VjcDI1NmsxoQOSGugH1jSdiE_fRK1FIBe9oLxaWH8D_7xXSnaOVBe-SYN1ZHCCIyg", + "enr:-IS4QFm4gtstCnRtOC-MST-8AFO-eUhoNyM0u1XbXNlr4wl1O_rGr6y7zOrS3SIZrPDAge_ijFZ4e2B9eZVHhmgJtg8BgmlkgnY0gmlwhM69ZOyJc2VjcDI1NmsxoQLaI-m2CDIjpwcnUf1ESspvOctJLpIrLA8AZ4zbo_1bFIN1ZHCCIyg", + "enr:-IS4QBE8rpfrvCZVf0RISINpHU4GM-ZmkX4y3h_WxF7YflJ-dh88a6q9_42mGVSAetfpOQqujnPE-BkDWss5qF6d45UBgmlkgnY0gmlwhJ_fCDaJc2VjcDI1NmsxoQN9rahqamBOJfj4u6yssJQJ1-EZoyAw-7HIgp1FwNUdnoN1ZHCCIyg", + "enr:-IS4QGeTMHteRmm-MSYniUd48OZ1M7RMUsIjnSP_TRbo-goQZAdYuqY2PyNJfDJQBz33kv16k7WB3bZnBK-O1DagvJIBgmlkgnY0gmlwhEFsKgOJc2VjcDI1NmsxoQIQXNgOCBNyoXz_7XP4Vm7pIB1Lp35d67BbC4iSlrrcJoN1ZHCCI40", + "enr:-IS4QOA4voX3J7-R_x8pjlaxBTpT1S_CL7ZaNjetjZ-0nnr2VaP0wEZsT2KvjA5UWc8vi9I0XvNSd1bjU0GXUjlt7J0BgmlkgnY0gmlwhEFsKgOJc2VjcDI1NmsxoQI7aL5dFuHhwbxWD-C1yWH7UPlae5wuV_3WbPylCBwPboN1ZHCCI44", + "enr:-IS4QFzPZ7Cc7BGYSQBlWdkPyep8XASIVlviHbi-ZzcCdvkcE382unsRq8Tb_dYQFNZFWLqhJsJljdgJ7WtWP830Gq0BgmlkgnY0gmlwhEFsKq6Jc2VjcDI1NmsxoQPjz2Y1Hsa0edvzvn6-OADS3re-FOkSiJSmBB7DVrsAXIN1ZHCCI40", + "enr:-IS4QHA1PJCdmESyKkQsBmMUhSkRDgwKjwTtPZYMcbMiqCb8I1Xt-Xyh9Nj0yWeIN4S3sOpP9nxI6qCCR1Nf4LjY0IABgmlkgnY0gmlwhEFsKq6Jc2VjcDI1NmsxoQLMWRNAgXVdGc0Ij9RZCPsIyrrL67eYfE9PPwqwRvmZooN1ZHCCI44" + ] +} \ No newline at end of file diff --git a/packages/portalnetwork/src/util/config.ts b/packages/portalnetwork/src/util/config.ts index 67db2a095..0d9166b56 100644 --- a/packages/portalnetwork/src/util/config.ts +++ b/packages/portalnetwork/src/util/config.ts @@ -11,6 +11,7 @@ import { NetworkId } from '../networks/types.js' import { setupMetrics } from './metrics.js' import type { NetworkConfig, PortalNetworkOpts } from '../client' +import { DEFAULT_BOOTNODES } from './bootnodes.js' export type AsyncReturnType Promise> = T extends ( ...args: any @@ -120,3 +121,8 @@ export const cliConfig = async (args: PortalClientOpts) => { } return clientConfig } + +export const DEFAULT_OPTS = { + bindAddress: '0.0.0.0', + bootnodes: DEFAULT_BOOTNODES.mainnet, +} diff --git a/packages/portalnetwork/test/examples/portalClient.spec.ts b/packages/portalnetwork/test/examples/portalClient.spec.ts new file mode 100644 index 000000000..aa5a4fbf4 --- /dev/null +++ b/packages/portalnetwork/test/examples/portalClient.spec.ts @@ -0,0 +1,164 @@ +import { PortalNetwork } from '../../../portalnetwork/src' +import { NetworkId, TransportLayer } from '../../../portalnetwork/src/index' +import { main } from '../../../portalnetwork/examples/src/index' +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as config from '../../../portalnetwork/examples/src/config' + +describe('main function', () => { + let mockNode: any + + beforeEach(() => { + mockNode = { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + enableLog: vi.fn(), + on: vi.fn(), + sendPortalNetworkMessage: vi.fn(), + discv5: { + enr: { + toENR: vi.fn().mockReturnValue('mock-enr-string') + } + }, + networks: { + [NetworkId.HistoryNetwork]: { + ping: vi.fn().mockResolvedValue(undefined), + findNodes: vi.fn().mockResolvedValue(undefined), + }, + [NetworkId.StateNetwork]: { + ping: vi.fn().mockResolvedValue(undefined), + findNodes: vi.fn().mockResolvedValue(undefined), + } + } + } + vi.spyOn(PortalNetwork, 'create').mockResolvedValue(mockNode) + + vi.useFakeTimers() + }) + + afterEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('should create and start a node, execute a method, and stop the node on SIGINT', async () => { + const mockArgs = { + method: 'portal_statePing', + params: '[]', + port: 9090 + } + vi.spyOn(config, 'parseArgs').mockResolvedValue(mockArgs) + + let sigintCallback: Function | null = null + const processOnSpy = vi.spyOn(process, 'on').mockImplementation((event: string, cb: any) => { + if (event === 'SIGINT') { + sigintCallback = cb + } + return process + }) + + const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + + const mainPromise = main() + + await vi.runAllTimersAsync() + + expect(PortalNetwork.create).toHaveBeenCalledWith({ + transport: TransportLayer.NODE, + supportedNetworks: [ + { networkId: NetworkId.HistoryNetwork }, + { networkId: NetworkId.StateNetwork }, + ], + config: { + enr: expect.any(Object), + bindAddrs: { ip4: expect.any(Object) }, + privateKey: expect.any(Object), + }, + }) + expect(mockNode.start).toHaveBeenCalled() + expect(mockNode.enableLog).toHaveBeenCalledWith('*Portal*,*uTP*,*discv5*') + expect(mockNode.on).toHaveBeenCalledWith('SendTalkReq', expect.any(Function)) + expect(mockNode.on).toHaveBeenCalledWith('SendTalkResp', expect.any(Function)) + + expect(sigintCallback).toBeTruthy() + await sigintCallback!() + await Promise.resolve() + + expect(mockNode.stop).toHaveBeenCalled() + expect(processExitSpy).toHaveBeenCalledWith(0) + + processOnSpy.mockRestore() + processExitSpy.mockRestore() + }) + + it('should handle errors during node creation and stop the node', async () => { + const error = new Error('Failed to create node') + vi.spyOn(PortalNetwork, 'create').mockRejectedValue(error) + + const mockArgs = { + method: 'portal_statePing', + params: '[]', + port: 9090, + } + vi.spyOn(config, 'parseArgs').mockResolvedValue(mockArgs) + + let sigintCallback: Function | null = null + const processOnSpy = vi.spyOn(process, 'on').mockImplementation((event: string, cb: any) => { + if (event === 'SIGINT') { + sigintCallback = cb + } + return process + }) + + const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const mainPromise = main() + + await vi.runAllTimersAsync() + + await Promise.resolve() + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error:', error) + expect(processExitSpy).toHaveBeenCalledWith(1) + + processOnSpy.mockRestore() + processExitSpy.mockRestore() + consoleErrorSpy.mockRestore() + }) + + it('should handle errors during method execution and stop the node', async () => { + const mockArgs = { + method: 'portal_unknownMethod', + params: '[]', + port: 9090, + } + vi.spyOn(config, 'parseArgs').mockResolvedValue(mockArgs) + + let sigintCallback: Function | null = null + const processOnSpy = vi.spyOn(process, 'on').mockImplementation((event: string, cb: any) => { + if (event === 'SIGINT') { + sigintCallback = cb + } + return process + }) + + const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const mainPromise = main() + + await vi.runAllTimersAsync() + + expect(mockNode.start).toHaveBeenCalled() + expect(mockNode.enableLog).toHaveBeenCalled() + + await Promise.resolve() + + expect(mockNode.stop).toHaveBeenCalled() + expect(processExitSpy).toHaveBeenCalledWith(1) + + processOnSpy.mockRestore() + processExitSpy.mockRestore() + consoleErrorSpy.mockRestore() + }) +}) \ No newline at end of file