Skip to content

Commit cc0c917

Browse files
committed
fix: faucet transactions
1 parent d6ab738 commit cc0c917

File tree

3 files changed

+120
-182
lines changed

3 files changed

+120
-182
lines changed

src/api/routes/faucets.ts

Lines changed: 112 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import PQueue from 'p-queue';
44
import { BigNumber } from 'bignumber.js';
55
import {
66
AnchorMode,
7+
estimateTransactionFeeWithFallback,
8+
getAddressFromPrivateKey,
79
makeSTXTokenTransfer,
810
SignedTokenTransferOptions,
911
StacksTransaction,
12+
TransactionVersion,
1013
} from '@stacks/transactions';
1114
import { StacksNetwork } from '@stacks/network';
1215
import {
@@ -16,7 +19,7 @@ import {
1619
isValidBtcAddress,
1720
} from '../../btc-faucet';
1821
import { DbFaucetRequestCurrency } from '../../datastore/common';
19-
import { getChainIDNetwork, getStxFaucetNetworks, intMax, stxToMicroStx } from '../../helpers';
22+
import { getChainIDNetwork, getStxFaucetNetwork, stxToMicroStx } from '../../helpers';
2023
import { testnetKeys } from './debug';
2124
import { StacksCoreRpcClient } from '../../core-rpc/client';
2225
import { logger } from '../../logger';
@@ -27,25 +30,6 @@ import { Server } from 'node:http';
2730
import { OptionalNullable } from '../schemas/util';
2831
import { RunFaucetResponseSchema } from '../schemas/responses/responses';
2932

30-
enum TxSendResultStatus {
31-
Success,
32-
ConflictingNonce,
33-
TooMuchChaining,
34-
Error,
35-
}
36-
37-
interface TxSendResultSuccess {
38-
status: TxSendResultStatus.Success;
39-
txId: string;
40-
}
41-
42-
interface TxSendResultError {
43-
status: TxSendResultStatus;
44-
error: Error;
45-
}
46-
47-
type TxSendResult = TxSendResultSuccess | TxSendResultError;
48-
4933
function clientFromNetwork(network: StacksNetwork): StacksCoreRpcClient {
5034
const coreUrl = new URL(network.coreApiUrl);
5135
return new StacksCoreRpcClient({ host: coreUrl.hostname, port: coreUrl.port });
@@ -230,9 +214,63 @@ export const FaucetRoutes: FastifyPluginAsync<
230214
const FAUCET_STACKING_WINDOW = 2 * 24 * 60 * 60 * 1000; // 2 days
231215
const FAUCET_STACKING_TRIGGER_COUNT = 1;
232216

233-
const STX_FAUCET_NETWORKS = () => getStxFaucetNetworks();
217+
const STX_FAUCET_NETWORK = getStxFaucetNetwork();
234218
const STX_FAUCET_KEYS = (process.env.FAUCET_PRIVATE_KEY ?? testnetKeys[0].secretKey).split(',');
235219

220+
async function calculateSTXFaucetAmount(
221+
network: StacksNetwork,
222+
stacking: boolean
223+
): Promise<bigint> {
224+
if (stacking) {
225+
try {
226+
const poxInfo = await clientFromNetwork(network).getPox();
227+
let stxAmount = BigInt(poxInfo.min_amount_ustx);
228+
const padPercent = new BigNumber(0.2);
229+
const padAmount = new BigNumber(stxAmount.toString())
230+
.times(padPercent)
231+
.integerValue()
232+
.toString();
233+
stxAmount = stxAmount + BigInt(padAmount);
234+
return stxAmount;
235+
} catch (error) {
236+
// ignore
237+
}
238+
}
239+
return FAUCET_DEFAULT_STX_AMOUNT;
240+
}
241+
242+
async function buildSTXFaucetTx(
243+
recipient: string,
244+
amount: bigint,
245+
network: StacksNetwork,
246+
senderKey: string,
247+
nonce: bigint,
248+
fee?: bigint
249+
): Promise<StacksTransaction> {
250+
try {
251+
const options: SignedTokenTransferOptions = {
252+
recipient,
253+
amount,
254+
senderKey,
255+
network,
256+
memo: 'faucet',
257+
anchorMode: AnchorMode.Any,
258+
nonce,
259+
};
260+
if (fee) options.fee = fee;
261+
return await makeSTXTokenTransfer(options);
262+
} catch (error: any) {
263+
if (
264+
fee === undefined &&
265+
(error as Error).message &&
266+
/estimating transaction fee|NoEstimateAvailable/.test(error.message)
267+
) {
268+
return await buildSTXFaucetTx(recipient, amount, network, senderKey, nonce, 200n);
269+
}
270+
throw error;
271+
}
272+
}
273+
236274
fastify.post(
237275
'/stx',
238276
{
@@ -302,193 +340,97 @@ export const FaucetRoutes: FastifyPluginAsync<
302340
});
303341
}
304342

305-
const address = req.query.address;
306-
if (!address) {
343+
const recipientAddress = req.query.address;
344+
if (!recipientAddress) {
307345
return await reply.status(400).send({
308346
error: 'address required',
309347
success: false,
310348
});
311349
}
312350

313351
await stxFaucetRequestQueue.add(async () => {
314-
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
315-
const lastRequests = await fastify.db.getSTXFaucetRequests(address);
316-
317-
const isStackingReq = req.query.stacking ?? false;
318-
319352
// Guard condition: requests are limited to x times per y minutes.
320353
// Only based on address for now, but we're keeping the IP in case
321354
// we want to escalate and implement a per IP policy
355+
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
356+
const lastRequests = await fastify.db.getSTXFaucetRequests(recipientAddress);
322357
const now = Date.now();
358+
const isStackingReq = req.query.stacking ?? false;
323359
const [window, triggerCount] = isStackingReq
324360
? [FAUCET_STACKING_WINDOW, FAUCET_STACKING_TRIGGER_COUNT]
325361
: [FAUCET_DEFAULT_WINDOW, FAUCET_DEFAULT_TRIGGER_COUNT];
326-
327362
const requestsInWindow = lastRequests.results
328363
.map(r => now - r.occurred_at)
329364
.filter(r => r <= window);
330365
if (requestsInWindow.length >= triggerCount) {
331-
logger.warn(`STX faucet rate limit hit for address ${address}`);
366+
logger.warn(`STX faucet rate limit hit for address ${recipientAddress}`);
332367
return await reply.status(429).send({
333368
error: 'Too many requests',
334369
success: false,
335370
});
336371
}
337372

338-
const stxAmounts: bigint[] = [];
339-
for (const network of STX_FAUCET_NETWORKS()) {
340-
try {
341-
let stxAmount = FAUCET_DEFAULT_STX_AMOUNT;
342-
if (isStackingReq) {
343-
const poxInfo = await clientFromNetwork(network).getPox();
344-
stxAmount = BigInt(poxInfo.min_amount_ustx);
345-
const padPercent = new BigNumber(0.2);
346-
const padAmount = new BigNumber(stxAmount.toString())
347-
.times(padPercent)
348-
.integerValue()
349-
.toString();
350-
stxAmount = stxAmount + BigInt(padAmount);
351-
}
352-
stxAmounts.push(stxAmount);
353-
} catch (error) {
354-
// ignore
355-
}
356-
}
357-
const stxAmount = intMax(stxAmounts);
358-
359-
const generateTx = async (
360-
network: StacksNetwork,
361-
keyIndex: number,
362-
nonce?: bigint,
363-
fee?: bigint
364-
): Promise<StacksTransaction> => {
365-
const txOpts: SignedTokenTransferOptions = {
366-
recipient: address,
367-
amount: stxAmount,
368-
senderKey: STX_FAUCET_KEYS[keyIndex],
369-
network: network,
370-
memo: 'Faucet',
371-
anchorMode: AnchorMode.Any,
372-
};
373-
if (fee !== undefined) {
374-
txOpts.fee = fee;
375-
}
376-
if (nonce !== undefined) {
377-
txOpts.nonce = nonce;
378-
}
373+
// Start with a random key index. We will try others in order if this one fails.
374+
let keyIndex = Math.round(Math.random() * (STX_FAUCET_KEYS.length - 1));
375+
let attempts = 0;
376+
let sendSuccess: { txId: string; txRaw: string } | undefined;
377+
const stxAmount = await calculateSTXFaucetAmount(STX_FAUCET_NETWORK, isStackingReq);
378+
const rpcClient = clientFromNetwork(STX_FAUCET_NETWORK);
379+
do {
380+
attempts++;
381+
const senderKey = STX_FAUCET_KEYS[keyIndex];
382+
const senderAddress = getAddressFromPrivateKey(senderKey, TransactionVersion.Testnet);
383+
logger.debug(`StxFaucet attempting faucet transaction from sender: ${senderAddress}`);
384+
const nonces = await fastify.db.getAddressNonces({ stxAddress: senderAddress });
385+
const tx = await buildSTXFaucetTx(
386+
recipientAddress,
387+
stxAmount,
388+
STX_FAUCET_NETWORK,
389+
senderKey,
390+
BigInt(nonces.possibleNextNonce)
391+
);
392+
const rawTx = Buffer.from(tx.serialize());
379393
try {
380-
return await makeSTXTokenTransfer(txOpts);
394+
const res = await rpcClient.sendTransaction(rawTx);
395+
sendSuccess = { txId: res.txId, txRaw: rawTx.toString('hex') };
396+
logger.info(
397+
`StxFaucet success. Sent ${stxAmount} uSTX from ${senderAddress} to ${recipientAddress}.`
398+
);
381399
} catch (error: any) {
382400
if (
383-
fee === undefined &&
384-
(error as Error).message &&
385-
/estimating transaction fee|NoEstimateAvailable/.test(error.message)
401+
error.message?.includes('ConflictingNonceInMempool') ||
402+
error.message?.includes('TooMuchChaining')
386403
) {
387-
const defaultFee = 200n;
388-
return await generateTx(network, keyIndex, nonce, defaultFee);
389-
}
390-
throw error;
391-
}
392-
};
393-
394-
const nonces: bigint[] = [];
395-
const fees: bigint[] = [];
396-
let txGenFetchError: Error | undefined;
397-
for (const network of STX_FAUCET_NETWORKS()) {
398-
try {
399-
const tx = await generateTx(network, 0);
400-
nonces.push(tx.auth.spendingCondition?.nonce ?? BigInt(0));
401-
fees.push(tx.auth.spendingCondition.fee);
402-
} catch (error: any) {
403-
txGenFetchError = error;
404-
}
405-
}
406-
if (nonces.length === 0) {
407-
throw txGenFetchError;
408-
}
409-
let nextNonce = intMax(nonces);
410-
const fee = intMax(fees);
411-
412-
const sendTxResults: TxSendResult[] = [];
413-
let retrySend = false;
414-
let sendSuccess: { txId: string; txRaw: string } | undefined;
415-
let lastSendError: Error | undefined;
416-
let stxKeyIndex = 0;
417-
do {
418-
const tx = await generateTx(STX_FAUCET_NETWORKS()[0], stxKeyIndex, nextNonce, fee);
419-
const rawTx = Buffer.from(tx.serialize());
420-
for (const network of STX_FAUCET_NETWORKS()) {
421-
const rpcClient = clientFromNetwork(network);
422-
try {
423-
const res = await rpcClient.sendTransaction(rawTx);
424-
sendSuccess = { txId: res.txId, txRaw: rawTx.toString('hex') };
425-
sendTxResults.push({
426-
status: TxSendResultStatus.Success,
427-
txId: res.txId,
428-
});
429-
} catch (error: any) {
430-
lastSendError = error;
431-
if (error.message?.includes('ConflictingNonceInMempool')) {
432-
sendTxResults.push({
433-
status: TxSendResultStatus.ConflictingNonce,
434-
error,
435-
});
436-
} else if (error.message?.includes('TooMuchChaining')) {
437-
sendTxResults.push({
438-
status: TxSendResultStatus.TooMuchChaining,
439-
error,
440-
});
441-
} else {
442-
sendTxResults.push({
443-
status: TxSendResultStatus.Error,
444-
error,
445-
});
404+
if (attempts == STX_FAUCET_KEYS.length) {
405+
logger.error(
406+
`StxFaucet attempts exhausted for all faucet keys. Last error: ${error}`
407+
);
408+
throw error;
446409
}
447-
}
448-
}
449-
if (sendTxResults.every(res => res.status === TxSendResultStatus.Success)) {
450-
retrySend = false;
451-
} else if (
452-
sendTxResults.every(res => res.status === TxSendResultStatus.ConflictingNonce)
453-
) {
454-
retrySend = true;
455-
sendTxResults.length = 0;
456-
nextNonce = nextNonce + 1n;
457-
} else if (
458-
sendTxResults.every(res => res.status === TxSendResultStatus.TooMuchChaining)
459-
) {
460-
// Try with the next key in case we have one.
461-
if (stxKeyIndex + 1 === STX_FAUCET_KEYS.length) {
462-
retrySend = false;
410+
// Try with the next key. Wrap around the keys array if necessary.
411+
keyIndex++;
412+
if (keyIndex > STX_FAUCET_KEYS.length) keyIndex = 0;
413+
logger.warn(
414+
`StxFaucet transaction failed for sender ${senderAddress}, trying with next key: ${error}`
415+
);
463416
} else {
464-
retrySend = true;
465-
stxKeyIndex++;
417+
logger.warn(`StxFaucet unexpected error when sending transaction: ${error}`);
418+
throw error;
466419
}
467-
} else {
468-
retrySend = false;
469420
}
470-
} while (retrySend);
471-
472-
if (!sendSuccess) {
473-
if (lastSendError) {
474-
throw lastSendError;
475-
} else {
476-
throw new Error(`Unexpected failure to send or capture error`);
477-
}
478-
} else {
479-
await reply.send({
480-
success: true,
481-
txId: sendSuccess.txId,
482-
txRaw: sendSuccess.txRaw,
483-
});
484-
}
421+
} while (!sendSuccess);
485422

486423
await fastify.writeDb?.insertFaucetRequest({
487424
ip: `${ip}`,
488-
address: address,
425+
address: recipientAddress,
489426
currency: DbFaucetRequestCurrency.STX,
490427
occurred_at: now,
491428
});
429+
await reply.send({
430+
success: true,
431+
txId: sendSuccess.txId,
432+
txRaw: sendSuccess.txRaw,
433+
});
492434
});
493435
}
494436
);

src/helpers.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ export function getIbdBlockHeight(): number | undefined {
3333
}
3434
}
3535

36-
export function getStxFaucetNetworks(): StacksNetwork[] {
37-
const networks: StacksNetwork[] = [getStacksTestnetNetwork()];
36+
export function getStxFaucetNetwork(): StacksNetwork {
3837
const faucetNodeHostOverride: string | undefined = process.env.STACKS_FAUCET_NODE_HOST;
3938
if (faucetNodeHostOverride) {
4039
const faucetNodePortOverride: string | undefined = process.env.STACKS_FAUCET_NODE_PORT;
@@ -46,9 +45,9 @@ export function getStxFaucetNetworks(): StacksNetwork[] {
4645
const network = new StacksTestnet({
4746
url: `http://${faucetNodeHostOverride}:${faucetNodePortOverride}`,
4847
});
49-
networks.push(network);
48+
return network;
5049
}
51-
return networks;
50+
return getStacksTestnetNetwork();
5251
}
5352

5453
function createEnumChecker<T extends string, TEnumValue extends number>(enumVariable: {

0 commit comments

Comments
 (0)