Skip to content

Commit 1bd1eda

Browse files
authored
feat: add v3 faucet endpoints and deprecate v1 faucets (#2691)
* v3 faucets * one queue per acct * 5xx error schema
1 parent e9a9240 commit 1bd1eda

14 files changed

Lines changed: 1665 additions & 413 deletions

File tree

‎src/api/faucets/common.ts‎

Lines changed: 413 additions & 0 deletions
Large diffs are not rendered by default.

‎src/api/faucets/errors.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { CoreRpcError } from '@stacks/rpc-client';
2+
import { isPgConnectionError } from '@stacks/api-toolkit';
3+
4+
// Low-level socket error codes thrown when a backing node (bitcoind RPC or the Stacks core node
5+
// RPC) that a faucet depends on is down or unreachable.
6+
const NODE_CONNECTION_ERROR_CODES = [
7+
'ECONNREFUSED',
8+
'ECONNRESET',
9+
'ETIMEDOUT',
10+
'EHOSTUNREACH',
11+
'ENOTFOUND',
12+
'EAI_AGAIN',
13+
];
14+
15+
/**
16+
* Detects whether an error was caused by an unreachable backing node. Node's socket errors expose a
17+
* `code`, but some HTTP clients (e.g. undici/`fetch`) nest the original error under `cause`, so we
18+
* check both, plus the message text as a last resort.
19+
*/
20+
export function isNodeConnectionError(error: unknown): boolean {
21+
const err = error as (Error & { code?: string; cause?: { code?: string } }) | undefined;
22+
const code = err?.code ?? err?.cause?.code;
23+
if (code && NODE_CONNECTION_ERROR_CODES.includes(code)) {
24+
return true;
25+
}
26+
const message = err?.message ?? '';
27+
return (
28+
NODE_CONNECTION_ERROR_CODES.some(c => message.includes(c)) || /fetch failed/i.test(message)
29+
);
30+
}
31+
32+
/**
33+
* Detects a failure caused by the faucet's own account running out of funds. This is an operational
34+
* condition (the faucet account needs refilling) rather than a client error. It surfaces as:
35+
* - `NotEnoughFunds`: the Stacks node rejecting an STX/sBTC faucet transaction, and
36+
* - `not enough total amount in utxo set`: the BTC faucet having no spendable UTXOs to build a tx
37+
* (note the funds may be present but not yet spendable, e.g. immature coinbase or unconfirmed).
38+
*/
39+
export function isInsufficientFundsError(error: unknown): boolean {
40+
if (getTxRejectionReason(error) === 'NotEnoughFunds') {
41+
return true;
42+
}
43+
const message = (error as Error | undefined)?.message ?? '';
44+
return message.includes('not enough total amount in utxo');
45+
}
46+
47+
/**
48+
* Extracts the Stacks node's mempool rejection reason (e.g. `ConflictingNonceInMempool`) from a
49+
* failed `/v2/transactions` broadcast. The node responds `400` with a JSON body like
50+
* `{ error: 'transaction rejected', reason: 'TooMuchChaining', ... }`, which `CoreRpcError`
51+
* surfaces under `details.error`.
52+
*/
53+
export function getTxRejectionReason(error: unknown): string | undefined {
54+
if (!(error instanceof CoreRpcError)) {
55+
return undefined;
56+
}
57+
const body = (error.details as { error?: { reason?: unknown } } | undefined)?.error;
58+
return typeof body?.reason === 'string' ? body.reason : undefined;
59+
}
60+
61+
/**
62+
* The sanitized outcome of an unhandled faucet error: which status to respond with, the
63+
* client-facing message, and the reason to record in the server log. Faucet handlers talk to
64+
* backing nodes whose failures would otherwise surface as a generic `500` leaking internal details
65+
* (e.g. the node's host/port), so every route's error handler runs its error through here and then
66+
* renders the result in its own response shape.
67+
*/
68+
export interface ClassifiedFaucetError {
69+
statusCode: number;
70+
/** Client-facing error message. Never includes details of the backing node. */
71+
message: string;
72+
/**
73+
* Operator-facing reason, appended to the server log line. Undefined for an unrecognized error,
74+
* which is logged without a suffix.
75+
*/
76+
logReason?: string;
77+
}
78+
79+
export function classifyFaucetError(error: unknown): ClassifiedFaucetError {
80+
if (isPgConnectionError(error)) {
81+
return {
82+
statusCode: 503,
83+
message: 'Faucet is temporarily unavailable, please try again later',
84+
logReason: 'database unavailable',
85+
};
86+
}
87+
if (isNodeConnectionError(error)) {
88+
return {
89+
statusCode: 503,
90+
message: 'Faucet is temporarily unavailable, please try again later',
91+
logReason: 'backing node is unreachable',
92+
};
93+
}
94+
if (isInsufficientFundsError(error)) {
95+
return {
96+
statusCode: 503,
97+
message: 'The faucet is temporarily out of funds, please try again later',
98+
logReason: 'faucet account is out of funds',
99+
};
100+
}
101+
return {
102+
statusCode: 500,
103+
message: 'Faucet request failed, please try again later',
104+
};
105+
}

‎src/api/init.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { TokensStxRoutes } from './routes/v3/tokens-stx.js';
6161
import { TokensFtRoutes } from './routes/v3/tokens-ft.js';
6262
import { SmartContractsRoutes } from './routes/v3/smart-contracts.js';
6363
import { TokensNftRoutes } from './routes/v3/tokens-nft.js';
64+
import { FaucetsRoutes } from './routes/v3/faucets.js';
6465

6566
export interface ApiServer {
6667
fastifyApp: FastifyInstance;
@@ -113,6 +114,7 @@ export const StacksApiRoutes: FastifyPluginAsync<
113114
await fastify.register(
114115
async fastify => {
115116
await fastify.register(BlocksRoutes);
117+
await fastify.register(FaucetsRoutes);
116118
await fastify.register(MempoolRoutes);
117119
await fastify.register(PrincipalsRoutes);
118120
await fastify.register(SearchRoutes);

0 commit comments

Comments
 (0)