diff --git a/.changeset/tidy-apricots-check.md b/.changeset/tidy-apricots-check.md new file mode 100644 index 000000000..ca588e4d1 --- /dev/null +++ b/.changeset/tidy-apricots-check.md @@ -0,0 +1,7 @@ +--- +"@status-im/wallet": patch +"hub": patch +"api": patch +--- + +use `/api/trpc/rpc.proxy` in `apps/hub` diff --git a/apps/api/src/app/api/trpc/[trpc]/route.ts b/apps/api/src/app/api/trpc/[trpc]/route.ts index e75918b6e..4819528d0 100644 --- a/apps/api/src/app/api/trpc/[trpc]/route.ts +++ b/apps/api/src/app/api/trpc/[trpc]/route.ts @@ -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 { + 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 { + 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 } diff --git a/apps/hub/src/app/_constants/chain.ts b/apps/hub/src/app/_constants/chain.ts index a4e62fa85..8163ccf61 100644 --- a/apps/hub/src/app/_constants/chain.ts +++ b/apps/hub/src/app/_constants/chain.ts @@ -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, diff --git a/apps/hub/src/app/_hooks/useApprovePreDepositToken.ts b/apps/hub/src/app/_hooks/useApprovePreDepositToken.ts index afd129290..3272bdbe7 100644 --- a/apps/hub/src/app/_hooks/useApprovePreDepositToken.ts +++ b/apps/hub/src/app/_hooks/useApprovePreDepositToken.ts @@ -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 diff --git a/apps/hub/src/app/_hooks/useApproveToken.ts b/apps/hub/src/app/_hooks/useApproveToken.ts index 719b946fe..b4f37b3b2 100644 --- a/apps/hub/src/app/_hooks/useApproveToken.ts +++ b/apps/hub/src/app/_hooks/useApproveToken.ts @@ -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 } diff --git a/apps/hub/src/app/_hooks/usePreDepositVault.ts b/apps/hub/src/app/_hooks/usePreDepositVault.ts index 85487d1bb..55a8d291c 100644 --- a/apps/hub/src/app/_hooks/usePreDepositVault.ts +++ b/apps/hub/src/app/_hooks/usePreDepositVault.ts @@ -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 diff --git a/apps/hub/src/app/_hooks/useVaultTokenStake.ts b/apps/hub/src/app/_hooks/useVaultTokenStake.ts index 4f1cae221..f0e507270 100644 --- a/apps/hub/src/app/_hooks/useVaultTokenStake.ts +++ b/apps/hub/src/app/_hooks/useVaultTokenStake.ts @@ -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' }) diff --git a/apps/hub/src/app/_hooks/useWrapETH.ts b/apps/hub/src/app/_hooks/useWrapETH.ts index af3558397..5566f40cc 100644 --- a/apps/hub/src/app/_hooks/useWrapETH.ts +++ b/apps/hub/src/app/_hooks/useWrapETH.ts @@ -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 diff --git a/packages/wallet/src/data/api/index.ts b/packages/wallet/src/data/api/index.ts index b9921da78..6d5acf61f 100644 --- a/packages/wallet/src/data/api/index.ts +++ b/packages/wallet/src/data/api/index.ts @@ -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> = createCallerFactory(apiRouter) + +export { createTRPCContext } from './lib/trpc' diff --git a/packages/wallet/src/data/api/routers/rpc.ts b/packages/wallet/src/data/api/routers/rpc.ts new file mode 100644 index 000000000..2b566183b --- /dev/null +++ b/packages/wallet/src/data/api/routers/rpc.ts @@ -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 = { + 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(url: URL, body: unknown): Promise { + 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 +} diff --git a/packages/wallet/src/data/index.ts b/packages/wallet/src/data/index.ts index db54834fb..9adeaf924 100644 --- a/packages/wallet/src/data/index.ts +++ b/packages/wallet/src/data/index.ts @@ -1,4 +1,9 @@ -export { type ApiRouter, apiRouter } from './api' +export { + type ApiRouter, + apiRouter, + createCaller, + createTRPCContext, +} from './api' export type { Activity, ApiInput,