Skip to content

Commit 3ea3b4d

Browse files
authored
PortalClient: More state/history RPC methods (#770)
* Add more methods * Refactor rpcMethods and MethodInput * Fix response viewer bug * Remove redundant test case * Improve RPCResponse * Returning result undefined * WIP * WIP * PoC for portal subnetwork * Prepopulate input value * Implement ping node and loading state to app component * Merge from master * WIP * Resolve conflict * WIP * WIP * Copyableshortid component * Improve findcontent query * Format useJsonRpc.ts * Fix lint issue * Merge branch 'master' into more-methods * Remove a wrong naming in portal_historyFindContent * Improve portal_historyFindContent to accommodate enrs and content type * Add portal_historyFindNodes * Merge remote-tracking branch 'origin/master' into pr/cjustinobi/770
1 parent 2405c7c commit 3ea3b4d

23 files changed

Lines changed: 2652 additions & 1392 deletions

package-lock.json

Lines changed: 1225 additions & 701 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/portal-client/__tests__/unit/hooks/useJsonRpc.test.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ describe('useJsonRpc', () => {
4747
initialize: vi.fn(),
4848
cleanup: vi.fn(),
4949
isNetworkReady: false,
50+
abortController: null,
51+
createAbortController: function (): AbortController {
52+
throw new Error('Function not implemented.')
53+
},
54+
cancelRequest: function (): void {
55+
throw new Error('Function not implemented.')
56+
}
5057
})
5158

5259
mockFormatBlockResponse.mockImplementation((result, includeTransactions) => ({
@@ -149,19 +156,6 @@ describe('useJsonRpc', () => {
149156
expect(mockClient.ETH.getBlockByHash).toHaveBeenCalledWith('0xabc', true)
150157
expect(result.current.result?.result.result).toHaveProperty('hash', '0xabc')
151158
})
152-
153-
it('should use default parameters when not provided', async () => {
154-
const mockBlockData = { number: '0x1', transactions: [] }
155-
mockClient.ETH.getBlockByNumber.mockResolvedValue(mockBlockData)
156-
157-
const { result } = renderHook(() => useJsonRpc())
158-
159-
await act(async () => {
160-
await result.current.sendRequestHandle('eth_getBlockByNumber', ['0x1'])
161-
})
162-
163-
expect(mockClient.ETH.getBlockByNumber).toHaveBeenCalledWith('0x1', false)
164-
})
165159
})
166160

167161
describe('Error handling', () => {

packages/portal-client/package-lock.json

Lines changed: 437 additions & 541 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/portal-client/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@
3333
"lucide-react": "^0.475.0",
3434
"react": "^18.3.1",
3535
"react-dom": "^18.3.1",
36-
"react-router-dom": "^7.2.0",
37-
"viem": "^2.23.2"
36+
"react-router-dom": "^7.2.0"
3837
},
3938
"devDependencies": {
4039
"@biomejs/biome": "^1.9.4",

packages/portal-client/src/App.tsx

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,50 @@
1-
import { FC } from 'react'
21
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
3-
import { PortalNetworkProvider } from '@/contexts/PortalNetworkContext'
2+
import { PortalNetworkProvider, usePortalNetwork } from '@/contexts/PortalNetworkContext'
43
import { NotificationProvider } from '@/contexts/NotificationContext'
54
import JsonRpc from '@/pages/JsonRpc'
65
import Home from '@/pages/Home'
76
import Config from '@/pages/Config'
7+
import Peers from '@/pages/Peers'
88
import PageNotFound from '@/pages/PageNotFound'
99
import Header from '@/components/layout/Header'
1010

11-
const App: FC = () => {
11+
const AppContent = () => {
12+
const { isLoading } = usePortalNetwork()
13+
14+
return (
15+
<div className="grid grid-rows-[auto_1fr] h-screen">
16+
<Header />
17+
<main className="overflow-auto">
18+
<div className="flex justify-center items-center h-full">
19+
<div className="w-full max-w-4xl mx-auto px-4 text-center">
20+
<Routes>
21+
<Route path="/" element={<Home />} />
22+
<Route path="/jsonrpc" element={<JsonRpc />} />
23+
<Route path="/config" element={<Config />} />
24+
<Route path="/peers" element={<Peers />} />
25+
<Route path="*" element={<PageNotFound />} />
26+
</Routes>
27+
</div>
28+
</div>
29+
</main>
30+
{isLoading && (
31+
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
32+
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
33+
</div>
34+
)}
35+
</div>
36+
)
37+
}
38+
39+
const App = () => {
1240
return (
1341
<PortalNetworkProvider>
14-
<NotificationProvider>
42+
<NotificationProvider>
1543
<Router>
16-
<div className="grid grid-rows-[auto_1fr] h-screen">
17-
<Header />
18-
<main className="overflow-auto">
19-
<div className="flex justify-center items-center h-full">
20-
<div className="w-full max-w-4xl mx-auto px-4 text-center">
21-
<Routes>
22-
<Route path="/" element={<Home />} />
23-
<Route path="/jsonrpc" element={<JsonRpc />} />
24-
<Route path="/config" element={<Config />} />
25-
<Route path="*" element={<PageNotFound />} />
26-
</Routes>
27-
</div>
28-
</div>
29-
</main>
30-
</div>
44+
<AppContent />
3145
</Router>
32-
</NotificationProvider>
33-
</PortalNetworkProvider>
46+
</NotificationProvider>
47+
</PortalNetworkProvider>
3448
)
3549
}
3650

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Copy, Check } from 'lucide-react'
2+
import { useState } from 'react'
3+
import { shortId } from 'portalnetwork'
4+
5+
interface CopyableShortIdProps {
6+
value: string
7+
displayPrefix?: string
8+
className?: string
9+
}
10+
11+
export const CopyableShortId = ({
12+
value,
13+
displayPrefix = '',
14+
className = '',
15+
}: CopyableShortIdProps) => {
16+
const [copied, setCopied] = useState(false)
17+
18+
const handleCopy = async () => {
19+
try {
20+
await navigator.clipboard.writeText(value)
21+
setCopied(true)
22+
setTimeout(() => setCopied(false), 2000)
23+
} catch (err) {
24+
console.error('Failed to copy:', err)
25+
}
26+
}
27+
28+
return (
29+
<div className={`flex items-center ${className}`}>
30+
<span className="font-mono">{displayPrefix}{shortId(value)}</span>
31+
<button
32+
onClick={handleCopy}
33+
className="ml-2 p-1 hover:bg-gray-100 rounded-full transition-colors"
34+
title={copied ? "Copied!" : "Copy to clipboard"}
35+
aria-label="Copy to clipboard"
36+
>
37+
{copied ? (
38+
<Check size={16} className="text-green-500" />
39+
) : (
40+
<Copy size={16} className="text-gray-500 hover:text-gray-700" />
41+
)}
42+
</button>
43+
</div>
44+
)
45+
}

packages/portal-client/src/components/common/jsonRenderer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const jsonRenderer = (value: any): ReactNode => {
2323
<div className="pl-4 border-l border-gray-600 mt-1">
2424
<div className="text-gray-400">[</div>
2525
{value.map((item, index) => (
26-
<div key={index} className="ml-4 flex items-start">
26+
<div key={index} className="ml-4 flex items-start text-left">
2727
<span className="text-gray-500 mr-2">{index}:</span>
2828
{jsonRenderer(item)}
2929
</div>
@@ -41,7 +41,7 @@ const jsonRenderer = (value: any): ReactNode => {
4141
<div className="pl-4 border-l border-gray-600 mt-1">
4242
<div className="text-gray-400">{'{'}</div>
4343
{entries.map(([key, val]) => (
44-
<div key={key} className="ml-4 flex items-start">
44+
<div key={key} className="ml-4 flex items-start text-left">
4545
<span className="text-purple-400 mr-2">{key}:</span>
4646
{jsonRenderer(val)}
4747
</div>
Lines changed: 125 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { FC, useState } from 'react'
1+
import { useEffect, useMemo, useState } from 'react'
2+
import { useLocation } from 'react-router-dom'
23
import { useJsonRpc } from '@/hooks/useJsonRpc'
34
import { MethodInput } from '@/components/ui/MethodInput'
45
import { ResponseViewer } from '@/components/ui/ResponseViewer'
@@ -7,24 +8,51 @@ import { usePortalNetwork } from '@/contexts/PortalNetworkContext'
78
import { useNotification } from '@/contexts/NotificationContext'
89
import { methodRegistry, MethodType } from '@/utils/rpcMethods'
910
import { APPROVED_METHODS } from '@/utils/constants/methodRegistry'
11+
import { MethodParamConfig } from '@/utils/types'
1012

11-
const BlockExplorer: FC = () => {
13+
const BlockExplorer = () => {
1214
const [selectedMethod, setSelectedMethod] = useState<MethodType | ''>('')
1315
const [inputValue, setInputValue] = useState('')
14-
16+
const [includeFullTx, setIncludeFullTx] = useState(false)
17+
const [blockHeight, setBlockHeight] = useState('')
18+
const [contentKey, setContentKey] = useState('')
19+
const [distances, setDistances] = useState('')
20+
1521
const { result, setResult, sendRequestHandle } = useJsonRpc()
16-
const { isLoading, setIsLoading } = usePortalNetwork()
22+
const { setIsLoading, cancelRequest, client } = usePortalNetwork()
1723
const { notify } = useNotification()
24+
const location = useLocation()
25+
const isPeersRoute = location.pathname === '/peers'
1826

19-
const methodOptions = APPROVED_METHODS.map((method) => ({
20-
value: method,
21-
label: methodRegistry[method].name,
22-
}))
27+
const filteredMethods = useMemo(() => {
28+
return isPeersRoute
29+
? APPROVED_METHODS.filter(method => method.startsWith('portal_'))
30+
: APPROVED_METHODS;
31+
}, [isPeersRoute])
32+
33+
const methodOptions = useMemo(() => {
34+
return filteredMethods.map((method) => ({
35+
value: method,
36+
label: methodRegistry[method].name,
37+
}));
38+
}, [filteredMethods])
2339

2440
const handleSubmit = async () => {
2541
if (selectedMethod && methodRegistry[selectedMethod]) {
2642
try {
27-
await methodRegistry[selectedMethod].handler(inputValue, sendRequestHandle)
43+
let formattedInput = inputValue
44+
45+
if (methodParamMap[selectedMethod]?.showIncludeFullTx) {
46+
formattedInput = `${inputValue},${includeFullTx}`
47+
} else if (methodParamMap[selectedMethod]?.showBlockHeight) {
48+
formattedInput = `${inputValue},${blockHeight}`
49+
} else if (methodParamMap[selectedMethod]?.showEnr) {
50+
formattedInput = `${inputValue},${contentKey}`
51+
} else if (methodParamMap[selectedMethod]?.showDistances) {
52+
const distanceArray = distances.split(',').map(d => Number(d.trim()))
53+
formattedInput = `${inputValue},${distanceArray}`
54+
}
55+
await methodRegistry[selectedMethod].handler(formattedInput, sendRequestHandle)
2856
} catch (err) {
2957
notify({
3058
message: err instanceof Error ? err.message : 'Request failed',
@@ -36,42 +64,101 @@ const BlockExplorer: FC = () => {
3664

3765
const handleSelectMethod = (method: MethodType) => {
3866
setSelectedMethod(method)
67+
reset()
68+
}
69+
70+
const handleCancel = () => {
71+
if (cancelRequest) {
72+
cancelRequest()
73+
setIsLoading(false)
74+
notify({
75+
message: 'Request cancelled',
76+
type: 'info',
77+
})
78+
}
79+
}
80+
81+
const reset = () => {
3982
setInputValue('')
83+
setBlockHeight('')
84+
setContentKey('')
85+
setDistances('')
86+
setIncludeFullTx(false)
4087
setResult(null)
4188
setIsLoading(false)
4289
}
4390

44-
return (
45-
<div className="w-full max-w-2xl mx-auto mt-4 p-4 bg-[#1C232A]">
46-
<div className="bg-[#2A323C] rounded-lg shadow-lg p-6">
47-
<h2 className="text-2xl font-bold mb-6 text-gray-200">JSON RPC Interface</h2>
48-
<div className="mb-6">
49-
<Select
50-
options={methodOptions}
51-
value={selectedMethod}
52-
onChange={(e) => handleSelectMethod(e.target.value as MethodType)}
53-
placeholder="Select a method"
54-
/>
55-
</div>
56-
{selectedMethod && (
57-
<div className="mb-6">
58-
<MethodInput
59-
value={inputValue}
60-
onChange={setInputValue}
61-
placeholder={methodRegistry[selectedMethod].paramPlaceholder}
62-
onSubmit={handleSubmit}
63-
isLoading={isLoading}
64-
className="bg-[#2A323C] text-gray-200 border border-gray-600 placeholder-gray-400 rounded-lg p-2 focus:ring-blue-500 focus:border-blue-500"
65-
/>
66-
</div>
67-
)}
91+
const methodParamMap = useMemo(() => {
92+
const map = {} as Record<MethodType, MethodParamConfig>
93+
filteredMethods.forEach((method) => {
94+
const config: MethodParamConfig = {}
95+
96+
if (method.includes('BlockBy')) {
97+
config.showIncludeFullTx = true
98+
}
99+
if (method === 'eth_getTransactionCount' || method === 'eth_getBalance') {
100+
config.showBlockHeight = true
101+
}
102+
if (method.includes('portal_historyFindContent')) {
103+
config.showEnr = true
104+
}
105+
if (method.includes('portal_historyFindNodes')) {
106+
config.showDistances = true
107+
}
108+
map[method] = config
109+
})
110+
111+
return map
112+
}, [])
113+
114+
useEffect(() => {
115+
if (client === null) {
116+
reset()
117+
}
118+
}, [client])
68119

69-
{isLoading && <div className="text-center text-gray-400">Loading...</div>}
70-
{result && <ResponseViewer data={result.result} />}
71-
</div>
72-
</div>
73-
)
120+
const currentMethodConfig = selectedMethod ? methodParamMap[selectedMethod] || {} : {}
74121

122+
return (
123+
<div className="w-full max-w-2xl mx-auto mt-4 p-4 bg-[#1C232A]">
124+
<div className="bg-[#2A323C] rounded-lg shadow-lg p-6">
125+
<h2 className="text-2xl font-bold mb-6 text-gray-200">JSON RPC Interface</h2>
126+
<div className="mb-6">
127+
<Select
128+
options={methodOptions}
129+
value={selectedMethod}
130+
onChange={(e) => handleSelectMethod(e.target.value as MethodType)}
131+
placeholder="Select a method"
132+
/>
133+
</div>
134+
{selectedMethod && (
135+
<div className="mb-6">
136+
<MethodInput
137+
value={inputValue}
138+
onChange={setInputValue}
139+
placeholder={methodRegistry[selectedMethod].paramPlaceholder}
140+
onSubmit={handleSubmit}
141+
onCancel={handleCancel}
142+
className="bg-[#2A323C] text-gray-200 border border-gray-600 placeholder-gray-400 rounded-lg p-2 focus:ring-blue-500 focus:border-blue-500"
143+
includeFullTx={includeFullTx}
144+
onIncludeFullTxChange={setIncludeFullTx}
145+
onBlockHeightChange={setBlockHeight}
146+
onDistancesChange={setDistances}
147+
onContentKeyChange={setContentKey}
148+
showIncludeFullTx={currentMethodConfig.showIncludeFullTx}
149+
showBlockHeight={currentMethodConfig.showBlockHeight}
150+
showDistances={currentMethodConfig.showDistances}
151+
showEnr={currentMethodConfig.showEnr}
152+
blockHeight={blockHeight}
153+
contentKey={contentKey}
154+
distances={distances}
155+
/>
156+
</div>
157+
)}
158+
{result && <ResponseViewer data={result} />}
159+
</div>
160+
</div>
161+
)
75162
}
76163

77164
export default BlockExplorer

packages/portal-client/src/components/layout/Links.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const Links = () => {
1212
<li>
1313
<Link to="/jsonrpc">JsonRPC</Link>
1414
</li>
15+
<li>
16+
<Link to="/peers">Peers</Link>
17+
</li>
1518
<li>
1619
<Link to="/config">Config</Link>
1720
</li>

0 commit comments

Comments
 (0)