import { Hono, type Context } from 'hono' import * as z from 'zod/mini' import * as Auth from '../../../internal/Auth.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as RateLimit from '../../../internal/RateLimit.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Value from '../../../internal/Value.js' import * as Viem from '../../../internal/Viem.js' import type { Environment } from '../App.js' const addressRateLimit = { limit: 1, period: 'minute' } satisfies RateLimit.Limit const userRateLimit = { limit: 3, period: 'minute' } satisfies RateLimit.Limit /** OpenAPI schemas owned by the sandbox faucet. */ export namespace schema { const Token = OpenApi.component( Schema.describe( z.object({ address: Schema.TokenAddress.check( z.describe('The test token contract that minted funds.'), ), currency: z .string() .check(z.describe('The token’s currency label.'), z.meta({ examples: ['USD'] })), decimals: z .number() .check( z.int(), z.nonnegative(), z.describe('The decimal places used by the token.'), z.meta({ examples: [6] }), ), name: z .string() .check( z.describe('The token’s human-readable name.'), z.meta({ examples: ['Test USD'] }), ), symbol: z.string().check(z.describe('The token’s symbol.'), z.meta({ examples: ['TUSD'] })), }), 'A test token minted by the faucet.', ), 'FaucetToken', ) const FundedToken = OpenApi.component( Schema.describe( z.object({ amount: Schema.TokenAmount.check( z.describe('Amount minted by the faucet.'), z.meta({ examples: [ { baseUnits: '1000000000', currency: 'USD', decimals: 6, formatted: '1000' }, ], }), ), token: Token, transactionHash: Schema.Hash.check(z.describe('The transaction that minted this token.')), }), 'One token amount minted by the faucet.', ), 'FaucetFundedToken', ) /** Faucet request body. */ export const FundBody = OpenApi.component( Schema.describe(z.object({ address: Schema.Address }), 'A Tempo testnet account to fund.'), 'FundTestnetAccountRequest', ) /** Faucet response. */ export const FundResponse = OpenApi.component( Schema.describe( z.object({ tokens: z .array(FundedToken) .check( z.describe('The configured test tokens minted to the account.'), z.meta({ examples: [[]] }), ), transactionHashes: z .array(Schema.Hash) .check( z.describe('Transactions that minted the configured test tokens.'), z.meta({ examples: [[]] }), ), }), 'Test tokens minted while funding the account.', ), 'FundTestnetAccountResponse', ) /** Faucet path parameters. */ export const Params = Schema.describe( z.object({ orgId: z .string() .check( z.describe('The organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }), 'Path parameters for the organization faucet.', ) } const bodyValidation = { code: 'body_invalid', message: 'Enter a valid Tempo address.', } as const const paramValidation = { code: 'param_invalid', message: 'Check the path parameters and try again.', } as const /** Mounts the session-authenticated Tempo testnet faucet. */ export function faucet() { return new Hono().post( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/faucet', Auth.policy({ session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.Params, paramValidation), OpenApi.validate('json', schema.FundBody, bodyValidation), OpenApi.describeRoute({ description: 'Funds an account with the test tokens configured on Tempo testnet. This operation is available to signed-in organization members and is rate limited.', operationId: 'fundTestnetAccount', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid', 'param_invalid'], description: 'The path or account address is invalid.', }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found.', }, 429: { codes: ['rate_limit_exceeded'], description: 'The user or account faucet limit was exceeded.', }, }, success: { description: 'The account was funded.', schema: schema.FundResponse, }, }), summary: 'Fund testnet account', tags: ['Faucet'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) if (OpenApi.narrowValidation) return OpenApi.validationError(c, bodyValidation) const { address } = c.req.valid('json') const limited = await consumeRateLimit(c, address) if (limited) return rateLimitError(c, limited) try { const client = c.get('getClient')(Viem.chainId.testnet) const receipts = await client.faucet.fundSync({ account: address, timeout: 30_000 }) const tokens = await Promise.all( receipts.map(async (receipt) => { const event = client.token.mint.extractEvent(receipt.logs) const metadata = await client.token.getMetadata({ token: event.address }) return { amount: Value.tokenAmount({ baseUnits: event.args.amount, currency: metadata.currency, decimals: metadata.decimals, }), token: { address: event.address, currency: metadata.currency, decimals: metadata.decimals, name: metadata.name, symbol: metadata.symbol, }, transactionHash: receipt.transactionHash, } }), ) const transactionHashes = tokens.map((entry) => entry.transactionHash) return c.json(Response.validated(schema.FundResponse, { tokens, transactionHashes }), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) } async function consumeRateLimit(c: Context, address: string) { const principal = Auth.getPrincipal(c) const store = c.get('auth').rateLimit if (!principal || !store) return undefined const user = await store.consume({ key: `faucet:user:${principal.id}`, limit: userRateLimit, }) if (!user.allowed) return { result: user, scope: 'faucet-user' } const account = await store.consume({ key: `faucet:address:${address}`, limit: addressRateLimit, }) if (!account.allowed) return { result: account, scope: 'faucet-address' } return undefined } function rateLimitError( c: Context, limited: { /** Denied quota result. */ result: RateLimit.Result /** Quota dimension that denied the request. */ scope: string }, ) { const { result, scope } = limited c.header('RateLimit-Limit', String(result.limit)) c.header('RateLimit-Remaining', String(result.remaining)) c.header('RateLimit-Reset', String(result.reset)) c.header('RateLimit-Scope', scope) c.header('Retry-After', String(Math.max(result.reset - Math.ceil(Date.now() / 1_000), 1))) return Response.error(c, { code: 'rate_limit_exceeded', message: 'This account was funded recently. Try again after the cooldown.', status: 429, }) }