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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ To add an environment-specific page, add one `SURFACES` entry (middleware and th
llms generator pick it up automatically) and gate its nav entry / layout / API
guard on `surfaceEnabled(...)`. `deploy.config.test.mjs` covers the matrix logic.

`NEXT_PUBLIC_DEPLOY_TARGET` is a **build-time** switch, so it can only separate
deployments that are built separately. The internal target builds one image and
promotes it between environments, so anything that must differ *within* the
internal target has to be runtime config instead. `TIPS_CHAINS` is the current
example: a comma-separated allowlist (`mainnet,sepolia`) of the chains the TIPS
chain switcher offers and its API will serve, read per request in
`app/tips/enabledChains.ts`. Unset — the default, including local dev — means
every known chain.

## Deployment

Deployed on Vercel (external target). Push to the default branch to ship; pull
Expand Down
4 changes: 3 additions & 1 deletion app/api/tips/block/[hash]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
transactionMetadataFromAuditEvents,
} from '../../audit-events';
import { getAuditRpcUrl, getRpcUrl } from '../../config';
import { tipsDisabledResponse } from '../../guard';
import { tipsChainDisabledResponse, tipsDisabledResponse } from '../../guard';
import { getTransactionReceiptSummaries } from '../../receipts';
import {
cacheBlockData,
Expand Down Expand Up @@ -305,6 +305,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash
const disabled = tipsDisabledResponse();
if (disabled) return disabled;
const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain'));
const chainDisabled = tipsChainDisabledResponse(chain);
if (chainDisabled) return chainDisabled;
const rpcUrl = getRpcUrl(chain);

try {
Expand Down
4 changes: 3 additions & 1 deletion app/api/tips/blocks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
parseBlockListQuery,
} from '../block-list';
import { getRpcUrl } from '../config';
import { tipsDisabledResponse } from '../guard';
import { tipsChainDisabledResponse, tipsDisabledResponse } from '../guard';

export const runtime = 'nodejs';

Expand All @@ -18,6 +18,8 @@ export async function GET(request: Request) {
const disabled = tipsDisabledResponse();
if (disabled) return disabled;
const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain'));
const chainDisabled = tipsChainDisabledResponse(chain);
if (chainDisabled) return chainDisabled;

try {
const query = parseBlockListQuery(new URL(request.url).searchParams);
Expand Down
4 changes: 3 additions & 1 deletion app/api/tips/bundle/[hash]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getJoinedAuditEventsByBundle,
} from '../../audit-events';
import { getAuditRpcUrl, getRpcUrl } from '../../config';
import { tipsDisabledResponse } from '../../guard';
import { tipsChainDisabledResponse, tipsDisabledResponse } from '../../guard';
import { getBundleHistory } from '../../s3';
import type { BundleEvent, BundleHistory, BundleTransaction } from '../../transaction-data';
import { publicClientFor, type TipsPublicClient } from '../../viem';
Expand Down Expand Up @@ -96,6 +96,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash
const disabled = tipsDisabledResponse();
if (disabled) return disabled;
const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain'));
const chainDisabled = tipsChainDisabledResponse(chain);
if (chainDisabled) return chainDisabled;

try {
const { hash } = await params;
Expand Down
14 changes: 14 additions & 0 deletions app/api/tips/guard.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { TipsChain } from '../../tips/chains';
import { isTipsChainEnabled } from '../../tips/enabledChains';
import { TIPS_ENABLED } from '../../tips/flag';

// Returns a 404 Response when TIPS is disabled (the public/Vercel build), else
Expand All @@ -7,3 +9,15 @@ import { TIPS_ENABLED } from '../../tips/flag';
export function tipsDisabledResponse(): Response | null {
return TIPS_ENABLED ? null : Response.json({ error: 'Not found' }, { status: 404 });
}

// Returns a 404 Response when this deployment does not serve `chain`, else
// null. The UI never asks for a disabled chain — useTipsChain clamps `?chain=`
// to the enabled list — so this catches hand-edited URLs and stale links. 404
// rather than a silent fall back to the default chain, which would return one
// chain's data under another chain's name; and 404 rather than letting the
// request through to per-chain config that is unset in this environment, where
// it would read the default bucket and 500 or, worse, succeed against the
// wrong source.
export function tipsChainDisabledResponse(chain: TipsChain): Response | null {
return isTipsChainEnabled(chain) ? null : Response.json({ error: 'Not found' }, { status: 404 });
}
4 changes: 3 additions & 1 deletion app/api/tips/rejected/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
rejectedTransactionFromAuditEvent,
} from '../audit-events';
import { getAuditRpcUrl } from '../config';
import { tipsDisabledResponse } from '../guard';
import { tipsChainDisabledResponse, tipsDisabledResponse } from '../guard';
import { getRejectedTransaction, listRejectedTransactions } from '../s3';
import type { RejectedTransaction } from '../transaction-data';

Expand All @@ -18,6 +18,8 @@ export async function GET(request: Request) {
const disabled = tipsDisabledResponse();
if (disabled) return disabled;
const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain'));
const chainDisabled = tipsChainDisabledResponse(chain);
if (chainDisabled) return chainDisabled;

try {
// Audit-first, S3 fallback: use the S3 archive only when audit is not
Expand Down
4 changes: 3 additions & 1 deletion app/api/tips/txn/[hash]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { resolveTipsChain } from '../../../../tips/chains';
import { tipsDisabledResponse } from '../../guard';
import { tipsChainDisabledResponse, tipsDisabledResponse } from '../../guard';
import {
InvalidTransactionHashError,
lookupTransaction,
Expand All @@ -17,6 +17,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash
const disabled = tipsDisabledResponse();
if (disabled) return disabled;
const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain'));
const chainDisabled = tipsChainDisabledResponse(chain);
if (chainDisabled) return chainDisabled;

try {
const { hash } = await params;
Expand Down
87 changes: 87 additions & 0 deletions app/tips/chains.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it } from 'vitest';

import { ALL_TIPS_CHAINS, parseTipsChains, resolveTipsChain } from './chains';
import { enabledTipsChains, isTipsChainEnabled } from './enabledChains';

describe('parseTipsChains', () => {
it('treats unset and empty as every known chain', () => {
expect(parseTipsChains(undefined)).toEqual([...ALL_TIPS_CHAINS]);
expect(parseTipsChains(null)).toEqual([...ALL_TIPS_CHAINS]);
expect(parseTipsChains('')).toEqual([...ALL_TIPS_CHAINS]);
});

it('parses an allowlist, tolerating whitespace and case', () => {
expect(parseTipsChains('mainnet,sepolia')).toEqual(['mainnet', 'sepolia']);
expect(parseTipsChains(' MAINNET , Sepolia ')).toEqual(['mainnet', 'sepolia']);
});

it('keeps catalogue order and drops duplicates', () => {
expect(parseTipsChains('zeronet,mainnet,mainnet')).toEqual(['mainnet', 'zeronet']);
});

it('drops unknown names but keeps the recognized ones', () => {
expect(parseTipsChains('mainnet,nope')).toEqual(['mainnet']);
});

it('falls back to every chain when nothing recognizable is named', () => {
// A typo should not empty the section; unset semantics are the safer default.
expect(parseTipsChains('nope,alsonope')).toEqual([...ALL_TIPS_CHAINS]);
});
});

describe('resolveTipsChain', () => {
it('defaults to mainnet for missing or unknown values', () => {
expect(resolveTipsChain(null)).toBe('mainnet');
expect(resolveTipsChain('nope')).toBe('mainnet');
});

it('returns the requested chain when it is enabled', () => {
expect(resolveTipsChain('zeronet')).toBe('zeronet');
expect(resolveTipsChain('sepolia', ['mainnet', 'sepolia'])).toBe('sepolia');
});

it('falls back to the default when the requested chain is not served here', () => {
// The production case: a zeronet link opened against the prod deployment.
expect(resolveTipsChain('zeronet', ['mainnet', 'sepolia'])).toBe('mainnet');
});

it('falls back to the first enabled chain when the default is not served', () => {
expect(resolveTipsChain('mainnet', ['sepolia'])).toBe('sepolia');
expect(resolveTipsChain('zeronet', ['sepolia', 'zeronet'])).toBe('zeronet');
});
});

describe('enabledTipsChains', () => {
const original = process.env.TIPS_CHAINS;
afterEach(() => {
if (original === undefined) delete process.env.TIPS_CHAINS;
else process.env.TIPS_CHAINS = original;
});

it('serves every chain when TIPS_CHAINS is unset (local dev)', () => {
delete process.env.TIPS_CHAINS;
expect(enabledTipsChains()).toEqual([...ALL_TIPS_CHAINS]);
expect(isTipsChainEnabled('zeronet')).toBe(true);
});

it('honours the production allowlist', () => {
process.env.TIPS_CHAINS = 'mainnet,sepolia';
expect(enabledTipsChains()).toEqual(['mainnet', 'sepolia']);
expect(isTipsChainEnabled('mainnet')).toBe(true);
expect(isTipsChainEnabled('sepolia')).toBe(true);
expect(isTipsChainEnabled('zeronet')).toBe(false);
});

it('honours the development allowlist', () => {
process.env.TIPS_CHAINS = 'mainnet,sepolia,zeronet';
expect(enabledTipsChains()).toEqual(['mainnet', 'sepolia', 'zeronet']);
expect(isTipsChainEnabled('zeronet')).toBe(true);
});

it('is read per call, not cached at module load', () => {
process.env.TIPS_CHAINS = 'mainnet';
expect(enabledTipsChains()).toEqual(['mainnet']);
process.env.TIPS_CHAINS = 'mainnet,zeronet';
expect(enabledTipsChains()).toEqual(['mainnet', 'zeronet']);
});
});
46 changes: 40 additions & 6 deletions app/tips/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ function explorerFor(chain: TipsChain): string {
return configured && configured.length > 0 ? configured : DEFAULT_EXPLORERS[chain];
}

export const TIPS_CHAINS: readonly TipsChainInfo[] = (
['mainnet', 'sepolia', 'zeronet'] as const
).map((id) => ({
/** Every chain the TIPS surface knows how to render, in display order. */
export const ALL_TIPS_CHAINS = ['mainnet', 'sepolia', 'zeronet'] as const;

export const TIPS_CHAINS: readonly TipsChainInfo[] = ALL_TIPS_CHAINS.map((id) => ({
id,
label: id === 'mainnet' ? 'Base Mainnet' : id === 'sepolia' ? 'Base Sepolia' : 'Zeronet',
explorerUrl: explorerFor(id),
Expand All @@ -38,9 +39,42 @@ export function isTipsChain(value: string | null | undefined): value is TipsChai
return value === 'mainnet' || value === 'sepolia' || value === 'zeronet';
}

/** Normalize an unknown ?chain= value to a valid chain (falls back to default). */
export function resolveTipsChain(value: string | null | undefined): TipsChain {
return isTipsChain(value) ? value : DEFAULT_TIPS_CHAIN;
/**
* Normalize an unknown ?chain= value to a chain that is actually available.
*
* `enabled` defaults to every known chain, so callers with no deployment
* context behave as before. When the requested chain is absent from `enabled`
* — a stale link, or a URL hand-edited to a chain this deployment does not
* serve — this falls back to the default chain if it is enabled, else to the
* first enabled one, so the caller always gets a chain it can serve.
*/
export function resolveTipsChain(
value: string | null | undefined,
enabled: readonly TipsChain[] = ALL_TIPS_CHAINS,
): TipsChain {
if (enabled.length === 0) return DEFAULT_TIPS_CHAIN;
if (isTipsChain(value) && enabled.includes(value)) return value;
return enabled.includes(DEFAULT_TIPS_CHAIN) ? DEFAULT_TIPS_CHAIN : enabled[0];
}

/**
* Parse a `TIPS_CHAINS` allowlist ("mainnet,sepolia") into chain ids.
*
* Unset or empty means every known chain — the local-dev and pre-configuration
* default, which keeps behaviour unchanged for deployments that do not set it.
* Unknown names are dropped rather than failing the request: the env var is
* operator-supplied, and a typo should not take the whole section down. A value
* naming only unknown chains is treated as unset for the same reason.
*/
export function parseTipsChains(raw: string | null | undefined): readonly TipsChain[] {
if (!raw) return ALL_TIPS_CHAINS;
const named = raw
.split(',')
.map((part) => part.trim().toLowerCase())
.filter(isTipsChain);
if (named.length === 0) return ALL_TIPS_CHAINS;
// Keep the catalogue's display order and drop duplicates.
return ALL_TIPS_CHAINS.filter((id) => named.includes(id));
}

export function tipsChainInfo(chain: TipsChain): TipsChainInfo {
Expand Down
15 changes: 10 additions & 5 deletions app/tips/components/ChainToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,26 @@

import { Tabs } from '../../components/ui/Tabs';
import { trackTipsChainSelect } from '../../analytics/events';
import { TIPS_CHAINS, type TipsChain } from '../chains';
import { tipsChainInfo, type TipsChain } from '../chains';
import { useTipsChain } from '../library/useTipsChain';
import { useEnabledTipsChains } from './TipsChainsProvider';

// Segmented control over the TIPS chains (Base Mainnet / Base Sepolia /
// Zeronet). Rewrites `?chain=` via useTipsChain's setter so the selection
// persists across navigation, and reports the choice to analytics.
// Segmented control over the chains this deployment serves. Rewrites `?chain=`
// via useTipsChain's setter so the selection persists across navigation, and
// reports the choice to analytics. Hidden when there is nothing to choose
// between — a one-chain deployment gets a label-less single tab otherwise.
export function ChainToggle() {
const { chain, setChain } = useTipsChain();
const enabled = useEnabledTipsChains();

if (enabled.length < 2) return null;

return (
<Tabs
ariaLabel="Select chain"
size="sm"
value={chain}
items={TIPS_CHAINS.map((c) => ({ value: c.id, label: c.label }))}
items={enabled.map((id) => ({ value: id, label: tipsChainInfo(id).label }))}
onChange={(value) => {
const next = value as TipsChain;
setChain(next);
Expand Down
32 changes: 32 additions & 0 deletions app/tips/components/TipsChainsProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import { createContext, useContext, type ReactNode } from 'react';

import { ALL_TIPS_CHAINS, type TipsChain } from '../chains';

// Carries the deployment's chain allowlist from the server (TIPS_CHAINS, read
// in app/tips/enabledChains.ts) down to the client components that render and
// resolve the chain. The list is runtime config the client cannot read itself,
// so the section layout resolves it once and provides it here.
const TipsChainsContext = createContext<readonly TipsChain[]>(ALL_TIPS_CHAINS);

export function TipsChainsProvider({
chains,
children,
}: {
chains: readonly TipsChain[];
children: ReactNode;
}) {
return <TipsChainsContext.Provider value={chains}>{children}</TipsChainsContext.Provider>;
}

/**
* The chains this deployment serves, in display order.
*
* Defaults to every known chain when no provider is present, matching the
* unset-TIPS_CHAINS behaviour so a component rendered outside the section
* (or in a test) still works.
*/
export function useEnabledTipsChains(): readonly TipsChain[] {
return useContext(TipsChainsContext);
}
20 changes: 20 additions & 0 deletions app/tips/enabledChains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Which TIPS chains this deployment serves. Server-only: reads TIPS_CHAINS,
// a plain (non-NEXT_PUBLIC_) env var, so it must never be imported from a
// client component — the client gets the list through TipsChainsProvider.
//
// This is deliberately runtime rather than build-time config. The internal
// deployment builds one image (protocols/ui Dockerfile.ui) and promotes that
// same image from development to production, so a NEXT_PUBLIC_* flag — inlined
// into the bundle at build time — cannot differ between the two environments.
// The Helm chart already varies per-chain TIPS_* env this way; TIPS_CHAINS
// joins it. Unset means every known chain, which is what local dev sees.
import { parseTipsChains, type TipsChain } from './chains';

export function enabledTipsChains(): readonly TipsChain[] {
return parseTipsChains(process.env.TIPS_CHAINS);
}

/** Is this chain served by this deployment? */
export function isTipsChainEnabled(chain: TipsChain): boolean {
return enabledTipsChains().includes(chain);
}
10 changes: 9 additions & 1 deletion app/tips/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';

import { TipsChainsProvider } from './components/TipsChainsProvider';
import { enabledTipsChains } from './enabledChains';
import { TIPS_ENABLED } from './flag';

// Metadata for the TIPS section. The app-wide chrome (sidebar, header) comes
Expand All @@ -18,5 +20,11 @@ export default function TipsLayout({ children }: { children: ReactNode }) {
// disabled. With the flag off this branch is a compile-time constant, so the
// section is unreachable in the public build.
if (!TIPS_ENABLED) notFound();
return <div className="mx-auto flex w-full max-w-5xl flex-1 flex-col">{children}</div>;
// Resolved here, once per request, because TIPS_CHAINS is server-only runtime
// config: the client components below cannot read it themselves.
return (
<TipsChainsProvider chains={enabledTipsChains()}>
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col">{children}</div>
</TipsChainsProvider>
);
}
Loading
Loading