@leofcoin/faucet 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # Peach testnet faucet
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The faucet transfers existing test LFC from a dedicated wallet. It cannot mint,
4
+ does not run a validator, and refuses any network except `leofcoin:peach`.
5
+ Default limits: 10 LFC per claim, one claim per address and IP per 24 hours,
6
+ and 1000 LFC per rolling day. Multiple IPs cannot exceed the global budget.
7
+
8
+ Claims and limits survive restart. Reservations are persisted before broadcast.
9
+ Unknown outcomes remain reserved for operator reconciliation and are never
10
+ automatically resent. Confirmation comes only from the canonical transaction
11
+ completion handle. Do not delete `claims.json` to reset limits.
12
+ After a restart, pending claims require operator reconciliation against canonical
13
+ chain history; they are not automatically confirmed or rebroadcast.
14
+ Only one process may use a faucet directory. An exclusive `faucet.lock` enforces
15
+ this; after an unclean shutdown, remove that lock only after verifying the old
16
+ process is no longer running. Never share the directory across machines.
17
+
18
+ ## Run locally
19
+
20
+ After the package and its updated runtime dependencies are published to npm,
21
+ no monorepo checkout is needed:
22
+
23
+ ```sh
24
+ FAUCET_DATA_DIR=/absolute/private/faucet-directory npx --yes --package @leofcoin/faucet leofcoin-faucet
25
+ ```
26
+
27
+ This starts a persistent signing service, not a validator. Its data directory
28
+ must remain private and outside the package installation directory. For a
29
+ production service, install and pin a reviewed package version rather than
30
+ downloading a changing version at every boot. Deployment templates are included
31
+ in the package's `deploy` directory.
32
+
33
+ Requires Node >=24.15.0. From the monorepo root:
34
+
35
+ ```sh
36
+ npm ci
37
+ npm run build
38
+ FAUCET_DATA_DIR=/absolute/private/faucet-directory npm start -w @leofcoin/faucet
39
+ ```
40
+
41
+ The service generates a new password file (mode 600) only if none exists and
42
+ keeps its identity/state in this separate directory. Back up the complete
43
+ directory securely. Startup prints the public faucet address and password-file
44
+ path, not its password or private key. Fund the public address from the existing
45
+ Peach genesis owner. Never use the genesis owner's identity as the faucet.
46
+
47
+ The genesis validator remains on the owner's local node. With the existing
48
+ genesis identity already installed there, start it using:
49
+
50
+ ```sh
51
+ LEOFCOIN_PASSWORD_FILE=/absolute/path/genesis-credentials/leofcoin-peach/genesis-password.txt npm run validator
52
+ ```
53
+
54
+ In its interactive shell, fund the faucet (replace ADDRESS):
55
+
56
+ ```text
57
+ transfer ADDRESS 1000
58
+ ```
59
+
60
+ Wait for canonical confirmation. Keep at least one eligible validator online;
61
+ additional validators join using the existing launcher and funded accounts.
62
+ Do not regenerate genesis to fund the faucet.
63
+
64
+ ## API
65
+
66
+ - `GET /status`: network, readiness, public account and claim amount.
67
+ - `POST /claims` with JSON `{ "address": "...", "requestId": "UUID" }`: 202 and a claim ID/hash.
68
+ Generate and retain the UUID before sending. Repeating the same request ID
69
+ returns its original receipt without another payment. A lost HTTP response
70
+ is not proof of failure: query its receipt or retry with the same UUID.
71
+ - `GET /claims/ID`: reserved, pending, confirmed or failed. Public responses
72
+ never contain IP addresses or identity material.
73
+
74
+ Use `npm test -w @leofcoin/faucet` for durable reservation, concurrency,
75
+ rate/budget, restart and HTTP tests.
76
+
77
+ ## Remote installation
78
+
79
+ Install the reviewed repository at `/opt/leofcoin-monorepo` and use a system
80
+ Node runtime >=24.15.0. Build dependencies before starting the service. Copy
81
+ `deploy/faucet.env.example` to `/etc/leofcoin-faucet.env` and
82
+ `deploy/leofcoin-faucet.service` to `/etc/systemd/system/`. Set `ExecStart` to
83
+ the actual absolute Node binary if it differs from `/usr/bin/node`.
84
+
85
+ ```sh
86
+ useradd --system --user-group --home-dir /var/lib/leofcoin-faucet --shell /usr/sbin/nologin leofcoin-faucet
87
+ systemctl daemon-reload
88
+ systemctl enable --now leofcoin-faucet
89
+ journalctl -u leofcoin-faucet -f
90
+ ```
91
+
92
+ Add `deploy/nginx-location.conf` inside the existing HTTPS server for
93
+ `remote.leofcoin.org`, then validate/reload Nginx. It exposes
94
+ `https://remote.leofcoin.org/faucet` while the signing service binds only to
95
+ loopback. `FAUCET_TRUST_PROXY=1` accepts `X-Real-IP` only from a loopback proxy;
96
+ the supplied Nginx config overwrites that header. Do not expose port 8088.
97
+
98
+ The owner funds the address printed by the remote service. Then verify
99
+ `/faucet/status` reports Peach and `ready: true`, request a small claim from a
100
+ test wallet, wait for confirmed status and verify the receiving node's balance.
101
+ The scheduled public observer workflow keeps checking two additional temporary
102
+ nodes. GitHub runners are observers, not permanent validator infrastructure.
@@ -0,0 +1,7 @@
1
+ FAUCET_NETWORK=leofcoin:peach
2
+ FAUCET_DATA_DIR=/var/lib/leofcoin-faucet
3
+ FAUCET_PORT=8088
4
+ FAUCET_AMOUNT=10
5
+ FAUCET_DAILY_BUDGET=1000
6
+ FAUCET_ORIGINS=https://leofcoin.org,https://leofcoin.github.io
7
+ FAUCET_TRUST_PROXY=1
@@ -0,0 +1,26 @@
1
+ [Unit]
2
+ Description=Leofcoin Peach testnet faucet
3
+ Wants=network-online.target
4
+ After=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ User=leofcoin-faucet
9
+ Group=leofcoin-faucet
10
+ WorkingDirectory=/opt/leofcoin-monorepo
11
+ EnvironmentFile=/etc/leofcoin-faucet.env
12
+ ExecStart=/usr/bin/node /opt/leofcoin-monorepo/packages/faucet/src/cli.js
13
+ Restart=on-failure
14
+ RestartSec=10
15
+ TimeoutStopSec=30
16
+ UMask=0077
17
+ NoNewPrivileges=true
18
+ ProtectSystem=strict
19
+ ProtectHome=true
20
+ PrivateTmp=true
21
+ StateDirectory=leofcoin-faucet
22
+ StateDirectoryMode=0700
23
+ ReadWritePaths=/var/lib/leofcoin-faucet
24
+
25
+ [Install]
26
+ WantedBy=multi-user.target
@@ -0,0 +1,9 @@
1
+ # Include inside the existing HTTPS server{} for remote.leofcoin.org.
2
+ location /faucet/ {
3
+ client_max_body_size 2k;
4
+ proxy_pass http://127.0.0.1:8088/;
5
+ proxy_set_header Host $host;
6
+ # Overwrite rather than forward a client-controlled value.
7
+ proxy_set_header X-Real-IP $remote_addr;
8
+ proxy_read_timeout 30s;
9
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@leofcoin/faucet",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Operator-funded faucet for the Leofcoin Peach testnet",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/leofcoin/monorepo.git",
9
+ "directory": "packages/faucet"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "bin": {
15
+ "leofcoin-faucet": "./src/cli.js"
16
+ },
17
+ "files": [
18
+ "src/cli.js",
19
+ "src/server.js",
20
+ "src/service.js",
21
+ "deploy",
22
+ "README.md"
23
+ ],
24
+ "type": "module",
25
+ "engines": {
26
+ "node": ">=24.15.0"
27
+ },
28
+ "scripts": {
29
+ "start": "node src/cli.js",
30
+ "test": "node --test test/*.test.js"
31
+ },
32
+ "dependencies": {
33
+ "@leofcoin/binary": "^1.1.0",
34
+ "@leofcoin/launch-chain": "^0.5.0",
35
+ "@leofcoin/lib": "^1.4.0",
36
+ "@leofcoin/multi-wallet": "^3.1.9",
37
+ "@leofcoin/utils": "^1.1.45",
38
+ "@vandeurenglenn/wrtc": "^0.7.2"
39
+ }
40
+ }
package/src/cli.js ADDED
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { unlinkSync } from 'node:fs'
4
+ import { randomBytes } from 'node:crypto'
5
+ import { resolve, join, relative } from 'node:path'
6
+ import { homedir } from 'node:os'
7
+ import MultiWallet from '@leofcoin/multi-wallet'
8
+ import { parseUnits } from '@leofcoin/utils'
9
+ import { signTransaction, calculateFee } from '@leofcoin/lib'
10
+ import { startNode } from '@leofcoin/launch-chain/node'
11
+ import { Faucet, FaucetError } from './service.js'
12
+ import { faucetServer } from './server.js'
13
+
14
+ const network = process.env.FAUCET_NETWORK || 'leofcoin:peach'
15
+ if (network !== 'leofcoin:peach') throw new Error('The faucet is restricted to the Peach testnet.')
16
+ const directory = resolve(process.env.FAUCET_DATA_DIR || join(homedir(), '.leofcoin', 'peach-faucet'))
17
+ await mkdir(directory, { recursive: true, mode: 0o700 })
18
+ const lockPath = join(directory, 'faucet.lock')
19
+ try {
20
+ await writeFile(lockPath, JSON.stringify({ pid: process.pid }), { mode: 0o600, flag: 'wx' })
21
+ } catch (error) {
22
+ if (error.code === 'EEXIST')
23
+ throw new Error(
24
+ `Faucet directory is locked: ${lockPath}. Stop the existing process before starting another. After an unclean shutdown, verify no faucet process is running before removing this lock.`
25
+ )
26
+ throw error
27
+ }
28
+ process.once('exit', () => {
29
+ try {
30
+ unlinkSync(lockPath)
31
+ } catch {}
32
+ })
33
+ const passwordPath = join(directory, 'password.txt')
34
+ try {
35
+ await writeFile(passwordPath, randomBytes(32).toString('base64url'), { mode: 0o600, flag: 'wx' })
36
+ } catch (error) {
37
+ if (error.code !== 'EEXIST') throw error
38
+ }
39
+ const password = (await readFile(passwordPath, 'utf8')).trim()
40
+ if (!password) throw new Error('Faucet password file is empty')
41
+ globalThis.wrtc = await import('@vandeurenglenn/wrtc')
42
+ const node = await startNode({
43
+ network,
44
+ networkVersion: 'peach',
45
+ root: relative(homedir(), join(directory, 'node')),
46
+ password,
47
+ httpPort: false,
48
+ wsPort: false
49
+ })
50
+ const accountPath = join(directory, 'account.json')
51
+ try {
52
+ const previous = JSON.parse(await readFile(accountPath, 'utf8'))
53
+ if (previous.address !== node.account)
54
+ throw new Error('Faucet identity changed. Refusing startup; restore the original identity.')
55
+ } catch (error) {
56
+ if (error.code !== 'ENOENT') throw error
57
+ await writeFile(accountPath, JSON.stringify({ network, address: node.account }), { mode: 0o600, flag: 'wx' })
58
+ }
59
+ const backupPath = join(directory, 'identity.backup')
60
+ try {
61
+ await writeFile(backupPath, await globalThis.peernet.identity.export(password), { mode: 0o600, flag: 'wx' })
62
+ } catch (error) {
63
+ if (error.code !== 'EEXIST') throw error
64
+ }
65
+ const amount = parseUnits(process.env.FAUCET_AMOUNT || '10')
66
+ const dailyBudget = parseUnits(process.env.FAUCET_DAILY_BUDGET || '1000')
67
+ const faucet = await new Faucet({
68
+ ledger: join(directory, 'claims.json'),
69
+ amount,
70
+ dailyBudget,
71
+ adapter: {
72
+ validateAddress: async (address) => {
73
+ try {
74
+ await new MultiWallet(network).fromAddress(address, null, network)
75
+ } catch {
76
+ throw new FaucetError(400, 'Invalid Peach recipient address')
77
+ }
78
+ if (address === node.account) throw new FaucetError(400, 'Cannot claim to the faucet account')
79
+ },
80
+ health: async () => {
81
+ const balance = BigInt((await node.chain.balanceOf(node.account)) || 0n)
82
+ const connected = Object.values(globalThis.peernet.connections || {}).some((peer) => peer.connected)
83
+ return {
84
+ account: node.account,
85
+ balance,
86
+ ready: connected && node.chain.state.sync === 'synced' && balance > amount
87
+ }
88
+ },
89
+ quote: async (to, amount) => {
90
+ const raw = await node.chain.createTransaction({
91
+ from: node.account,
92
+ to: node.chain.nativeToken,
93
+ method: 'transfer',
94
+ params: [to, amount.toString()]
95
+ })
96
+ const signed = await signTransaction(raw, globalThis.peernet.identity)
97
+ const fee = BigInt(await calculateFee(signed))
98
+ if (BigInt((await node.chain.balanceOf(node.account)) || 0n) < amount + fee)
99
+ throw new Error('Faucet balance cannot cover amount and fee')
100
+ return { signed, fee }
101
+ },
102
+ submit: async (_to, _amount, quote) => node.chain.sendTransaction(quote.signed),
103
+ reportError: (error) => console.error('Faucet claim needs operator attention:', error.message)
104
+ }
105
+ }).init()
106
+ const origins = (process.env.FAUCET_ORIGINS || 'https://leofcoin.org,https://leofcoin.github.io')
107
+ .split(',')
108
+ .map((value) => value.trim())
109
+ .filter(Boolean)
110
+ const port = Number(process.env.FAUCET_PORT || 8088)
111
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error('Invalid faucet port')
112
+ const server = faucetServer(faucet, { origins, trustProxy: process.env.FAUCET_TRUST_PROXY === '1' })
113
+ server.listen(port, '127.0.0.1', () => {
114
+ console.log(`Peach faucet account: ${node.account}`)
115
+ console.log(
116
+ `Password file: ${passwordPath}; encrypted identity backup: ${backupPath}. Back up the complete ${directory} directory securely.`
117
+ )
118
+ console.log(`Faucet listening on 127.0.0.1:${port}; fund this account from the testnet owner before enabling claims.`)
119
+ })
120
+ for (const signal of ['SIGTERM', 'SIGINT'])
121
+ process.once(signal, async () => {
122
+ server.close()
123
+ await faucet.flush()
124
+ node.stop()
125
+ process.exit(0)
126
+ })
package/src/server.js ADDED
@@ -0,0 +1,84 @@
1
+ import { encodeWire, decodeWire, BINARY_CONTENT_TYPE } from '@leofcoin/binary'
2
+ import { createServer } from 'node:http'
3
+ import { isIP } from 'node:net'
4
+ import { FaucetError } from './service.js'
5
+
6
+ export function faucetServer(faucet, { origins = [], trustProxy = false } = {}) {
7
+ const allowed = new Set(origins)
8
+ const clients = new Map()
9
+ return createServer(async (request, response) => {
10
+ const send = (status, value) => {
11
+ response.writeHead(status, { 'Content-Type': BINARY_CONTENT_TYPE, 'Cache-Control': 'no-store' })
12
+ response.end(encodeWire('rpc', value))
13
+ }
14
+ try {
15
+ const origin = request.headers.origin
16
+ if (origin && !allowed.has(origin)) throw new FaucetError(403, 'Origin is not allowed')
17
+ if (origin) {
18
+ response.setHeader('Access-Control-Allow-Origin', origin)
19
+ response.setHeader('Vary', 'Origin')
20
+ }
21
+ response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
22
+ response.setHeader('Access-Control-Allow-Headers', 'Content-Type')
23
+ const socketIp = request.socket.remoteAddress
24
+ const forwarded =
25
+ trustProxy && ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(socketIp)
26
+ ? request.headers['x-real-ip']
27
+ : undefined
28
+ const ip = typeof forwarded === 'string' && isIP(forwarded) ? forwarded : socketIp.replace(/^::ffff:/, '')
29
+ const now = Date.now()
30
+ for (const [key, value] of clients) if (value.until < now) clients.delete(key)
31
+ const rate = clients.get(ip) || { count: 0, until: now + 60000 }
32
+ if (++rate.count > 60 || clients.size > 10000) throw new FaucetError(429, 'Too many requests', 60)
33
+ clients.set(ip, rate)
34
+ if (request.method === 'OPTIONS') {
35
+ response.writeHead(204)
36
+ response.end()
37
+ return
38
+ }
39
+ const path = new URL(request.url, 'http://faucet').pathname
40
+ if (request.method === 'GET' && path === '/status') {
41
+ const status = await faucet.status()
42
+ send(200, status)
43
+ return
44
+ }
45
+ if (request.method === 'GET' && /^\/claims\/[a-f0-9-]{36}$/.test(path)) {
46
+ send(200, faucet.get(path.slice(8)))
47
+ return
48
+ }
49
+ if (request.method !== 'POST' || path !== '/claims') throw new FaucetError(404, 'Not found')
50
+ if (request.headers['content-type']?.split(';')[0] !== BINARY_CONTENT_TYPE)
51
+ throw new FaucetError(415, 'Use application/octet-stream')
52
+ const chunks = []
53
+ let size = 0
54
+ for await (const chunk of request) {
55
+ size += chunk.length
56
+ if (size > 2048) throw new FaucetError(413, 'Request too large')
57
+ chunks.push(chunk)
58
+ }
59
+ let value
60
+ try {
61
+ value = decodeWire('rpc', Buffer.concat(chunks), { maxBytes: 2048 })
62
+ } catch {
63
+ throw new FaucetError(400, 'Invalid binary request')
64
+ }
65
+ if (!value || typeof value.address !== 'string' || value.address.length > 100)
66
+ throw new FaucetError(400, 'Recipient address required')
67
+ if (
68
+ value.requestId !== undefined &&
69
+ (typeof value.requestId !== 'string' ||
70
+ !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value.requestId))
71
+ )
72
+ throw new FaucetError(400, 'Invalid request ID')
73
+ const claim = await faucet.claim(value.address.trim(), ip, value.requestId)
74
+ send(202, claim)
75
+ } catch (error) {
76
+ if (error.retryAfter) response.setHeader('Retry-After', String(error.retryAfter))
77
+ send(error.status || 500, {
78
+ error: error.status ? error.message : 'Faucet request failed',
79
+ accepted: Boolean(error.claimId),
80
+ ...(error.claimId ? { claimId: error.claimId } : {})
81
+ })
82
+ }
83
+ })
84
+ }
package/src/service.js ADDED
@@ -0,0 +1,186 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, readFile, open, rename } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+ import { formatUnits } from '@leofcoin/utils'
5
+
6
+ export class FaucetError extends Error {
7
+ constructor(status, message, retryAfter = 0) {
8
+ super(message)
9
+ this.status = status
10
+ this.retryAfter = retryAfter
11
+ }
12
+ }
13
+
14
+ export class Faucet {
15
+ #records = []
16
+ #queue = Promise.resolve()
17
+ constructor({ ledger, amount, dailyBudget, cooldownMs = 86400000, adapter, now = Date.now }) {
18
+ if (amount <= 0n || dailyBudget < amount || !Number.isSafeInteger(cooldownMs) || cooldownMs < 60000)
19
+ throw new Error('Invalid faucet limits')
20
+ Object.assign(this, { ledger, amount, dailyBudget, cooldownMs, adapter, now })
21
+ }
22
+ async init() {
23
+ await mkdir(dirname(this.ledger), { recursive: true, mode: 0o700 })
24
+ try {
25
+ const data = JSON.parse(await readFile(this.ledger, 'utf8'))
26
+ if (
27
+ data.version !== 1 ||
28
+ !Array.isArray(data.claims) ||
29
+ data.claims.some(
30
+ (claim) =>
31
+ typeof claim.id !== 'string' ||
32
+ typeof claim.address !== 'string' ||
33
+ typeof claim.ip !== 'string' ||
34
+ !Number.isSafeInteger(claim.createdAt) ||
35
+ !['reserved', 'pending', 'confirmed', 'failed'].includes(claim.state) ||
36
+ !/^\d+$/.test(claim.amount) ||
37
+ BigInt(claim.amount) <= 0n ||
38
+ (claim.fee !== undefined && !/^\d+$/.test(claim.fee))
39
+ )
40
+ )
41
+ throw new Error('Invalid faucet ledger')
42
+ this.#records = data.claims
43
+ } catch (error) {
44
+ if (error.code !== 'ENOENT') throw error
45
+ }
46
+ return this
47
+ }
48
+ async #persist() {
49
+ const temporary = `${this.ledger}.tmp`
50
+ const file = await open(temporary, 'w', 0o600)
51
+ try {
52
+ await file.writeFile(JSON.stringify({ version: 1, claims: this.#records }))
53
+ await file.sync()
54
+ } finally {
55
+ await file.close()
56
+ }
57
+ await rename(temporary, this.ledger)
58
+ const directory = await open(dirname(this.ledger), 'r')
59
+ try {
60
+ await directory.sync()
61
+ } finally {
62
+ await directory.close()
63
+ }
64
+ }
65
+ #serialized(task) {
66
+ const next = this.#queue.then(task)
67
+ this.#queue = next.catch(() => {})
68
+ return next
69
+ }
70
+ async flush() {
71
+ await this.#queue
72
+ }
73
+ publicClaim(claim) {
74
+ if (!claim) throw new FaucetError(404, 'Claim not found')
75
+ return {
76
+ id: claim.id,
77
+ address: claim.address,
78
+ amount: formatUnits(BigInt(claim.amount)),
79
+ state: claim.state,
80
+ hash: claim.hash || null,
81
+ createdAt: claim.createdAt,
82
+ nextClaimAt: claim.createdAt + this.cooldownMs
83
+ }
84
+ }
85
+ get(id) {
86
+ return this.publicClaim(this.#records.find((record) => record.id === id))
87
+ }
88
+ async status() {
89
+ const health = await this.adapter.health()
90
+ return {
91
+ network: 'leofcoin:peach',
92
+ testnet: true,
93
+ amount: formatUnits(this.amount),
94
+ cooldownMs: this.cooldownMs,
95
+ ready: health.ready,
96
+ account: health.account,
97
+ balance: formatUnits(BigInt(health.balance || 0n))
98
+ }
99
+ }
100
+ claim(address, ip, requestId) {
101
+ return this.#serialized(async () => {
102
+ await this.adapter.validateAddress(address)
103
+ if (requestId) {
104
+ const existing = this.#records.find((record) => record.id === requestId)
105
+ if (existing) {
106
+ if (existing.address !== address) throw new FaucetError(409, 'Claim ID belongs to a different recipient')
107
+ return this.publicClaim(existing)
108
+ }
109
+ }
110
+ const health = await this.adapter.health()
111
+ if (!health.ready)
112
+ throw new FaucetError(503, 'Testnet faucet is not ready. It needs a synced node, peers and funds.')
113
+ const now = this.now()
114
+ const previous = this.#records
115
+ .filter((record) => record.address === address || record.ip === ip)
116
+ .sort((a, b) => b.createdAt - a.createdAt)[0]
117
+ if (previous && now < previous.createdAt + this.cooldownMs)
118
+ throw new FaucetError(
119
+ 429,
120
+ 'You can request test LFC once per day per address and IP.',
121
+ Math.ceil((previous.createdAt + this.cooldownMs - now) / 1000)
122
+ )
123
+ const spent = this.#records
124
+ .filter((record) => record.createdAt > now - 86400000 && record.state !== 'failed')
125
+ .reduce((total, record) => total + BigInt(record.amount), 0n)
126
+ if (spent + this.amount > this.dailyBudget)
127
+ throw new FaucetError(429, 'The daily faucet budget has been reached.', 3600)
128
+ const quote = await this.adapter.quote?.(address, this.amount)
129
+ const fee = BigInt(quote?.fee || 0n)
130
+ const outstanding = this.#records
131
+ .filter((record) => record.state === 'reserved' || record.state === 'pending')
132
+ .reduce((total, record) => total + BigInt(record.amount) + BigInt(record.fee || 0n), 0n)
133
+ if (BigInt(health.balance || 0n) < outstanding + this.amount + fee)
134
+ throw new FaucetError(503, 'Faucet funds are reserved for pending claims. Wait for confirmation.')
135
+ const record = {
136
+ id: requestId || randomUUID(),
137
+ address,
138
+ ip,
139
+ amount: this.amount.toString(),
140
+ fee: fee.toString(),
141
+ createdAt: now,
142
+ state: 'reserved'
143
+ }
144
+ this.#records.push(record)
145
+ // A reservation is durable BEFORE broadcasting. A crash or timeout cannot
146
+ // erase its rate/budget charge or cause an automatic duplicate payment.
147
+ await this.#persist()
148
+ try {
149
+ const pending = await this.adapter.submit(address, this.amount, quote)
150
+ if (!pending?.hash || !pending.wait || typeof pending.wait.then !== 'function')
151
+ throw new Error('Missing canonical completion handle')
152
+ // A completion may reject while disk IO is still pending. Observe it
153
+ // immediately; persistence failure still requires manual reconciliation.
154
+ Promise.resolve(pending.wait).catch(() => {})
155
+ record.hash = pending.hash
156
+ record.state = 'pending'
157
+ await this.#persist()
158
+ Promise.resolve(pending.wait)
159
+ .then(
160
+ () =>
161
+ this.#serialized(async () => {
162
+ record.state = 'confirmed'
163
+ await this.#persist()
164
+ }),
165
+ () =>
166
+ this.#serialized(async () => {
167
+ record.state = 'failed'
168
+ await this.#persist()
169
+ })
170
+ )
171
+ .catch((error) => this.adapter.reportError?.(error))
172
+ } catch (error) {
173
+ // The node may already have broadcast before an IO error. Keep the
174
+ // reservation for manual reconciliation, never refund and auto-resend.
175
+ this.adapter.reportError?.(error)
176
+ const failure = new FaucetError(
177
+ 503,
178
+ 'Claim reserved but submission could not be verified. Do not retry; contact the testnet operator.'
179
+ )
180
+ failure.claimId = record.id
181
+ throw failure
182
+ }
183
+ return this.publicClaim(record)
184
+ })
185
+ }
186
+ }