Skip to content
Closed
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This is a **TypeScript monorepo** for applications on the Tempo blockchain appli
| `apps/tokenlist` | Token registry API |
| `apps/contract-verification` | Smart contract verification |
| `apps/og` | OpenGraph image generation |
| `apps/tempo-admin` | Internal admin tools (mainnet faucet with Okta auth) |

## Commands

Expand Down
4 changes: 4 additions & 0 deletions apps/tempo-admin/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
OKTA_ISSUER=https://your-org.okta.com/oauth2/default
OKTA_CLIENT_ID=your-okta-client-id
FAUCET_PRIVATE_KEY=0x...
TEMPO_RPC_URL=https://rpc.tempo.xyz
18 changes: 18 additions & 0 deletions apps/tempo-admin/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
interface EnvironmentVariables {
readonly OKTA_ISSUER: string
readonly OKTA_CLIENT_ID: string
readonly FAUCET_PRIVATE_KEY: string
readonly TEMPO_RPC_URL: string
}

interface ImportMetaEnv extends EnvironmentVariables {}

interface ImportMeta {
readonly env: ImportMetaEnv
}

declare namespace NodeJS {
interface ProcessEnv extends EnvironmentVariables {
readonly NODE_ENV: 'development' | 'production' | 'test'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS faucet_dispensations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
recipient TEXT NOT NULL,
amount TEXT NOT NULL,
purpose TEXT NOT NULL,
tx_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX idx_dispensations_email ON faucet_dispensations(email);
CREATE INDEX idx_dispensations_created_at ON faucet_dispensations(created_at);
CREATE INDEX idx_dispensations_status ON faucet_dispensations(status);
30 changes: 30 additions & 0 deletions apps/tempo-admin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "tempo-admin",
"private": true,
"scripts": {
"build": "echo 'noop'",
"check": "pnpm check:biome && pnpm check:types",
"check:biome": "biome check --write .",
"check:types": "tsgo --project tsconfig.json --noEmit",
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"format": "biome format --write .",
"gen:types": "test -f .env || cp .env.example .env; CLOUDFLARE_ENV= wrangler types",
"test": "vitest --run"
},
"dependencies": {
"@hono/zod-validator": "catalog:",
"hono": "catalog:",
"ox": "catalog:",
"viem": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "catalog:",
"@cloudflare/workers-types": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"wrangler": "catalog:"
}
}
126 changes: 126 additions & 0 deletions apps/tempo-admin/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import type { Address } from 'viem'
import { isAddress } from 'viem'
import * as z from 'zod'
import {
confirmDispensation,
createPendingDispensation,
failDispensation,
getDailyTotal,
getDispensations,
} from './lib/db.js'
import { dispenseFunds, getFaucetBalance } from './lib/faucet.js'
import type { OktaUser } from './lib/okta.js'
import { oktaAuth } from './lib/okta.js'

const MAX_DISPENSE_AMOUNT = 10
const DAILY_LIMIT = 50

type AppEnv = {
Variables: { user: OktaUser }
}

const app = new Hono<AppEnv>()

app.use(
'*',
cors({
origin: ['https://admin.tempo.xyz'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400,
}),
)

app.use('/api/*', oktaAuth)

app.get('/health', (c) => c.json({ status: 'ok' }))

const amountRegex = /^\d+(\.\d{1,18})?$/

app.post(
'/api/faucet/dispense',
zValidator(
'json',
z.object({
recipient: z.string().refine((v) => isAddress(v), 'Invalid address'),
amount: z
.string()
.refine((v) => amountRegex.test(v), 'Invalid amount format')
.refine(
(v) => Number(v) > 0 && Number(v) <= MAX_DISPENSE_AMOUNT,
`Amount must be between 0 and ${MAX_DISPENSE_AMOUNT}`,
),
purpose: z
.string()
.min(1, 'Purpose is required')
.max(500, 'Purpose must be 500 characters or less'),
}),
),
async (c) => {
const { recipient, amount, purpose } = c.req.valid('json')
const user = c.get('user')

const dailyTotal = await getDailyTotal(user.email)
if (dailyTotal + Number(amount) > DAILY_LIMIT) {
return c.json(
{
error: `Daily limit of ${DAILY_LIMIT} TEMPO exceeded. Used today: ${dailyTotal}`,
},
429,
)
}

const record = await createPendingDispensation({
email: user.email,
recipient,
amount,
purpose,
})

try {
const txHash = await dispenseFunds({
recipient: recipient as Address,
amount,
})

await confirmDispensation(record.id, txHash)

return c.json({ ...record, tx_hash: txHash, status: 'confirmed' }, 201)
} catch (err) {
const message = err instanceof Error ? err.message : 'Transaction failed'
await failDispensation(record.id, message)
return c.json({ error: message }, 500)
}
},
)

app.get(
'/api/faucet/history',
zValidator(
'query',
z.object({
limit: z.optional(z.coerce.number().int().min(1).max(100)),
offset: z.optional(z.coerce.number().int().min(0)),
}),
),
async (c) => {
const { limit, offset } = c.req.valid('query')
const user = c.get('user')
const dispensations = await getDispensations({
email: user.email,
limit,
offset,
})
return c.json(dispensations)
},
)

app.get('/api/faucet/balance', async (c) => {
const balance = await getFaucetBalance()
return c.json({ balance })
})

export default app
91 changes: 91 additions & 0 deletions apps/tempo-admin/src/lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { env } from 'cloudflare:workers'

export type Dispensation = {
id: number
email: string
recipient: string
amount: string
purpose: string
tx_hash: string | null
status: 'pending' | 'confirmed' | 'failed'
error: string | null
created_at: string
}

export async function createPendingDispensation(params: {
email: string
recipient: string
amount: string
purpose: string
}): Promise<Dispensation> {
const result = await env.DB.prepare(
"INSERT INTO faucet_dispensations (email, recipient, amount, purpose, status) VALUES (?, ?, ?, ?, 'pending') RETURNING *",
)
.bind(params.email, params.recipient, params.amount, params.purpose)
.first<Dispensation>()

if (!result) {
throw new Error('Failed to create dispensation record')
}

return result
}

export async function confirmDispensation(
id: number,
txHash: string,
): Promise<void> {
await env.DB.prepare(
"UPDATE faucet_dispensations SET tx_hash = ?, status = 'confirmed' WHERE id = ?",
)
.bind(txHash, id)
.run()
}

export async function failDispensation(
id: number,
error: string,
): Promise<void> {
await env.DB.prepare(
"UPDATE faucet_dispensations SET status = 'failed', error = ? WHERE id = ?",
)
.bind(error, id)
.run()
}

export async function getDailyTotal(email: string): Promise<number> {
const result = await env.DB.prepare(
"SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) as total FROM faucet_dispensations WHERE email = ? AND status = 'confirmed' AND created_at >= datetime('now', '-1 day')",
)
.bind(email)
.first<{ total: number }>()

return result?.total ?? 0
}

export async function getDispensations(params?: {
email?: string | undefined
limit?: number | undefined
offset?: number | undefined
}): Promise<Dispensation[]> {
const limit = params?.limit ?? 50
const offset = params?.offset ?? 0

if (params?.email) {
const { results } = await env.DB.prepare(
'SELECT * FROM faucet_dispensations WHERE email = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
)
.bind(params.email, limit, offset)
.all<Dispensation>()

return results
}

const { results } = await env.DB.prepare(
'SELECT * FROM faucet_dispensations ORDER BY created_at DESC LIMIT ? OFFSET ?',
)
.bind(limit, offset)
.all<Dispensation>()

return results
}
43 changes: 43 additions & 0 deletions apps/tempo-admin/src/lib/faucet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { env } from 'cloudflare:workers'
import {
type Address,
createWalletClient,
http,
parseEther,
publicActions,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { tempo } from 'viem/chains'

export async function dispenseFunds(params: {
recipient: Address
amount: string
}): Promise<string> {
const account = privateKeyToAccount(env.FAUCET_PRIVATE_KEY as `0x${string}`)

const client = createWalletClient({
account,
chain: tempo,
transport: http(env.TEMPO_RPC_URL),
}).extend(publicActions)

const hash = await client.sendTransaction({
to: params.recipient,
value: parseEther(params.amount),
})

return hash
}

export async function getFaucetBalance(): Promise<string> {
const account = privateKeyToAccount(env.FAUCET_PRIVATE_KEY as `0x${string}`)

const client = createWalletClient({
account,
chain: tempo,
transport: http(env.TEMPO_RPC_URL),
}).extend(publicActions)

const balance = await client.getBalance({ address: account.address })
return balance.toString()
}
Loading
Loading