From cc0c9177c82da1ddeb4e0720bfac53edd75d659e Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Mon, 14 Oct 2024 12:01:34 -0600 Subject: [PATCH 1/6] fix: faucet transactions --- src/api/routes/faucets.ts | 282 ++++++++++++++--------------------- src/helpers.ts | 7 +- tests/api/faucet-stx.test.ts | 13 +- 3 files changed, 120 insertions(+), 182 deletions(-) diff --git a/src/api/routes/faucets.ts b/src/api/routes/faucets.ts index 64d8b93e1..a7d026cf0 100644 --- a/src/api/routes/faucets.ts +++ b/src/api/routes/faucets.ts @@ -4,9 +4,12 @@ import PQueue from 'p-queue'; import { BigNumber } from 'bignumber.js'; import { AnchorMode, + estimateTransactionFeeWithFallback, + getAddressFromPrivateKey, makeSTXTokenTransfer, SignedTokenTransferOptions, StacksTransaction, + TransactionVersion, } from '@stacks/transactions'; import { StacksNetwork } from '@stacks/network'; import { @@ -16,7 +19,7 @@ import { isValidBtcAddress, } from '../../btc-faucet'; import { DbFaucetRequestCurrency } from '../../datastore/common'; -import { getChainIDNetwork, getStxFaucetNetworks, intMax, stxToMicroStx } from '../../helpers'; +import { getChainIDNetwork, getStxFaucetNetwork, stxToMicroStx } from '../../helpers'; import { testnetKeys } from './debug'; import { StacksCoreRpcClient } from '../../core-rpc/client'; import { logger } from '../../logger'; @@ -27,25 +30,6 @@ import { Server } from 'node:http'; import { OptionalNullable } from '../schemas/util'; import { RunFaucetResponseSchema } from '../schemas/responses/responses'; -enum TxSendResultStatus { - Success, - ConflictingNonce, - TooMuchChaining, - Error, -} - -interface TxSendResultSuccess { - status: TxSendResultStatus.Success; - txId: string; -} - -interface TxSendResultError { - status: TxSendResultStatus; - error: Error; -} - -type TxSendResult = TxSendResultSuccess | TxSendResultError; - function clientFromNetwork(network: StacksNetwork): StacksCoreRpcClient { const coreUrl = new URL(network.coreApiUrl); return new StacksCoreRpcClient({ host: coreUrl.hostname, port: coreUrl.port }); @@ -230,9 +214,63 @@ export const FaucetRoutes: FastifyPluginAsync< const FAUCET_STACKING_WINDOW = 2 * 24 * 60 * 60 * 1000; // 2 days const FAUCET_STACKING_TRIGGER_COUNT = 1; - const STX_FAUCET_NETWORKS = () => getStxFaucetNetworks(); + const STX_FAUCET_NETWORK = getStxFaucetNetwork(); const STX_FAUCET_KEYS = (process.env.FAUCET_PRIVATE_KEY ?? testnetKeys[0].secretKey).split(','); + async function calculateSTXFaucetAmount( + network: StacksNetwork, + stacking: boolean + ): Promise { + if (stacking) { + try { + const poxInfo = await clientFromNetwork(network).getPox(); + let stxAmount = BigInt(poxInfo.min_amount_ustx); + const padPercent = new BigNumber(0.2); + const padAmount = new BigNumber(stxAmount.toString()) + .times(padPercent) + .integerValue() + .toString(); + stxAmount = stxAmount + BigInt(padAmount); + return stxAmount; + } catch (error) { + // ignore + } + } + return FAUCET_DEFAULT_STX_AMOUNT; + } + + async function buildSTXFaucetTx( + recipient: string, + amount: bigint, + network: StacksNetwork, + senderKey: string, + nonce: bigint, + fee?: bigint + ): Promise { + try { + const options: SignedTokenTransferOptions = { + recipient, + amount, + senderKey, + network, + memo: 'faucet', + anchorMode: AnchorMode.Any, + nonce, + }; + if (fee) options.fee = fee; + return await makeSTXTokenTransfer(options); + } catch (error: any) { + if ( + fee === undefined && + (error as Error).message && + /estimating transaction fee|NoEstimateAvailable/.test(error.message) + ) { + return await buildSTXFaucetTx(recipient, amount, network, senderKey, nonce, 200n); + } + throw error; + } + } + fastify.post( '/stx', { @@ -302,8 +340,8 @@ export const FaucetRoutes: FastifyPluginAsync< }); } - const address = req.query.address; - if (!address) { + const recipientAddress = req.query.address; + if (!recipientAddress) { return await reply.status(400).send({ error: 'address required', success: false, @@ -311,184 +349,88 @@ export const FaucetRoutes: FastifyPluginAsync< } await stxFaucetRequestQueue.add(async () => { - const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; - const lastRequests = await fastify.db.getSTXFaucetRequests(address); - - const isStackingReq = req.query.stacking ?? false; - // Guard condition: requests are limited to x times per y minutes. // Only based on address for now, but we're keeping the IP in case // we want to escalate and implement a per IP policy + const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + const lastRequests = await fastify.db.getSTXFaucetRequests(recipientAddress); const now = Date.now(); + const isStackingReq = req.query.stacking ?? false; const [window, triggerCount] = isStackingReq ? [FAUCET_STACKING_WINDOW, FAUCET_STACKING_TRIGGER_COUNT] : [FAUCET_DEFAULT_WINDOW, FAUCET_DEFAULT_TRIGGER_COUNT]; - const requestsInWindow = lastRequests.results .map(r => now - r.occurred_at) .filter(r => r <= window); if (requestsInWindow.length >= triggerCount) { - logger.warn(`STX faucet rate limit hit for address ${address}`); + logger.warn(`STX faucet rate limit hit for address ${recipientAddress}`); return await reply.status(429).send({ error: 'Too many requests', success: false, }); } - const stxAmounts: bigint[] = []; - for (const network of STX_FAUCET_NETWORKS()) { - try { - let stxAmount = FAUCET_DEFAULT_STX_AMOUNT; - if (isStackingReq) { - const poxInfo = await clientFromNetwork(network).getPox(); - stxAmount = BigInt(poxInfo.min_amount_ustx); - const padPercent = new BigNumber(0.2); - const padAmount = new BigNumber(stxAmount.toString()) - .times(padPercent) - .integerValue() - .toString(); - stxAmount = stxAmount + BigInt(padAmount); - } - stxAmounts.push(stxAmount); - } catch (error) { - // ignore - } - } - const stxAmount = intMax(stxAmounts); - - const generateTx = async ( - network: StacksNetwork, - keyIndex: number, - nonce?: bigint, - fee?: bigint - ): Promise => { - const txOpts: SignedTokenTransferOptions = { - recipient: address, - amount: stxAmount, - senderKey: STX_FAUCET_KEYS[keyIndex], - network: network, - memo: 'Faucet', - anchorMode: AnchorMode.Any, - }; - if (fee !== undefined) { - txOpts.fee = fee; - } - if (nonce !== undefined) { - txOpts.nonce = nonce; - } + // Start with a random key index. We will try others in order if this one fails. + let keyIndex = Math.round(Math.random() * (STX_FAUCET_KEYS.length - 1)); + let attempts = 0; + let sendSuccess: { txId: string; txRaw: string } | undefined; + const stxAmount = await calculateSTXFaucetAmount(STX_FAUCET_NETWORK, isStackingReq); + const rpcClient = clientFromNetwork(STX_FAUCET_NETWORK); + do { + attempts++; + const senderKey = STX_FAUCET_KEYS[keyIndex]; + const senderAddress = getAddressFromPrivateKey(senderKey, TransactionVersion.Testnet); + logger.debug(`StxFaucet attempting faucet transaction from sender: ${senderAddress}`); + const nonces = await fastify.db.getAddressNonces({ stxAddress: senderAddress }); + const tx = await buildSTXFaucetTx( + recipientAddress, + stxAmount, + STX_FAUCET_NETWORK, + senderKey, + BigInt(nonces.possibleNextNonce) + ); + const rawTx = Buffer.from(tx.serialize()); try { - return await makeSTXTokenTransfer(txOpts); + const res = await rpcClient.sendTransaction(rawTx); + sendSuccess = { txId: res.txId, txRaw: rawTx.toString('hex') }; + logger.info( + `StxFaucet success. Sent ${stxAmount} uSTX from ${senderAddress} to ${recipientAddress}.` + ); } catch (error: any) { if ( - fee === undefined && - (error as Error).message && - /estimating transaction fee|NoEstimateAvailable/.test(error.message) + error.message?.includes('ConflictingNonceInMempool') || + error.message?.includes('TooMuchChaining') ) { - const defaultFee = 200n; - return await generateTx(network, keyIndex, nonce, defaultFee); - } - throw error; - } - }; - - const nonces: bigint[] = []; - const fees: bigint[] = []; - let txGenFetchError: Error | undefined; - for (const network of STX_FAUCET_NETWORKS()) { - try { - const tx = await generateTx(network, 0); - nonces.push(tx.auth.spendingCondition?.nonce ?? BigInt(0)); - fees.push(tx.auth.spendingCondition.fee); - } catch (error: any) { - txGenFetchError = error; - } - } - if (nonces.length === 0) { - throw txGenFetchError; - } - let nextNonce = intMax(nonces); - const fee = intMax(fees); - - const sendTxResults: TxSendResult[] = []; - let retrySend = false; - let sendSuccess: { txId: string; txRaw: string } | undefined; - let lastSendError: Error | undefined; - let stxKeyIndex = 0; - do { - const tx = await generateTx(STX_FAUCET_NETWORKS()[0], stxKeyIndex, nextNonce, fee); - const rawTx = Buffer.from(tx.serialize()); - for (const network of STX_FAUCET_NETWORKS()) { - const rpcClient = clientFromNetwork(network); - try { - const res = await rpcClient.sendTransaction(rawTx); - sendSuccess = { txId: res.txId, txRaw: rawTx.toString('hex') }; - sendTxResults.push({ - status: TxSendResultStatus.Success, - txId: res.txId, - }); - } catch (error: any) { - lastSendError = error; - if (error.message?.includes('ConflictingNonceInMempool')) { - sendTxResults.push({ - status: TxSendResultStatus.ConflictingNonce, - error, - }); - } else if (error.message?.includes('TooMuchChaining')) { - sendTxResults.push({ - status: TxSendResultStatus.TooMuchChaining, - error, - }); - } else { - sendTxResults.push({ - status: TxSendResultStatus.Error, - error, - }); + if (attempts == STX_FAUCET_KEYS.length) { + logger.error( + `StxFaucet attempts exhausted for all faucet keys. Last error: ${error}` + ); + throw error; } - } - } - if (sendTxResults.every(res => res.status === TxSendResultStatus.Success)) { - retrySend = false; - } else if ( - sendTxResults.every(res => res.status === TxSendResultStatus.ConflictingNonce) - ) { - retrySend = true; - sendTxResults.length = 0; - nextNonce = nextNonce + 1n; - } else if ( - sendTxResults.every(res => res.status === TxSendResultStatus.TooMuchChaining) - ) { - // Try with the next key in case we have one. - if (stxKeyIndex + 1 === STX_FAUCET_KEYS.length) { - retrySend = false; + // Try with the next key. Wrap around the keys array if necessary. + keyIndex++; + if (keyIndex > STX_FAUCET_KEYS.length) keyIndex = 0; + logger.warn( + `StxFaucet transaction failed for sender ${senderAddress}, trying with next key: ${error}` + ); } else { - retrySend = true; - stxKeyIndex++; + logger.warn(`StxFaucet unexpected error when sending transaction: ${error}`); + throw error; } - } else { - retrySend = false; } - } while (retrySend); - - if (!sendSuccess) { - if (lastSendError) { - throw lastSendError; - } else { - throw new Error(`Unexpected failure to send or capture error`); - } - } else { - await reply.send({ - success: true, - txId: sendSuccess.txId, - txRaw: sendSuccess.txRaw, - }); - } + } while (!sendSuccess); await fastify.writeDb?.insertFaucetRequest({ ip: `${ip}`, - address: address, + address: recipientAddress, currency: DbFaucetRequestCurrency.STX, occurred_at: now, }); + await reply.send({ + success: true, + txId: sendSuccess.txId, + txRaw: sendSuccess.txRaw, + }); }); } ); diff --git a/src/helpers.ts b/src/helpers.ts index a80efb657..53c8a1cd5 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -33,8 +33,7 @@ export function getIbdBlockHeight(): number | undefined { } } -export function getStxFaucetNetworks(): StacksNetwork[] { - const networks: StacksNetwork[] = [getStacksTestnetNetwork()]; +export function getStxFaucetNetwork(): StacksNetwork { const faucetNodeHostOverride: string | undefined = process.env.STACKS_FAUCET_NODE_HOST; if (faucetNodeHostOverride) { const faucetNodePortOverride: string | undefined = process.env.STACKS_FAUCET_NODE_PORT; @@ -46,9 +45,9 @@ export function getStxFaucetNetworks(): StacksNetwork[] { const network = new StacksTestnet({ url: `http://${faucetNodeHostOverride}:${faucetNodePortOverride}`, }); - networks.push(network); + return network; } - return networks; + return getStacksTestnetNetwork(); } function createEnumChecker(enumVariable: { diff --git a/tests/api/faucet-stx.test.ts b/tests/api/faucet-stx.test.ts index b7fb5f8df..5185e42e0 100644 --- a/tests/api/faucet-stx.test.ts +++ b/tests/api/faucet-stx.test.ts @@ -1,20 +1,17 @@ import * as process from 'process'; -import { getStxFaucetNetworks } from '../../src/helpers'; +import { getStxFaucetNetwork } from '../../src/helpers'; describe('stx faucet', () => { test('faucet node env var override', () => { - const faucetDefaults = getStxFaucetNetworks(); - expect(faucetDefaults.length).toBe(1); - expect(faucetDefaults[0].coreApiUrl).toBe('http://127.0.0.1:20443'); + const faucetDefaults = getStxFaucetNetwork(); + expect(faucetDefaults.coreApiUrl).toBe('http://127.0.0.1:20443'); process.env.STACKS_FAUCET_NODE_HOST = '1.2.3.4'; process.env.STACKS_FAUCET_NODE_PORT = '12345'; try { - const faucetOverride = getStxFaucetNetworks(); - expect(faucetOverride.length).toBe(2); - expect(faucetDefaults[0].coreApiUrl).toBe('http://127.0.0.1:20443'); - expect(faucetOverride[1].coreApiUrl).toBe('http://1.2.3.4:12345'); + const faucetOverride = getStxFaucetNetwork(); + expect(faucetOverride.coreApiUrl).toBe('http://1.2.3.4:12345'); } finally { delete process.env.STACKS_FAUCET_NODE_HOST; delete process.env.STACKS_FAUCET_NODE_PORT; From bf3a4b3f08378ec64ba155a51b023a46fd87e0fd Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Mon, 14 Oct 2024 12:03:44 -0600 Subject: [PATCH 2/6] chore: style --- src/api/routes/faucets.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api/routes/faucets.ts b/src/api/routes/faucets.ts index a7d026cf0..2fc049c2e 100644 --- a/src/api/routes/faucets.ts +++ b/src/api/routes/faucets.ts @@ -363,7 +363,7 @@ export const FaucetRoutes: FastifyPluginAsync< .map(r => now - r.occurred_at) .filter(r => r <= window); if (requestsInWindow.length >= triggerCount) { - logger.warn(`STX faucet rate limit hit for address ${recipientAddress}`); + logger.warn(`StxFaucet rate limit hit for address ${recipientAddress}`); return await reply.status(429).send({ error: 'Too many requests', success: false, @@ -372,12 +372,12 @@ export const FaucetRoutes: FastifyPluginAsync< // Start with a random key index. We will try others in order if this one fails. let keyIndex = Math.round(Math.random() * (STX_FAUCET_KEYS.length - 1)); - let attempts = 0; + let keysAttempted = 0; let sendSuccess: { txId: string; txRaw: string } | undefined; const stxAmount = await calculateSTXFaucetAmount(STX_FAUCET_NETWORK, isStackingReq); const rpcClient = clientFromNetwork(STX_FAUCET_NETWORK); do { - attempts++; + keysAttempted++; const senderKey = STX_FAUCET_KEYS[keyIndex]; const senderAddress = getAddressFromPrivateKey(senderKey, TransactionVersion.Testnet); logger.debug(`StxFaucet attempting faucet transaction from sender: ${senderAddress}`); @@ -401,8 +401,8 @@ export const FaucetRoutes: FastifyPluginAsync< error.message?.includes('ConflictingNonceInMempool') || error.message?.includes('TooMuchChaining') ) { - if (attempts == STX_FAUCET_KEYS.length) { - logger.error( + if (keysAttempted == STX_FAUCET_KEYS.length) { + logger.warn( `StxFaucet attempts exhausted for all faucet keys. Last error: ${error}` ); throw error; From 5c755ed8bc1446f0e3357ce77293ca1c7439d0a6 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Mon, 14 Oct 2024 12:04:48 -0600 Subject: [PATCH 3/6] fix: off by 1 --- src/api/routes/faucets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/routes/faucets.ts b/src/api/routes/faucets.ts index 2fc049c2e..820e6376b 100644 --- a/src/api/routes/faucets.ts +++ b/src/api/routes/faucets.ts @@ -409,7 +409,7 @@ export const FaucetRoutes: FastifyPluginAsync< } // Try with the next key. Wrap around the keys array if necessary. keyIndex++; - if (keyIndex > STX_FAUCET_KEYS.length) keyIndex = 0; + if (keyIndex >= STX_FAUCET_KEYS.length) keyIndex = 0; logger.warn( `StxFaucet transaction failed for sender ${senderAddress}, trying with next key: ${error}` ); From 495e56c9b2ddf9b3ae18a291f1828b4b661e803b Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Mon, 14 Oct 2024 12:13:31 -0600 Subject: [PATCH 4/6] fix: unused export --- src/helpers.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index 53c8a1cd5..ae6fb5d94 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -468,22 +468,6 @@ export function assertNotNullish( } } -function intMax(args: bigint[]): bigint; -function intMax(args: number[]): number; -function intMax(args: bigint[] | number[]): any { - if (args.length === 0) { - throw new Error(`empty array not supported in intMax`); - } else if (typeof args[0] === 'bigint') { - return (args as bigint[]).reduce((m, e) => (e > m ? e : m)); - } else if (typeof args[0] === 'number') { - return Math.max(...(args as number[])); - } else { - // eslint-disable-next-line @typescript-eslint/ban-types - throw new Error(`Unsupported type for intMax: ${(args[0] as object).constructor.name}`); - } -} -export { intMax }; - export class BigIntMath { static abs(a: bigint): bigint { return a < 0n ? -a : a; From bc470d4f1e46b0630db0f2d5053f4fd906fbe302 Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 15 Oct 2024 14:53:18 +0200 Subject: [PATCH 5/6] fix: build --- src/api/routes/faucets.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/routes/faucets.ts b/src/api/routes/faucets.ts index 820e6376b..7dac20a00 100644 --- a/src/api/routes/faucets.ts +++ b/src/api/routes/faucets.ts @@ -214,7 +214,7 @@ export const FaucetRoutes: FastifyPluginAsync< const FAUCET_STACKING_WINDOW = 2 * 24 * 60 * 60 * 1000; // 2 days const FAUCET_STACKING_TRIGGER_COUNT = 1; - const STX_FAUCET_NETWORK = getStxFaucetNetwork(); + const STX_FAUCET_NETWORK = () => getStxFaucetNetwork(); const STX_FAUCET_KEYS = (process.env.FAUCET_PRIVATE_KEY ?? testnetKeys[0].secretKey).split(','); async function calculateSTXFaucetAmount( @@ -374,8 +374,8 @@ export const FaucetRoutes: FastifyPluginAsync< let keyIndex = Math.round(Math.random() * (STX_FAUCET_KEYS.length - 1)); let keysAttempted = 0; let sendSuccess: { txId: string; txRaw: string } | undefined; - const stxAmount = await calculateSTXFaucetAmount(STX_FAUCET_NETWORK, isStackingReq); - const rpcClient = clientFromNetwork(STX_FAUCET_NETWORK); + const stxAmount = await calculateSTXFaucetAmount(STX_FAUCET_NETWORK(), isStackingReq); + const rpcClient = clientFromNetwork(STX_FAUCET_NETWORK()); do { keysAttempted++; const senderKey = STX_FAUCET_KEYS[keyIndex]; @@ -385,7 +385,7 @@ export const FaucetRoutes: FastifyPluginAsync< const tx = await buildSTXFaucetTx( recipientAddress, stxAmount, - STX_FAUCET_NETWORK, + STX_FAUCET_NETWORK(), senderKey, BigInt(nonces.possibleNextNonce) ); From 080e4e3b296dfc71b8d9205c5c84027fed9ccf2f Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 15 Oct 2024 15:52:49 +0200 Subject: [PATCH 6/6] fix: stx faucet should detect possible custom network ID --- src/api/routes/faucets.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/api/routes/faucets.ts b/src/api/routes/faucets.ts index 7dac20a00..c9b2b04e8 100644 --- a/src/api/routes/faucets.ts +++ b/src/api/routes/faucets.ts @@ -239,6 +239,12 @@ export const FaucetRoutes: FastifyPluginAsync< return FAUCET_DEFAULT_STX_AMOUNT; } + async function fetchNetworkChainID(network: StacksNetwork): Promise { + const rpcClient = clientFromNetwork(network); + const info = await rpcClient.getInfo(); + return info.network_id; + } + async function buildSTXFaucetTx( recipient: string, amount: bigint, @@ -258,6 +264,10 @@ export const FaucetRoutes: FastifyPluginAsync< nonce, }; if (fee) options.fee = fee; + + // Detect possible custom network chain ID + network.chainId = await fetchNetworkChainID(network); + return await makeSTXTokenTransfer(options); } catch (error: any) { if (