Skip to content

Commit d6c3cc8

Browse files
authored
Merge pull request #23 from tokenhost/feat/ui-faucet-button
DX: local Anvil faucet endpoint + UI button
2 parents 2b35582 + b94e581 commit d6c3cc8

4 files changed

Lines changed: 297 additions & 12 deletions

File tree

Readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pnpm th dev apps/example/job-board.schema.json
2323

2424
# Open http://127.0.0.1:3000/
2525
# MetaMask: approve switching/adding the Anvil network (chainId 31337).
26+
# Use the "Get test ETH" button (local faucet) if your wallet needs funds.
2627
```
2728

2829
Environment examples:

packages/cli/src/index.ts

Lines changed: 163 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,19 @@ async function ensureAnvilRunning(rpcUrl: string, opts?: { start: boolean; expec
585585
throw new Error(`Timed out waiting for anvil at ${rpcUrl} to become ready.`);
586586
}
587587

588-
function startUiSiteServer(args: { buildDir: string; host: string; port: number }): { server: nodeHttp.Server; url: string } {
588+
type FaucetConfig = {
589+
enabled: boolean;
590+
rpcUrl: string;
591+
chainId: number;
592+
targetWei: bigint;
593+
};
594+
595+
function startUiSiteServer(args: {
596+
buildDir: string;
597+
host: string;
598+
port: number;
599+
faucet?: FaucetConfig | null;
600+
}): { server: nodeHttp.Server; url: string } {
589601
const resolvedBuildDir = path.resolve(args.buildDir);
590602
const uiSiteDir = path.join(resolvedBuildDir, 'ui-site');
591603

@@ -600,6 +612,9 @@ function startUiSiteServer(args: { buildDir: string; host: string; port: number
600612
const host = String(args.host || '127.0.0.1');
601613
const port = args.port;
602614
const rootAbs = path.resolve(uiSiteDir);
615+
const faucet = args.faucet ?? null;
616+
const faucetPath = '/__tokenhost/faucet';
617+
const faucetTargetEth = faucet?.targetWei ? Number(faucet.targetWei / 10n ** 18n) : 10;
603618

604619
function contentTypeForPath(filePath: string): string {
605620
const ext = path.extname(filePath).toLowerCase();
@@ -646,14 +661,39 @@ function startUiSiteServer(args: { buildDir: string; host: string; port: number
646661
res.end(text);
647662
}
648663

664+
function sendJson(res: nodeHttp.ServerResponse, status: number, value: unknown) {
665+
res.statusCode = status;
666+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
667+
res.setHeader('Cache-Control', 'no-store');
668+
res.end(JSON.stringify(value));
669+
}
670+
671+
function toHexQuantity(n: bigint): string {
672+
if (n < 0n) throw new Error('Negative quantity not allowed.');
673+
return `0x${n.toString(16)}`;
674+
}
675+
676+
function readBody(req: nodeHttp.IncomingMessage, maxBytes = 1024 * 1024): Promise<string> {
677+
return new Promise((resolve, reject) => {
678+
let raw = '';
679+
let total = 0;
680+
req.on('data', (chunk: Buffer) => {
681+
total += chunk.length;
682+
if (total > maxBytes) {
683+
reject(new Error('Request body too large.'));
684+
req.destroy();
685+
return;
686+
}
687+
raw += chunk.toString('utf-8');
688+
});
689+
req.on('end', () => resolve(raw));
690+
req.on('error', reject);
691+
});
692+
}
693+
649694
const server = nodeHttp.createServer((req, res) => {
650695
if (!req.url) return sendText(res, 400, 'Bad Request');
651696

652-
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
653-
res.setHeader('Allow', 'GET, HEAD');
654-
return sendText(res, 405, 'Method Not Allowed');
655-
}
656-
657697
let pathname = '/';
658698
try {
659699
pathname = new URL(req.url, `http://${host}:${port}`).pathname || '/';
@@ -667,6 +707,78 @@ function startUiSiteServer(args: { buildDir: string; host: string; port: number
667707
return sendText(res, 400, 'Bad Request');
668708
}
669709

710+
if (pathname === faucetPath) {
711+
(async () => {
712+
const enabled = Boolean(faucet?.enabled && faucet.rpcUrl && faucet.chainId === anvil.id);
713+
if (req.method === 'GET' || req.method === 'HEAD') {
714+
return sendJson(res, 200, {
715+
ok: true,
716+
enabled,
717+
chainId: faucet?.chainId ?? null,
718+
targetEthDefault: faucetTargetEth,
719+
reason: enabled ? null : faucet ? 'disabled' : 'not-configured'
720+
});
721+
}
722+
723+
if (req.method !== 'POST') {
724+
res.setHeader('Allow', 'GET, HEAD, POST');
725+
return sendText(res, 405, 'Method Not Allowed');
726+
}
727+
728+
if (!enabled) {
729+
return sendJson(res, 400, { ok: false, error: 'Faucet is disabled.' });
730+
}
731+
732+
try {
733+
const raw = await readBody(req);
734+
const parsed = raw.trim() ? JSON.parse(raw) : null;
735+
const addr = normalizeAddress(String(parsed?.address ?? ''), 'address');
736+
737+
const rpcChainId = await tryGetRpcChainId(faucet!.rpcUrl, 1000);
738+
if (rpcChainId === null) {
739+
return sendJson(res, 503, { ok: false, error: `RPC not reachable at ${faucet!.rpcUrl}. Start anvil and retry.` });
740+
}
741+
if (rpcChainId !== faucet!.chainId) {
742+
return sendJson(res, 409, {
743+
ok: false,
744+
error: `RPC chainId mismatch. RPC=${rpcChainId} expected=${faucet!.chainId}.`
745+
});
746+
}
747+
748+
const oldHex = (await rpcRequest(faucet!.rpcUrl, 'eth_getBalance', [addr, 'latest'], 2000)) as string;
749+
const oldWei = BigInt(oldHex);
750+
const targetWei = faucet!.targetWei;
751+
752+
let didSet = false;
753+
if (oldWei < targetWei) {
754+
await rpcRequest(faucet!.rpcUrl, 'anvil_setBalance', [addr, toHexQuantity(targetWei)], 2000);
755+
didSet = true;
756+
}
757+
758+
const newHex = (await rpcRequest(faucet!.rpcUrl, 'eth_getBalance', [addr, 'latest'], 2000)) as string;
759+
const newWei = BigInt(newHex);
760+
761+
return sendJson(res, 200, {
762+
ok: true,
763+
address: addr,
764+
chainId: faucet!.chainId,
765+
targetWei: toHexQuantity(targetWei),
766+
oldBalanceWei: toHexQuantity(oldWei),
767+
newBalanceWei: toHexQuantity(newWei),
768+
didSet
769+
});
770+
} catch (e: any) {
771+
return sendJson(res, 400, { ok: false, error: String(e?.message ?? e) });
772+
}
773+
})();
774+
return;
775+
}
776+
777+
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
778+
res.setHeader('Allow', 'GET, HEAD');
779+
return sendText(res, 405, 'Method Not Allowed');
780+
}
781+
670782
if (!pathname.startsWith('/')) pathname = `/${pathname}`;
671783
const rel = pathname.replace(/^\/+/, '');
672784
const unsafeAbs = path.resolve(rootAbs, rel);
@@ -1378,6 +1490,7 @@ program
13781490
.option('--no-start-anvil', 'Do not start anvil automatically (anvil chain only)')
13791491
.option('--no-deploy', 'Skip deployment (UI will show Not deployed)')
13801492
.option('--no-preview', 'Skip preview server')
1493+
.option('--no-faucet', 'Disable local faucet endpoint in preview server')
13811494
.action(
13821495
async (
13831496
schemaArg: string | undefined,
@@ -1396,6 +1509,7 @@ program
13961509
startAnvil: boolean;
13971510
deploy: boolean;
13981511
preview: boolean;
1512+
faucet: boolean;
13991513
}
14001514
) => {
14011515
let rl: ReadlineInterface | null = null;
@@ -1490,6 +1604,9 @@ program
14901604
} else {
14911605
console.log(` - preview: SKIP`);
14921606
}
1607+
if (opts.preview) {
1608+
console.log(` - faucet: ${opts.faucet && chainName === 'anvil' ? 'ENABLED' : 'SKIP'}`);
1609+
}
14931610
return;
14941611
}
14951612

@@ -1532,7 +1649,21 @@ program
15321649

15331650
if (opts.preview) {
15341651
console.log('');
1535-
const { server, url } = startUiSiteServer({ buildDir: outDir, host, port });
1652+
const faucetEnabled = Boolean(opts.faucet && chainName === 'anvil');
1653+
const faucetTargetWei = 10n * 10n ** 18n;
1654+
const { server, url } = startUiSiteServer({
1655+
buildDir: outDir,
1656+
host,
1657+
port,
1658+
faucet: faucetEnabled
1659+
? {
1660+
enabled: true,
1661+
rpcUrl,
1662+
chainId: chain.id,
1663+
targetWei: faucetTargetWei
1664+
}
1665+
: null
1666+
});
15361667
console.log('');
15371668
console.log(`Ready: ${url}`);
15381669
console.log('Press Ctrl+C to stop.');
@@ -1585,14 +1716,35 @@ program
15851716
.description('Serve the generated static UI locally (no Python required)')
15861717
.option('--port <n>', 'Port to listen on', '3000')
15871718
.option('--host <host>', 'Host to bind (default: 127.0.0.1)', '127.0.0.1')
1719+
.option('--rpc <url>', 'RPC URL override (used for auto-deploy and faucet)')
15881720
.option('--no-deploy', 'Do not auto-deploy when the manifest has a placeholder 0x0 address')
15891721
.option('--no-start-anvil', 'Do not start anvil automatically (anvil chain only)')
1590-
.action(async (buildDir: string, opts: { port: string; host: string; deploy: boolean; startAnvil: boolean }) => {
1722+
.option('--no-faucet', 'Disable local faucet endpoint')
1723+
.action(async (buildDir: string, opts: { port: string; host: string; rpc?: string; deploy: boolean; startAnvil: boolean; faucet: boolean }) => {
15911724
let anvilChild: ReturnType<typeof spawn> | null = null;
15921725
try {
15931726
const resolvedBuildDir = path.resolve(buildDir);
15941727
const manifestPath = path.join(resolvedBuildDir, 'manifest.json');
15951728
const zeroAddress = '0x0000000000000000000000000000000000000000';
1729+
const faucetTargetWei = 10n * 10n ** 18n;
1730+
let faucetConfig: FaucetConfig | null = null;
1731+
1732+
// Enable faucet when previewing an anvil build (chainId 31337) and the user hasn't disabled it.
1733+
if (opts.faucet && fs.existsSync(manifestPath)) {
1734+
try {
1735+
const manifest = readJsonFile(manifestPath) as any;
1736+
const deployments = Array.isArray(manifest?.deployments) ? manifest.deployments : [];
1737+
const d = deployments.find((x: any) => x && x.role === 'primary') ?? deployments[0] ?? null;
1738+
const chainId = Number(d?.chainId ?? NaN);
1739+
if (chainId === anvil.id) {
1740+
const { chainName, chain } = resolveKnownChain('anvil');
1741+
const rpcUrl = resolveRpcUrl(chainName, chain, opts.rpc);
1742+
faucetConfig = { enabled: true, rpcUrl, chainId, targetWei: faucetTargetWei };
1743+
}
1744+
} catch {
1745+
// Ignore manifest parsing issues; serving static UI still works.
1746+
}
1747+
}
15961748

15971749
// If the manifest is still at the placeholder address, auto-deploy on anvil by default.
15981750
if (opts.deploy && fs.existsSync(manifestPath)) {
@@ -1606,19 +1758,19 @@ program
16061758
const chainNameFromId = chainId === anvil.id ? ('anvil' as const) : chainId === sepolia.id ? ('sepolia' as const) : null;
16071759
if (chainNameFromId === 'anvil') {
16081760
const { chainName, chain } = resolveKnownChain('anvil');
1609-
const rpcUrl = resolveRpcUrl(chainName, chain, undefined);
1761+
const rpcUrl = resolveRpcUrl(chainName, chain, opts.rpc);
16101762
console.log(`Manifest is not deployed (0x0). Deploying automatically to ${chainName}...`);
16111763
const ensured = await ensureAnvilRunning(rpcUrl, { start: Boolean(opts.startAnvil), expectedChainId: chain.id });
16121764
anvilChild = ensured.child;
1613-
await deployBuildDir(resolvedBuildDir, { chain: 'anvil', role: 'primary' });
1765+
await deployBuildDir(resolvedBuildDir, { chain: 'anvil', rpc: opts.rpc, role: 'primary' });
16141766
console.log('Auto-deploy complete.');
16151767
console.log('');
16161768
}
16171769
}
16181770
}
16191771

16201772
const port = Number(opts.port);
1621-
const { server } = startUiSiteServer({ buildDir: resolvedBuildDir, host: opts.host, port });
1773+
const { server } = startUiSiteServer({ buildDir: resolvedBuildDir, host: opts.host, port, faucet: faucetConfig });
16221774

16231775
const cleanup = () => {
16241776
try {

packages/templates/next-export-ui/app/layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import './globals.css';
33
import React from 'react';
44

55
import ConnectButton from '../src/components/ConnectButton';
6+
import FaucetButton from '../src/components/FaucetButton';
67
import { ths } from '../src/lib/ths';
78

89
export const metadata = {
@@ -20,7 +21,10 @@ export default function RootLayout(props: { children: React.ReactNode }) {
2021
<h1>{ths.app.name}</h1>
2122
<span className="badge">{ths.schemaVersion}</span>
2223
</div>
23-
<ConnectButton />
24+
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
25+
<FaucetButton />
26+
<ConnectButton />
27+
</div>
2428
</div>
2529
{props.children}
2630
</div>

0 commit comments

Comments
 (0)