@@ -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 {
0 commit comments