Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/rpc-proxy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
BASE_RPC_URL=
7 changes: 7 additions & 0 deletions apps/rpc-proxy/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Build outputs
dist/
*.js
*.mjs

# Node modules
node_modules/
19 changes: 19 additions & 0 deletions apps/rpc-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# @x402scan/rpc-proxy

Standalone HTTP service exposing a single endpoint:

- `GET /balance/:address`
- EVM addresses return **Base USDC** (ERC-20) balance

## Configuration

Set environment variables:

- `PORT` (default: `6970`)
- `BASE_RPC_URL` (required for EVM/Base lookups)

## Run locally

```bash
pnpm -w -F @x402scan/rpc-proxy dev
```
4 changes: 4 additions & 0 deletions apps/rpc-proxy/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { config } from '@x402scan/eslint-config/base';

/** @type {import("eslint").Linter.Config[]} */
export default config;
32 changes: 32 additions & 0 deletions apps/rpc-proxy/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@x402scan/rpc-proxy",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"types:check": "tsc --noEmit",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
"knip": "pnpm -w knip --workspace ./apps/rpc-proxy",
"format": "pnpm -w format:dir ./apps/rpc-proxy",
"format:check": "pnpm -w format:check:dir ./apps/rpc-proxy"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"dotenv": "catalog:env",
"hono": "^4.7.1",
"neverthrow": "^8.2.0",
"viem": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@x402scan/eslint-config": "workspace:*",
"@x402scan/typescript-config": "workspace:*",
"eslint": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:"
}
}
83 changes: 83 additions & 0 deletions apps/rpc-proxy/src/routes/balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { Context, Hono } from 'hono';
import type { Address } from 'viem';

import {
createPublicClient,
erc20Abi,
formatUnits,
getAddress,
http,
isAddress,
} from 'viem';
import { base } from 'viem/chains';

import { errAsync, ResultAsync } from 'neverthrow';

const BASE_USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const USDC_DECIMALS = 6;
const USDC_SYMBOL = 'USDC';
const BASE_CHAIN = base;

const ERROR_NO_RPC = 'no_rpc';
const ERROR_RPC_FAILED = 'rpc_failed';

type GetBalanceError =
| { type: typeof ERROR_NO_RPC; message: string }
| { type: typeof ERROR_RPC_FAILED; message: string };

function getBalance(address: Address): ResultAsync<bigint, GetBalanceError> {
const rpcUrl = process.env.BASE_RPC_URL;
if (!rpcUrl) {
return errAsync({ type: ERROR_NO_RPC, message: 'No RPC URL provided' });
}
const client = createPublicClient({ chain: base, transport: http(rpcUrl) });
return ResultAsync.fromPromise(
client.readContract({
address: BASE_USDC_ADDRESS,
abi: erc20Abi,
functionName: 'balanceOf',
args: [address],
}),
(): GetBalanceError => ({
type: ERROR_RPC_FAILED,
message: 'RPC balanceOf call failed',
})
);
}

async function balanceHandler(c: Context) {
const addressParam = c.req.param('address');
if (!isAddress(addressParam)) {
return c.json({ error: 'Invalid EVM address' }, 400);
}
const address = getAddress(addressParam);

return await getBalance(address).match(
balance =>
c.json({
chain: BASE_CHAIN.id,
address,
balance: formatUnits(balance, USDC_DECIMALS),
rawBalance: balance.toString(),
token: {
symbol: USDC_SYMBOL,
decimals: USDC_DECIMALS,
address: BASE_USDC_ADDRESS,
},
}),
error => {
switch (error.type) {
case ERROR_NO_RPC:
return c.json({ error: error.message }, 503);
case ERROR_RPC_FAILED:
return c.json({ error: error.message }, 502);
default:
return c.json({ error: 'Unhandled error' }, 500);
}
}
);
}

export function registerBalanceRouter(app: Hono) {
app.get('/balance/:address', balanceHandler);
}
39 changes: 39 additions & 0 deletions apps/rpc-proxy/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import 'dotenv/config';
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { cors } from 'hono/cors';

import { registerBalanceRouter } from './routes/balance.js';

const app = new Hono();

// Enable CORS for all origins (simple service endpoint)
app.use(
'*',
cors({
origin: '*',
allowMethods: ['GET', 'OPTIONS'],
allowHeaders: ['*'],
exposeHeaders: ['*'],
credentials: true,
})
);

app.get('/', c => {
return c.json({
service: 'rpc-proxy',
version: '1.0.0',
endpoints: {
balance: '/balance/:address',
},
});
});

registerBalanceRouter(app);

const port = Number(process.env.PORT) || 6970;

serve({
fetch: app.fetch,
port,
});
9 changes: 9 additions & 0 deletions apps/rpc-proxy/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "@x402scan/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
12 changes: 12 additions & 0 deletions apps/rpc-proxy/turbo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"dev": {
"env": ["PORT", "BASE_RPC_URL"]
},
"build": {
"env": ["PORT", "BASE_RPC_URL"]
}
}
}
47 changes: 47 additions & 0 deletions apps/scan/src/app/api/rpc/balance/[address]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import {
ERROR_NO_RPC,
ERROR_RPC_FAILED,
getBalance,
USDC_DECIMALS,
} from '@/services/rpc/balance';
import { formatUnits, getAddress, isAddress } from 'viem';

export async function GET(
_: Request,
{ params }: { params: Promise<{ address: string }> }
) {
const { address } = await params;

if (!isAddress(address)) {
return NextResponse.json({ error: 'Invalid EVM address' }, { status: 400 });
}

const parsedAddress = getAddress(address);

const balance = await getBalance(parsedAddress).match(
balance =>
NextResponse.json(
{
balance: formatUnits(balance, USDC_DECIMALS),
rawBalance: balance.toString(),
},
{ status: 200 }
),
error => {
switch (error.type) {
case ERROR_NO_RPC:
return NextResponse.json({ error: error.message }, { status: 503 });
case ERROR_RPC_FAILED:
return NextResponse.json({ error: error.message }, { status: 502 });
default:
return NextResponse.json(
{ error: 'Unhandled error' },
{ status: 500 }
);
Comment on lines +38 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return NextResponse.json(
{ error: 'Unhandled error' },
{ status: 500 }
);
const _exhaustiveCheck: never = error;
return _exhaustiveCheck;

Switch statement has a default case that silently catches unhandled GetBalanceError union members instead of enforcing exhaustiveness checking

Fix on Vercel

}
}
);

return balance;
}
41 changes: 41 additions & 0 deletions apps/scan/src/services/rpc/balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { env } from '@/env';
import { errAsync, ResultAsync } from 'neverthrow';
import { base } from 'wagmi/chains';
import {
createPublicClient,
http,
erc20Abi,
type Address,
} from 'viem';

export const USDC_DECIMALS = 6;
const BASE_USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';

export const ERROR_NO_RPC = 'no_rpc';
export const ERROR_RPC_FAILED = 'rpc_failed';

type GetBalanceError =
| { type: typeof ERROR_NO_RPC; message: string }
| { type: typeof ERROR_RPC_FAILED; message: string };

export function getBalance(
address: Address
): ResultAsync<bigint, GetBalanceError> {
const rpcUrl = env.NEXT_PUBLIC_BASE_RPC_URL;
if (!rpcUrl) {
return errAsync({ type: ERROR_NO_RPC, message: 'No RPC URL provided' });
}
const client = createPublicClient({ chain: base, transport: http(rpcUrl) });
return ResultAsync.fromPromise(
client.readContract({
address: BASE_USDC_ADDRESS,
abi: erc20Abi,
functionName: 'balanceOf',
args: [address],
}),
(): GetBalanceError => ({
type: ERROR_RPC_FAILED,
message: 'RPC balanceOf call failed',
})
);
}
1 change: 1 addition & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const config: KnipConfig = {
ignore: ['src/scripts/**', 'src/components/ui/charts/chart/**'],
},
'apps/proxy': {},
'apps/rpc-proxy': {},
'apps/rpcs/solana': {},
'packages/external/facilitators': {
project: ['src/**/*.ts'],
Expand Down
Loading