Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ NEXT_PUBLIC_VIBENET_RPC_URL=https://rpc.vibes.base.org
# when set, /api/internal-explorer prefers it over S3 and falls back to S3 when
# unset/empty.
# TIPS_MAINNET_AUDIT_RPC_URL=
# Public origin that serves a chain's Internal Explorer (runtime, not
# NEXT_PUBLIC_*). Unset locally so the chain toggle stays on this origin.
# BASE_UI_ZERONET_HOST=https://base-ui.aws-dev.cbhq.net
# BASE_UI_MAINNET_HOST=https://base-ui.aws.cbhq.net
# BASE_UI_SEPOLIA_HOST=https://base-ui.aws.cbhq.net
# Per-chain block explorer for Internal Explorer links (client-visible). Defaults:
# mainnet=https://base.blockscout.com, sepolia=https://base-sepolia.blockscout.com.
# NEXT_PUBLIC_TIPS_ZERONET_EXPLORER_URL=
Expand Down
5 changes: 3 additions & 2 deletions app/api/internal-explorer/block/[hash]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { type Hash } from 'viem';

import { resolveExplorerChain, type ExplorerChain } from '../../../../internal-explorer/chains';
import { type ExplorerChain } from '../../../../internal-explorer/chains';
import { calculateTransactionFee } from '../../../../internal-explorer/library/explorer-format';
import { resolveExplorerChainFromRequest } from '../../chain';
import {
bundleHistoryFromAuditEvents,
getAuditEventsByBlockNumber,
Expand Down Expand Up @@ -304,7 +305,7 @@ async function buildAndCacheBlockData(
export async function GET(request: Request, { params }: { params: Promise<{ hash: string }> }) {
const disabled = explorerDisabledResponse();
if (disabled) return disabled;
const chain = resolveExplorerChain(new URL(request.url).searchParams.get('chain'));
const chain = resolveExplorerChainFromRequest(request);
const rpcUrl = getRpcUrl(chain);

try {
Expand Down
4 changes: 2 additions & 2 deletions app/api/internal-explorer/blocks/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolveExplorerChain } from '../../../internal-explorer/chains';
import { resolveExplorerChainFromRequest } from '../chain';
import {
BlockListUnavailableError,
InvalidBlockListQueryError,
Expand All @@ -17,7 +17,7 @@ export type { BlockSummary, BlocksPage, BlocksResponse } from '../block-list';
export async function GET(request: Request) {
const disabled = explorerDisabledResponse();
if (disabled) return disabled;
const chain = resolveExplorerChain(new URL(request.url).searchParams.get('chain'));
const chain = resolveExplorerChainFromRequest(request);

try {
const query = parseBlockListQuery(new URL(request.url).searchParams);
Expand Down
5 changes: 3 additions & 2 deletions app/api/internal-explorer/bundle/[hash]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type Hash } from 'viem';

import { resolveExplorerChain, type ExplorerChain } from '../../../../internal-explorer/chains';
import { type ExplorerChain } from '../../../../internal-explorer/chains';
import { resolveExplorerChainFromRequest } from '../../chain';
import {
bundleHistoryFromAuditEvents,
getJoinedAuditEventsByBundle,
Expand Down Expand Up @@ -95,7 +96,7 @@ async function enrichBundleTransactionsFromRpc(
export async function GET(request: Request, { params }: { params: Promise<{ hash: string }> }) {
const disabled = explorerDisabledResponse();
if (disabled) return disabled;
const chain = resolveExplorerChain(new URL(request.url).searchParams.get('chain'));
const chain = resolveExplorerChainFromRequest(request);

try {
const { hash } = await params;
Expand Down
15 changes: 15 additions & 0 deletions app/api/internal-explorer/chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Resolve the active ExplorerChain from an API request: explicit `?chain=`
// wins; otherwise the Host header picks the origin default (zeronet on aws-dev,
// mainnet on aws prod or when no hosts are configured).
import { resolveExplorerChain, type ExplorerChain } from '../../internal-explorer/chains';
import { originFromHostHeader } from '../../internal-explorer/hosts';
import { getExplorerHosts } from './config';

export function resolveExplorerChainFromRequest(request: Request): ExplorerChain {
const chainParam = new URL(request.url).searchParams.get('chain');
const origin = originFromHostHeader(
request.headers.get('host'),
request.headers.get('x-forwarded-host'),
);
return resolveExplorerChain(chainParam, origin, getExplorerHosts());
}
19 changes: 19 additions & 0 deletions app/api/internal-explorer/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { S3Client, type S3ClientConfig } from '@aws-sdk/client-s3';

import type { ExplorerChain } from '../../internal-explorer/chains';
import type { ExplorerHostMap } from '../../internal-explorer/hosts';

// Env var infix for each chain: TIPS_MAINNET_*, TIPS_SEPOLIA_*, TIPS_ZERONET_*.
const ENV_PREFIX: Record<ExplorerChain, string> = {
Expand Down Expand Up @@ -90,3 +91,21 @@ export function getShadowMetricsUrl(chain: ExplorerChain): string | undefined {
export function isAuditConfigured(chain: ExplorerChain): boolean {
return Boolean(getAuditRpcUrl(chain));
}

// Public origin that serves observability for a chain. Unset locally so the
// client stays on the current origin with no host-switch prompt. Set at runtime
// (not NEXT_PUBLIC_*) so the same image can default zeronet on aws-dev and
// mainnet on aws prod. Helm: BASE_UI_<CHAIN>_HOST.
export function getExplorerHost(chain: ExplorerChain): string | undefined {
const value = envValue([`BASE_UI_${ENV_PREFIX[chain]}_HOST`])?.trim();
return value || undefined;
}

export function getExplorerHosts(): ExplorerHostMap {
const hosts: ExplorerHostMap = {};
for (const chain of ['mainnet', 'sepolia', 'zeronet'] as const) {
const host = getExplorerHost(chain);
if (host) hosts[chain] = host;
}
return hosts;
}
274 changes: 274 additions & 0 deletions app/api/internal-explorer/hosts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import assert from 'node:assert/strict';

import { afterEach, beforeEach, describe, test } from 'vitest';

import { resolveExplorerChainFromRequest } from './chain';
import { getExplorerHost, getExplorerHosts } from './config';
import { DEFAULT_EXPLORER_CHAIN, resolveExplorerChain, type ExplorerChain } from '../../internal-explorer/chains';
import {
configuredHostOrigin,
defaultExplorerChainForOrigin,
explorerHostEnvironment,
explorerHostLabel,
explorerHostSwitchHref,
originFromHostHeader,
originsEqual,
planHostSwitch,
} from '../../internal-explorer/hosts';

const HOST_KEYS = ['BASE_UI_ZERONET_HOST', 'BASE_UI_MAINNET_HOST', 'BASE_UI_SEPOLIA_HOST'] as const;

const DEPLOYED_HOSTS: Record<ExplorerChain, string> = {
zeronet: 'https://base-ui.aws-dev.cbhq.net',
mainnet: 'https://base-ui.aws.cbhq.net',
sepolia: 'https://base-ui.aws.cbhq.net',
};

const previousEnv: Partial<Record<(typeof HOST_KEYS)[number], string | undefined>> = {};

beforeEach(() => {
for (const key of HOST_KEYS) {
previousEnv[key] = process.env[key];
delete process.env[key];
}
});

afterEach(() => {
for (const key of HOST_KEYS) {
if (previousEnv[key] === undefined) delete process.env[key];
else process.env[key] = previousEnv[key];
}
});

describe('originsEqual', () => {
test('treats a Host header and a full origin as the same host', () => {
assert.equal(originsEqual('base-ui.aws-dev.cbhq.net', 'https://base-ui.aws-dev.cbhq.net'), true);
assert.equal(originsEqual('https://base-ui.aws.cbhq.net/', 'base-ui.aws.cbhq.net'), true);
});

test('ignores a default https port', () => {
assert.equal(
originsEqual('https://base-ui.aws.cbhq.net:443', 'https://base-ui.aws.cbhq.net'),
true,
);
});

test('distinguishes aws-dev from aws prod', () => {
assert.equal(
originsEqual('https://base-ui.aws-dev.cbhq.net', 'https://base-ui.aws.cbhq.net'),
false,
);
});

test('keeps a non-default port', () => {
assert.equal(originsEqual('localhost:3000', 'http://localhost:3000'), true);
assert.equal(originsEqual('localhost:3000', 'http://localhost:3001'), false);
});

test('rejects empty values', () => {
assert.equal(originsEqual('', 'https://base-ui.aws.cbhq.net'), false);
assert.equal(originsEqual('https://base-ui.aws.cbhq.net', ''), false);
});
});

describe('defaultExplorerChainForOrigin', () => {
test('defaults to mainnet when no hosts are configured', () => {
assert.equal(defaultExplorerChainForOrigin('http://localhost:3000', {}), 'mainnet');
assert.equal(DEFAULT_EXPLORER_CHAIN, 'mainnet');
});

test('defaults to zeronet on the zeronet host', () => {
assert.equal(
defaultExplorerChainForOrigin('https://base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS),
'zeronet',
);
assert.equal(defaultExplorerChainForOrigin('base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS), 'zeronet');
});

test('defaults to mainnet on the mainnet/sepolia host', () => {
assert.equal(
defaultExplorerChainForOrigin('https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS),
'mainnet',
);
assert.equal(defaultExplorerChainForOrigin('base-ui.aws.cbhq.net', DEPLOYED_HOSTS), 'mainnet');
});

test('falls back to mainnet when the origin matches none of the hosts', () => {
assert.equal(defaultExplorerChainForOrigin('https://example.invalid', DEPLOYED_HOSTS), 'mainnet');
});
});

describe('resolveExplorerChain', () => {
test('keeps an explicit chain even when it belongs on another host', () => {
assert.equal(
resolveExplorerChain('mainnet', 'https://base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS),
'mainnet',
);
assert.equal(
resolveExplorerChain('zeronet', 'https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS),
'zeronet',
);
});

test('defaults from origin when ?chain= is missing', () => {
assert.equal(
resolveExplorerChain(null, 'https://base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS),
'zeronet',
);
assert.equal(
resolveExplorerChain(undefined, 'https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS),
'mainnet',
);
assert.equal(resolveExplorerChain(null), 'mainnet');
});

test('treats an unknown chain param as missing', () => {
assert.equal(
resolveExplorerChain('goerli', 'https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS),
'mainnet',
);
});
});

describe('planHostSwitch', () => {
test('replaces in place when the next chain is on this origin', () => {
assert.equal(
planHostSwitch('https://base-ui.aws.cbhq.net', 'sepolia', DEPLOYED_HOSTS, false),
'replace',
);
assert.equal(
planHostSwitch('https://base-ui.aws-dev.cbhq.net', 'zeronet', DEPLOYED_HOSTS, false),
'replace',
);
});

test('replaces in place when hosts are unset', () => {
assert.equal(planHostSwitch('http://localhost:3000', 'mainnet', {}, false), 'replace');
assert.equal(planHostSwitch('http://localhost:3000', 'zeronet', {}, true), 'replace');
});

test('prompts when the next chain is on another host', () => {
assert.equal(
planHostSwitch('https://base-ui.aws-dev.cbhq.net', 'mainnet', DEPLOYED_HOSTS, false),
'prompt',
);
assert.equal(
planHostSwitch('https://base-ui.aws.cbhq.net', 'zeronet', DEPLOYED_HOSTS, false),
'prompt',
);
});

test('navigates immediately when the skip-prompt flag is set', () => {
assert.equal(
planHostSwitch('https://base-ui.aws-dev.cbhq.net', 'mainnet', DEPLOYED_HOSTS, true),
'navigate',
);
assert.equal(
planHostSwitch('https://base-ui.aws.cbhq.net', 'zeronet', DEPLOYED_HOSTS, true),
'navigate',
);
});
});

describe('explorerHostSwitchHref', () => {
test('assigns the destination origin with ?chain= and other query params', () => {
const href = explorerHostSwitchHref(
'https://base-ui.aws.cbhq.net/',
'/internal-explorer/blocks',
'tab=rejected&cursor=1',
'mainnet',
);
const url = new URL(href);
assert.equal(url.origin, 'https://base-ui.aws.cbhq.net');
assert.equal(url.pathname, '/internal-explorer/blocks');
assert.equal(url.searchParams.get('chain'), 'mainnet');
assert.equal(url.searchParams.get('tab'), 'rejected');
assert.equal(url.searchParams.get('cursor'), '1');
});

test('overwrites an existing chain param', () => {
const href = explorerHostSwitchHref(
'https://base-ui.aws-dev.cbhq.net',
'/internal-explorer',
'chain=mainnet',
'zeronet',
);
assert.equal(new URL(href).searchParams.get('chain'), 'zeronet');
});
});

describe('host labels', () => {
test('strips the scheme for display', () => {
assert.equal(explorerHostLabel('https://base-ui.aws.cbhq.net'), 'base-ui.aws.cbhq.net');
});

test('labels the zeronet host as development and the rest as production', () => {
assert.equal(
explorerHostEnvironment('https://base-ui.aws-dev.cbhq.net', DEPLOYED_HOSTS),
'development',
);
assert.equal(explorerHostEnvironment('https://base-ui.aws.cbhq.net', DEPLOYED_HOSTS), 'production');
});

test('canonicalizes a configured host to its origin', () => {
assert.equal(configuredHostOrigin('https://base-ui.aws.cbhq.net/'), 'https://base-ui.aws.cbhq.net');
});

test('prefers x-forwarded-host over Host', () => {
assert.equal(
originFromHostHeader('internal.local', 'base-ui.aws.cbhq.net, other.local'),
'base-ui.aws.cbhq.net',
);
});
});

describe('getExplorerHosts', () => {
test('returns an empty map when BASE_UI_*_HOST is unset', () => {
assert.deepEqual(getExplorerHosts(), {});
assert.equal(getExplorerHost('zeronet'), undefined);
});

test('reads BASE_UI_<CHAIN>_HOST at call time', () => {
process.env.BASE_UI_ZERONET_HOST = 'https://base-ui.aws-dev.cbhq.net';
process.env.BASE_UI_MAINNET_HOST = 'https://base-ui.aws.cbhq.net';
process.env.BASE_UI_SEPOLIA_HOST = 'https://base-ui.aws.cbhq.net';
assert.deepEqual(getExplorerHosts(), DEPLOYED_HOSTS);
});
});

describe('resolveExplorerChainFromRequest', () => {
test('defaults from the Host header when ?chain= is missing', () => {
process.env.BASE_UI_ZERONET_HOST = DEPLOYED_HOSTS.zeronet;
process.env.BASE_UI_MAINNET_HOST = DEPLOYED_HOSTS.mainnet;
process.env.BASE_UI_SEPOLIA_HOST = DEPLOYED_HOSTS.sepolia;

const zeronetReq = new Request('https://base-ui.aws-dev.cbhq.net/api/internal-explorer/blocks', {
headers: { host: 'base-ui.aws-dev.cbhq.net' },
});
assert.equal(resolveExplorerChainFromRequest(zeronetReq), 'zeronet');

const prodReq = new Request('https://base-ui.aws.cbhq.net/api/internal-explorer/blocks', {
headers: { host: 'base-ui.aws.cbhq.net' },
});
assert.equal(resolveExplorerChainFromRequest(prodReq), 'mainnet');
});

test('keeps an explicit ?chain= that does not match this host', () => {
process.env.BASE_UI_ZERONET_HOST = DEPLOYED_HOSTS.zeronet;
process.env.BASE_UI_MAINNET_HOST = DEPLOYED_HOSTS.mainnet;
process.env.BASE_UI_SEPOLIA_HOST = DEPLOYED_HOSTS.sepolia;

const request = new Request(
'https://base-ui.aws-dev.cbhq.net/api/internal-explorer/blocks?chain=mainnet',
{ headers: { host: 'base-ui.aws-dev.cbhq.net' } },
);
assert.equal(resolveExplorerChainFromRequest(request), 'mainnet');
});

test('defaults to mainnet when hosts are unset', () => {
const request = new Request('http://localhost:3000/api/internal-explorer/blocks', {
headers: { host: 'localhost:3000' },
});
assert.equal(resolveExplorerChainFromRequest(request), 'mainnet');
});
});
Loading
Loading