Skip to content

Commit f7265f9

Browse files
authored
fix: randomize key order for testnet stx faucet transactions (#2120)
1 parent 8b10b69 commit f7265f9

File tree

3 files changed

+130
-198
lines changed

3 files changed

+130
-198
lines changed

src/api/routes/faucets.ts

Lines changed: 122 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,73 @@ 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 fetchNetworkChainID(network: StacksNetwork): Promise<number> {
243+
const rpcClient = clientFromNetwork(network);
244+
const info = await rpcClient.getInfo();
245+
return info.network_id;
246+
}
247+
248+
async function buildSTXFaucetTx(
249+
recipient: string,
250+
amount: bigint,
251+
network: StacksNetwork,
252+
senderKey: string,
253+
nonce: bigint,
254+
fee?: bigint
255+
): Promise<StacksTransaction> {
256+
try {
257+
const options: SignedTokenTransferOptions = {
258+
recipient,
259+
amount,
260+
senderKey,
261+
network,
262+
memo: 'faucet',
263+
anchorMode: AnchorMode.Any,
264+
nonce,
265+
};
266+
if (fee) options.fee = fee;
267+
268+
// Detect possible custom network chain ID
269+
network.chainId = await fetchNetworkChainID(network);
270+
271+
return await makeSTXTokenTransfer(options);
272+
} catch (error: any) {
273+
if (
274+
fee === undefined &&
275+
(error as Error).message &&
276+
/estimating transaction fee|NoEstimateAvailable/.test(error.message)
277+
) {
278+
return await buildSTXFaucetTx(recipient, amount, network, senderKey, nonce, 200n);
279+
}
280+
throw error;
281+
}
282+
}
283+
236284
fastify.post(
237285
'/stx',
238286
{
@@ -302,193 +350,97 @@ export const FaucetRoutes: FastifyPluginAsync<
302350
});
303351
}
304352

305-
const address = req.query.address;
306-
if (!address) {
353+
const recipientAddress = req.query.address;
354+
if (!recipientAddress) {
307355
return await reply.status(400).send({
308356
error: 'address required',
309357
success: false,
310358
});
311359
}
312360

313361
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-
319362
// Guard condition: requests are limited to x times per y minutes.
320363
// Only based on address for now, but we're keeping the IP in case
321364
// we want to escalate and implement a per IP policy
365+
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
366+
const lastRequests = await fastify.db.getSTXFaucetRequests(recipientAddress);
322367
const now = Date.now();
368+
const isStackingReq = req.query.stacking ?? false;
323369
const [window, triggerCount] = isStackingReq
324370
? [FAUCET_STACKING_WINDOW, FAUCET_STACKING_TRIGGER_COUNT]
325371
: [FAUCET_DEFAULT_WINDOW, FAUCET_DEFAULT_TRIGGER_COUNT];
326-
327372
const requestsInWindow = lastRequests.results
328373
.map(r => now - r.occurred_at)
329374
.filter(r => r <= window);
330375
if (requestsInWindow.length >= triggerCount) {
331-
logger.warn(`STX faucet rate limit hit for address ${address}`);
376+
logger.warn(`StxFaucet rate limit hit for address ${recipientAddress}`);
332377
return await reply.status(429).send({
333378
error: 'Too many requests',
334379
success: false,
335380
});
336381
}
337382

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-
}
383+
// Start with a random key index. We will try others in order if this one fails.
384+
let keyIndex = Math.round(Math.random() * (STX_FAUCET_KEYS.length - 1));
385+
let keysAttempted = 0;
386+
let sendSuccess: { txId: string; txRaw: string } | undefined;
387+
const stxAmount = await calculateSTXFaucetAmount(STX_FAUCET_NETWORK(), isStackingReq);
388+
const rpcClient = clientFromNetwork(STX_FAUCET_NETWORK());
389+
do {
390+
keysAttempted++;
391+
const senderKey = STX_FAUCET_KEYS[keyIndex];
392+
const senderAddress = getAddressFromPrivateKey(senderKey, TransactionVersion.Testnet);
393+
logger.debug(`StxFaucet attempting faucet transaction from sender: ${senderAddress}`);
394+
const nonces = await fastify.db.getAddressNonces({ stxAddress: senderAddress });
395+
const tx = await buildSTXFaucetTx(
396+
recipientAddress,
397+
stxAmount,
398+
STX_FAUCET_NETWORK(),
399+
senderKey,
400+
BigInt(nonces.possibleNextNonce)
401+
);
402+
const rawTx = Buffer.from(tx.serialize());
379403
try {
380-
return await makeSTXTokenTransfer(txOpts);
404+
const res = await rpcClient.sendTransaction(rawTx);
405+
sendSuccess = { txId: res.txId, txRaw: rawTx.toString('hex') };
406+
logger.info(
407+
`StxFaucet success. Sent ${stxAmount} uSTX from ${senderAddress} to ${recipientAddress}.`
408+
);
381409
} catch (error: any) {
382410
if (
383-
fee === undefined &&
384-
(error as Error).message &&
385-
/estimating transaction fee|NoEstimateAvailable/.test(error.message)
411+
error.message?.includes('ConflictingNonceInMempool') ||
412+
error.message?.includes('TooMuchChaining')
386413
) {
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-
});
414+
if (keysAttempted == STX_FAUCET_KEYS.length) {
415+
logger.warn(
416+
`StxFaucet attempts exhausted for all faucet keys. Last error: ${error}`
417+
);
418+
throw error;
446419
}
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;
420+
// Try with the next key. Wrap around the keys array if necessary.
421+
keyIndex++;
422+
if (keyIndex >= STX_FAUCET_KEYS.length) keyIndex = 0;
423+
logger.warn(
424+
`StxFaucet transaction failed for sender ${senderAddress}, trying with next key: ${error}`
425+
);
463426
} else {
464-
retrySend = true;
465-
stxKeyIndex++;
427+
logger.warn(`StxFaucet unexpected error when sending transaction: ${error}`);
428+
throw error;
466429
}
467-
} else {
468-
retrySend = false;
469430
}
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-
}
431+
} while (!sendSuccess);
485432

486433
await fastify.writeDb?.insertFaucetRequest({
487434
ip: `${ip}`,
488-
address: address,
435+
address: recipientAddress,
489436
currency: DbFaucetRequestCurrency.STX,
490437
occurred_at: now,
491438
});
439+
await reply.send({
440+
success: true,
441+
txId: sendSuccess.txId,
442+
txRaw: sendSuccess.txRaw,
443+
});
492444
});
493445
}
494446
);

0 commit comments

Comments
 (0)