mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 14:21:18 +00:00
use /api/trpc/rpc.proxy in apps/hub (#1003)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@status-im/wallet": patch
|
||||
"hub": patch
|
||||
"api": patch
|
||||
---
|
||||
|
||||
use `/api/trpc/rpc.proxy` in `apps/hub`
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
export { type ApiRouter, apiRouter } from './api'
|
||||
export {
|
||||
type ApiRouter,
|
||||
apiRouter,
|
||||
createCaller,
|
||||
createTRPCContext,
|
||||
} from './api'
|
||||
export type {
|
||||
Activity,
|
||||
ApiInput,
|
||||
|
||||
Reference in New Issue
Block a user