use /api/trpc/rpc.proxy in apps/hub (#1003)

This commit is contained in:
Felicio
2026-02-19 09:37:59 +09:00
committed by GitHub
parent 1a8d54d2f3
commit 81599fac07
11 changed files with 331 additions and 27 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@status-im/wallet": patch
"hub": patch
"api": patch
---
use `/api/trpc/rpc.proxy` in `apps/hub`
+202 -14
View File
@@ -17,7 +17,20 @@ export type { ApiRouter }
export const dynamic = 'force-dynamic'
async function handler(request: NextRequest) {
// let error: Error | undefined
const url = new URL(request.url)
const isRpcProxyPath = url.pathname.endsWith('/rpc.proxy')
const isTrpcFormat = url.searchParams.has('input')
// Handle JSON-RPC requests to rpc.proxy
// Transform JSON-RPC format to tRPC format, use fetchRequestHandler, then transform back
// Skip if already in tRPC format (let fetchRequestHandler handle it normally)
if (request.method === 'POST' && isRpcProxyPath && !isTrpcFormat) {
return handleJsonRpcProxy(request)
}
if (request.method === 'POST' && isRpcProxyPath && isTrpcFormat) {
console.log('NORMAL::')
}
try {
const response = await fetchRequestHandler({
@@ -60,6 +73,7 @@ async function handler(request: NextRequest) {
'assets.token',
'collectibles.page',
'market.tokenPrice',
'rpc.proxy',
].includes(path)
) ||
opts?.type === 'mutation'
@@ -68,12 +82,9 @@ async function handler(request: NextRequest) {
}
return {
// status: 429,
headers: {
'cache-control': cacheControl,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
...getCorsHeaders(),
},
}
},
@@ -83,15 +94,8 @@ async function handler(request: NextRequest) {
const status = response.status
const result = await response.json()
return Response.json(
result,
// { status: result.httpStatus }
// { status: 429 }
{ status: status }
)
} catch (error) {
console.error(error)
return Response.json(result, { status })
} catch {
const status = 500
// @see https://github.com/trpc/trpc/discussions/3640#discussioncomment-5511435 for returning explicit trpc error in superjson construct as result and preventing possible timeouts due to additional parsing
const result = {
@@ -113,4 +117,188 @@ async function handler(request: NextRequest) {
}
}
/**
* Creates a tRPC-formatted request from a JSON-RPC request
*/
function createTrpcRequest(
request: NextRequest,
jsonRpcBody: {
method: string
params?: unknown[]
id?: string | number
},
chainId?: number
): Request {
const trpcInput = {
method: jsonRpcBody.method,
params: jsonRpcBody.params,
id: jsonRpcBody.id,
jsonrpc: '2.0' as const,
chainId,
}
const trpcUrl = new URL(request.url)
trpcUrl.pathname = '/api/trpc/rpc.proxy'
trpcUrl.searchParams.set('input', JSON.stringify({ json: trpcInput }))
return new Request(trpcUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...Object.fromEntries(request.headers),
},
body: JSON.stringify({ json: trpcInput }),
})
}
/**
* Creates CORS headers for JSON-RPC responses
*/
function getCorsHeaders(): Record<string, string> {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
}
/**
* Handles JSON-RPC requests by transforming them to tRPC format
*/
async function handleJsonRpcProxy(request: NextRequest): Promise<Response> {
try {
const url = new URL(request.url)
const jsonRpcBody = await request.json()
const chainIdParam = url.searchParams.get('chainId')
const chainId = chainIdParam ? Number.parseInt(chainIdParam, 10) : undefined
const trpcRequest = createTrpcRequest(request, jsonRpcBody, chainId)
const response = await fetchRequestHandler({
endpoint: '/api/trpc',
router: apiRouter,
req: trpcRequest,
createContext: async () => {
const headers = new Headers(await nextHeaders())
return { headers }
},
responseMeta: () => ({
headers: {
'cache-control': 'private, no-store',
...getCorsHeaders(),
},
}),
})
if (!response.ok) {
throw new Error(
`tRPC handler error: ${response.status} ${response.statusText}`
)
}
const trpcResponse = await response.json()
const jsonRpcResult = extractJsonRpcResult(trpcResponse)
if (!jsonRpcResult) {
throw new Error('Failed to process RPC request: no result data')
}
return Response.json(jsonRpcResult, {
headers: {
...getCorsHeaders(),
'cache-control': 'private, no-store',
},
})
} catch (error) {
let errorId: string | number | null = null
try {
const body = await request.clone().json()
errorId = body.id ?? null
} catch {
// Ignore if we can't parse the body for error ID
}
return Response.json(
{
jsonrpc: '2.0',
id: errorId,
error: {
code: -32603,
message:
error instanceof Error ? error.message : 'Internal server error',
},
},
{
status: 500,
headers: getCorsHeaders(),
}
)
}
}
/**
* Extracts JSON-RPC response from tRPC response structure
* tRPC wraps responses: { "result": { "data": { "json": {...} } } }
*/
function extractJsonRpcResult(trpcResponse: unknown): unknown {
if (Array.isArray(trpcResponse)) {
// Batch format
const data = trpcResponse[0]?.result?.data
const result =
data &&
typeof data === 'object' &&
'json' in data &&
data.json !== undefined
? data.json
: data
if (!result && trpcResponse[0]?.error) {
const trpcError = trpcResponse[0].error
throw new Error(
trpcError.message ||
trpcError.json?.message ||
'Failed to process RPC request'
)
}
return result
}
if (
trpcResponse &&
typeof trpcResponse === 'object' &&
'result' in trpcResponse &&
trpcResponse.result &&
typeof trpcResponse.result === 'object' &&
'data' in trpcResponse.result
) {
// Single call success
const data = trpcResponse.result.data
return data &&
typeof data === 'object' &&
'json' in data &&
data.json !== undefined
? data.json
: data
}
if (
trpcResponse &&
typeof trpcResponse === 'object' &&
'error' in trpcResponse
) {
// Single call error
const trpcError = trpcResponse.error as {
message?: string
json?: { message?: string }
}
throw new Error(
trpcError.message ||
trpcError.json?.message ||
'Failed to process RPC request'
)
}
throw new Error('Failed to process RPC request: unexpected response format')
}
export { handler as GET, handler as POST }
+7 -3
View File
@@ -14,11 +14,15 @@ export const getDefaultWagmiConfig = () =>
getDefaultConfig({
chains: [statusSepolia, mainnet, linea],
transports: {
[statusSepolia.id]: http(statusSepolia.rpcUrls.default.http[0]),
[statusSepolia.id]: http(
`${clientEnv.NEXT_PUBLIC_STATUS_API_URL}/api/trpc/rpc.proxy?chainId=${statusSepolia.id}`
),
[mainnet.id]: http(
'https://mainnet.infura.io/v3/6291a6aa45c94fd79bda6770b58153dd'
`${clientEnv.NEXT_PUBLIC_STATUS_API_URL}/api/trpc/rpc.proxy?chainId=${mainnet.id}`
),
[linea.id]: http(
`${clientEnv.NEXT_PUBLIC_STATUS_API_URL}/api/trpc/rpc.proxy?chainId=${linea.id}`
),
[linea.id]: http(linea.rpcUrls.default.http[0]),
},
walletConnectProjectId:
clientEnv.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID as string,
@@ -161,7 +161,6 @@ export function useApproveToken(): UseApprovePreDepositTokenReturn {
sendPreDepositEvent({ type: 'COMPLETE', amount })
toast.positive(t('success.token_allowance_increased'))
} catch (error) {
console.error('Failed to approve tokens:', error)
const message =
error instanceof BaseError
? error.shortMessage
+3 -3
View File
@@ -150,12 +150,13 @@ export function useApproveToken(): UseApproveTokenReturn {
// Transaction submitted, notify state machine
sendVaultEvent({ type: 'SIGN' })
// Wait for confirmation
const { status } = await waitForTransactionReceipt(config, {
const receipt = await waitForTransactionReceipt(config, {
hash,
confirmations: TRANSACTION_CONFIG.CONFIRMATION_BLOCKS,
})
const { status } = receipt
// Check for revert
if (status === 'reverted') {
throw new Error(t('errors.transaction_reverted'))
@@ -166,7 +167,6 @@ export function useApproveToken(): UseApproveTokenReturn {
toast.positive(t('success.token_allowance_increased'))
} catch (error) {
// Handle approval failure
console.error('Failed to approve tokens:', error)
sendVaultEvent({ type: 'REJECT' })
throw error
}
@@ -140,11 +140,14 @@ export function usePreDepositVault(): UsePreDepositVaultReturn {
sendPreDepositEvent({ type: 'EXECUTE' })
const { status } = await waitForTransactionReceipt(config, {
const receipt = await waitForTransactionReceipt(config, {
hash,
confirmations: TRANSACTION_CONFIG.CONFIRMATION_BLOCKS,
pollingInterval: 2000,
})
const { status } = receipt
if (status === 'reverted') {
throw new Error(t('errors.transaction_reverted'))
}
@@ -152,7 +155,6 @@ export function usePreDepositVault(): UsePreDepositVaultReturn {
sendPreDepositEvent({ type: 'COMPLETE', amount })
toast.positive(t('success.deposit_successful', { vault: vault.name }))
} catch (error) {
console.error(`Failed to deposit into ${vault.name}: `, error)
sendPreDepositEvent({ type: 'REJECT' })
const message =
error instanceof BaseError
@@ -149,11 +149,13 @@ export function useVaultTokenStake(): UseVaultStakeReturn {
sendVaultEvent({ type: 'SIGN' })
// Wait for transaction confirmation
const { status } = await waitForTransactionReceipt(config, {
const receipt = await waitForTransactionReceipt(config, {
hash,
confirmations: CONFIRMATION_BLOCKS,
})
const { status } = receipt
// Check if transaction was reverted
if (status === 'reverted') {
sendVaultEvent({ type: 'REJECT' })
+3 -2
View File
@@ -43,18 +43,19 @@ export function useWrapETH(): UseWrapETHRETURN {
chainId: mainnet.id,
})
const { status } = await waitForTransactionReceipt(config, {
const receipt = await waitForTransactionReceipt(config, {
hash,
confirmations: TRANSACTION_CONFIG.CONFIRMATION_BLOCKS,
})
const { status } = receipt
if (status === 'reverted') {
throw new Error(t('errors.transaction_reverted'))
}
toast.positive(t('success.eth_wrapped'))
} catch (error) {
console.error('Failed to wrap ETH: ', error)
const message =
error instanceof BaseError
? error.shortMessage
+4
View File
@@ -5,6 +5,7 @@ import { collectiblesRouter as collectibles } from './routers/collectibles'
import { configRouter as config } from './routers/config'
import { marketRouter as market } from './routers/market'
import { nodesRouter as nodes } from './routers/nodes'
import { rpcRouter as rpc } from './routers/rpc'
export const apiRouter = router({
assets,
@@ -13,9 +14,12 @@ export const apiRouter = router({
activities,
config,
market,
rpc,
})
export type ApiRouter = typeof apiRouter
export const createCaller: ReturnType<typeof createCallerFactory<ApiRouter>> =
createCallerFactory(apiRouter)
export { createTRPCContext } from './lib/trpc'
@@ -0,0 +1,92 @@
import { z } from 'zod'
import { serverEnv } from '../../../config/env.server.mjs'
import { publicProcedure, router } from '../lib/trpc'
const PROXY_BASE_URL = serverEnv.ETH_RPC_PROXY_URL
const PROXY_AUTH = {
username: serverEnv.ETH_RPC_PROXY_AUTH_USERNAME,
password: serverEnv.ETH_RPC_PROXY_AUTH_PASSWORD,
}
const CHAIN_ID_TO_PROXY_PATH: Record<number, string> = {
1: 'ethereum/mainnet',
59144: 'linea/mainnet',
11155111: 'ethereum/sepolia',
1660990954: 'status/sepolia',
}
export const rpcRouter = router({
proxy: publicProcedure
.input(
z.object({
method: z.string(),
params: z.array(z.unknown()).optional(),
id: z.union([z.string(), z.number()]).optional(),
jsonrpc: z.literal('2.0').optional(),
chainId: z.number().optional(),
}),
)
.mutation(async ({ input }) => {
const chainId = input.chainId ?? 1
const proxyPath = CHAIN_ID_TO_PROXY_PATH[chainId]
if (!proxyPath) {
throw new Error(`Unsupported chainId: ${chainId}`)
}
const url = new URL(`${PROXY_BASE_URL}/${proxyPath}`)
const jsonRpcRequest = {
jsonrpc: '2.0',
method: input.method,
params: input.params || [],
id: input.id ?? 1,
}
return await _fetchWithAuth<{
jsonrpc: string
id: string | number
result?: unknown
error?: { code: number; message: string; data?: unknown }
}>(url, jsonRpcRequest)
}),
})
async function _fetchWithAuth<T>(url: URL, body: unknown): Promise<T> {
const credentials = Buffer.from(
`${PROXY_AUTH.username}:${PROXY_AUTH.password}`,
).toString('base64')
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
cache: 'no-store',
})
if (!response.ok) {
let errorMessage = response.statusText
try {
const errorBody = await response.text()
if (errorBody) {
try {
const parsed = JSON.parse(errorBody)
errorMessage = parsed.error?.message || parsed.message || errorBody
} catch {
errorMessage = errorBody
}
}
} catch {
// Ignore errors when reading error body
}
throw new Error(`Failed to fetch: ${response.status} ${errorMessage}`)
}
return response.json() as Promise<T>
}
+6 -1
View File
@@ -1,4 +1,9 @@
export { type ApiRouter, apiRouter } from './api'
export {
type ApiRouter,
apiRouter,
createCaller,
createTRPCContext,
} from './api'
export type {
Activity,
ApiInput,