@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 +101 -2
- package/deploy/faucet.env.example +7 -0
- package/deploy/leofcoin-faucet.service +26 -0
- package/deploy/nginx-location.conf +9 -0
- package/package.json +38 -4
- package/src/cli.js +126 -0
- package/src/server.js +84 -0
- package/src/service.js +186 -0
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Peach testnet faucet
|
|
2
2
|
|
|
3
|
-
|
|
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,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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|