diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71c5db6fb..faa0c7f9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,7 @@ jobs: api:pox5, # krypton:bns-e2e, # krypton:faucet-btc, + # krypton:faucet-sbtc, # krypton:faucet-stx, # krypton:pox-4-btc-address-formats, # krypton:pox-4-burnchain-delegate-stx, diff --git a/openapi.yaml b/openapi.yaml index 20bcd90c6..185a207e6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -80279,6 +80279,75 @@ paths: type: string help: type: string + /extended/v1/faucets/sbtc: + post: + operationId: run_faucet_sbtc + summary: Get sBTC testnet tokens + tags: + - Faucets + description: >- + Add sBTC tokens to the specified testnet address. The endpoint performs + a SIP-010 `transfer` + contract call on the configured testnet sBTC token contract. Testnet STX addresses begin with `ST`. + + The endpoint returns the transaction ID, which you can use to view the transaction in the + [Stacks Explorer](https://explorer.hiro.so/?chain=testnet). The tokens are delivered once the transaction has + been included in a block. + + **Note:** This is a testnet only endpoint. This endpoint will not work on the mainnet. + parameters: + - schema: + type: string + example: ST3M7N9Q9HDRM7RVP1Q26P0EE69358PZZAZD7KMXQ + in: query + name: address + required: false + description: A valid testnet STX address + responses: + "200": + description: POST request that initiates a transfer of tokens to a specified + testnet address + content: + application/json: + schema: + title: RunFaucetResponse + description: POST request that initiates a transfer of tokens to a specified + testnet address + type: object + required: + - success + - txId + - txRaw + properties: + success: + description: Indicates if the faucet call was successful + type: boolean + enum: + - true + txId: + description: The transaction ID for the faucet call + type: string + txRaw: + description: Raw transaction in hex string representation + type: string + 4XX: + description: Default Response + content: + application/json: + schema: + type: object + required: + - success + - error + properties: + success: + description: Indicates if the faucet call was successful + type: boolean + enum: + - false + error: + description: Error message + type: string /extended/v2/blocks/: get: operationId: get_blocks @@ -94121,6 +94190,380 @@ paths: type: string message: type: string + /extended/v3/principals/{principal}/balances/stx: + get: + operationId: get_principal_stx_balance + summary: Get principal STX balance + tags: + - Accounts + description: "Get a principal's STX balance: its total and spendable (available) + balance, any locked STX, and the projected balance from pending mempool + transactions." + parameters: + - schema: + anyOf: + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41} + title: Stacks Address + description: Stacks Address + examples: + - SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP + type: string + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$ + title: Smart Contract ID + description: Smart Contract ID + examples: + - SP000000000000000000002Q6VF78.pox-3 + type: string + in: path + name: principal + required: true + responses: + "200": + description: Default Response + content: + application/json: + schema: + title: PrincipalStxBalance + type: object + required: + - balance + - available + - locked + - mempool + properties: + balance: + description: Total micro-STX balance (available plus locked) + type: string + available: + description: Spendable micro-STX balance (balance minus locked) + type: string + locked: + anyOf: + - title: StxLock + type: object + required: + - amount + - pox_version + - lock_tx_id + - stacks_lock_height + - burn_lock_height + - burn_unlock_height + properties: + amount: + description: The amount of locked micro-STX + type: string + pox_version: + description: The PoX contract version that created the lock (e.g. 4, 5) + type: integer + lock_tx_id: + pattern: ^(0x)?[a-fA-F0-9]{64}$ + title: Transaction ID + description: Transaction ID + examples: + - "0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382\ + a7e4b6a13df3dd7a91c6" + type: string + stacks_lock_height: + description: The Stacks block height at which the lock was created + type: integer + burn_lock_height: + description: The burnchain block height at which the lock was created + type: integer + burn_unlock_height: + description: The burnchain block height at which the locked STX unlocks + type: integer + - type: "null" + mempool: + anyOf: + - title: StxMempoolBalance + type: object + required: + - estimated_balance + - inbound + - outbound + properties: + estimated_balance: + description: Estimated spendable micro-STX balance once pending mempool txs + confirm + type: string + inbound: + description: Pending inbound micro-STX from the mempool + type: string + outbound: + description: Pending outbound micro-STX from the mempool (transfers plus fees) + type: string + - type: "null" + 4XX: + description: Default Response + content: + application/json: + schema: + title: Error Response + additionalProperties: true + type: object + required: + - error + properties: + error: + type: string + message: + type: string + /extended/v3/principals/{principal}/balances/ft: + get: + operationId: get_principal_ft_balances + summary: Get principal FT balances + tags: + - Accounts + description: Get a principal's fungible-token balances, sorted by balance descending. + parameters: + - schema: + minimum: 1 + default: 100 + maximum: 200 + type: integer + in: query + name: limit + required: false + description: Number of results per page + - schema: + pattern: ^\d+:.+$ + type: string + in: query + name: cursor + required: false + description: "Cursor for paginating FT balances (sorted by balance, descending). + Format: balance:asset_identifier" + - schema: + anyOf: + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41} + title: Stacks Address + description: Stacks Address + examples: + - SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP + type: string + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$ + title: Smart Contract ID + description: Smart Contract ID + examples: + - SP000000000000000000002Q6VF78.pox-3 + type: string + in: path + name: principal + required: true + responses: + "200": + description: Default Response + content: + application/json: + schema: + type: object + required: + - total + - limit + - cursor + - results + properties: + total: + type: integer + example: 1 + limit: + minimum: 1 + default: 100 + maximum: 200 + description: Number of results per page + type: integer + cursor: + type: object + required: + - next + - previous + - current + properties: + next: + anyOf: + - pattern: ^\d+:.+$ + description: "Cursor for paginating FT balances (sorted by balance, descending). + Format: balance:asset_identifier" + type: string + - type: "null" + previous: + anyOf: + - pattern: ^\d+:.+$ + description: "Cursor for paginating FT balances (sorted by balance, descending). + Format: balance:asset_identifier" + type: string + - type: "null" + current: + anyOf: + - pattern: ^\d+:.+$ + description: "Cursor for paginating FT balances (sorted by balance, descending). + Format: balance:asset_identifier" + type: string + - type: "null" + results: + type: array + items: + title: PrincipalFtPosition + type: object + required: + - asset_identifier + - balance + properties: + asset_identifier: + description: Fungible token asset identifier + type: string + example: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token + balance: + description: The principal's balance of this token, as a string-quoted integer + in base units + type: string + 4XX: + description: Default Response + content: + application/json: + schema: + title: Error Response + additionalProperties: true + type: object + required: + - error + properties: + error: + type: string + message: + type: string + /extended/v3/principals/{principal}/balances/nft: + get: + operationId: get_principal_nft_balances + summary: Get principal NFT balances + tags: + - Accounts + description: Get the non-fungible token instances currently owned by a + principal, ordered by asset identifier and value. + parameters: + - schema: + minimum: 1 + default: 100 + maximum: 200 + type: integer + in: query + name: limit + required: false + description: Number of results per page + - schema: + pattern: ^0x[0-9a-fA-F]*:.+$ + type: string + in: query + name: cursor + required: false + description: "Cursor for paginating NFT balances (sorted by asset identifier + then value). Format: value:asset_identifier" + - schema: + anyOf: + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41} + title: Stacks Address + description: Stacks Address + examples: + - SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP + type: string + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$ + title: Smart Contract ID + description: Smart Contract ID + examples: + - SP000000000000000000002Q6VF78.pox-3 + type: string + in: path + name: principal + required: true + responses: + "200": + description: Default Response + content: + application/json: + schema: + type: object + required: + - total + - limit + - cursor + - results + properties: + total: + type: integer + example: 1 + limit: + minimum: 1 + default: 100 + maximum: 200 + description: Number of results per page + type: integer + cursor: + type: object + required: + - next + - previous + - current + properties: + next: + anyOf: + - pattern: ^0x[0-9a-fA-F]*:.+$ + description: "Cursor for paginating NFT balances (sorted by asset identifier + then value). Format: value:asset_identifier" + type: string + - type: "null" + previous: + anyOf: + - pattern: ^0x[0-9a-fA-F]*:.+$ + description: "Cursor for paginating NFT balances (sorted by asset identifier + then value). Format: value:asset_identifier" + type: string + - type: "null" + current: + anyOf: + - pattern: ^0x[0-9a-fA-F]*:.+$ + description: "Cursor for paginating NFT balances (sorted by asset identifier + then value). Format: value:asset_identifier" + type: string + - type: "null" + results: + type: array + items: + title: PrincipalNftPosition + type: object + required: + - asset_identifier + - value + properties: + asset_identifier: + description: Non-fungible token asset identifier + type: string + example: SP2X0TZ59D5SZ8ACQ6YMCHHNR2ZN51Z32E2CJ173.the-explorer-guild::The-Explorer-Guild + value: + description: The NFT instance identifier, as a Clarity value + type: object + required: + - hex + - repr + properties: + hex: + type: string + repr: + type: string + 4XX: + description: Default Response + content: + application/json: + schema: + title: Error Response + additionalProperties: true + type: object + required: + - error + properties: + error: + type: string + message: + type: string /extended/v3/staking/bonds: get: operationId: get_bonds diff --git a/package.json b/package.json index 32770d4a4..698ba04fb 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:snp": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/snp/setup.ts --test-concurrency=1 ./tests/snp/**/*.test.ts", "test:krypton:bns-e2e": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/bns-e2e/**/*.test.ts", "test:krypton:faucet-btc": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/faucet-btc/**/*.test.ts", + "test:krypton:faucet-sbtc": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/faucet-sbtc/**/*.test.ts", "test:krypton:faucet-stx": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/faucet-stx/**/*.test.ts", "test:krypton:pox-4-btc-address-formats": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/pox-4-btc-address-formats/**/*.test.ts", "test:krypton:pox-4-burnchain-delegate-stx": "NODE_ENV=test node --import tsx --test --test-global-setup=./tests/krypton/setup.ts --test-concurrency=1 ./tests/krypton/pox-4-burnchain-delegate-stx/**/*.test.ts", diff --git a/src/api/routes/v1/faucets.ts b/src/api/routes/v1/faucets.ts index 8f3f3df72..bdb4797da 100644 --- a/src/api/routes/v1/faucets.ts +++ b/src/api/routes/v1/faucets.ts @@ -2,12 +2,19 @@ import * as btc from 'bitcoinjs-lib'; import PQueue from 'p-queue'; import { BigNumber } from 'bignumber.js'; import { + ContractIdString, getAddressFromPrivateKey, + makeContractCall, makeSTXTokenTransfer, + noneCV, + Pc, + principalCV, privateKeyToPublic, publicKeyToHex, + SignedContractCallOptions, SignedTokenTransferOptions, StacksTransactionWire, + uintCV, } from '@stacks/transactions'; import type { StacksNetwork } from '@stacks/network'; import { @@ -111,6 +118,16 @@ export const FaucetRoutes: FastifyPluginAsync< done(); }; + const sbtcFaucetEnabledMiddleware: preHandlerHookHandler = (_req, reply, done) => { + if (!ENV.TESTNET_SBTC_FAUCET_ENABLED) { + return reply.status(403).send({ + error: 'sBTC faucet is not enabled', + success: false, + }); + } + done(); + }; + const missingBtcConfigMiddleware: preHandlerHookHandler = (_req, reply, done) => { try { getRpcClient(); @@ -539,4 +556,159 @@ export const FaucetRoutes: FastifyPluginAsync< }); } ); + + const sbtcFaucetRequestQueue = new PQueue({ concurrency: 1 }); + + async function buildSBTCFaucetTx( + recipient: string, + amount: bigint, + network: StacksNetwork, + senderKey: string, + nonce: bigint, + fee?: bigint + ): Promise { + const [contractId, assetName] = ENV.TESTNET_SBTC_FAUCET_ASSET_IDENTIFIER.split('::') as [ + ContractIdString, + string, + ]; + const [contractAddress, contractName] = contractId.split('.'); + const senderAddress = getAddressFromPrivateKey(senderKey, 'testnet'); + try { + const options: SignedContractCallOptions = { + contractAddress, + contractName, + functionName: 'transfer', + functionArgs: [ + uintCV(amount), + principalCV(senderAddress), + principalCV(recipient), + noneCV(), + ], + senderKey, + network, + nonce, + postConditions: [Pc.principal(senderAddress).willSendEq(amount).ft(contractId, assetName)], + }; + if (fee) options.fee = fee; + + // Detect possible custom network chain ID + network.chainId = await fetchNetworkChainID(network); + + return await makeContractCall(options); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if ( + fee === undefined && + (error as Error).message && + /estimating transaction fee|NoEstimateAvailable/.test(error.message) + ) { + return await buildSBTCFaucetTx(recipient, amount, network, senderKey, nonce, 1000n); + } + throw error; + } + } + + fastify.post( + '/sbtc', + { + preHandler: sbtcFaucetEnabledMiddleware, + schema: { + operationId: 'run_faucet_sbtc', + summary: 'Get sBTC testnet tokens', + description: `Add sBTC tokens to the specified testnet address. The endpoint performs a SIP-010 \`transfer\` + contract call on the configured testnet sBTC token contract. Testnet STX addresses begin with \`ST\`. + + The endpoint returns the transaction ID, which you can use to view the transaction in the + [Stacks Explorer](https://explorer.hiro.so/?chain=testnet). The tokens are delivered once the transaction has + been included in a block. + + **Note:** This is a testnet only endpoint. This endpoint will not work on mainnet.`, + tags: ['Faucets'], + querystring: Type.Object({ + address: Type.Optional( + Type.String({ + description: 'A valid testnet STX address', + examples: ['ST3M7N9Q9HDRM7RVP1Q26P0EE69358PZZAZD7KMXQ'], + }) + ), + }), + response: { + 200: RunFaucetResponseSchema, + '4xx': Type.Object({ + success: Type.Literal(false, { + description: 'Indicates if the faucet call was successful', + }), + error: Type.String({ description: 'Error message' }), + }), + }, + }, + }, + async (req, reply) => { + const recipientAddress = req.query.address; + if (!recipientAddress) { + return await reply.status(400).send({ + error: 'address required', + success: false, + }); + } + + await sbtcFaucetRequestQueue.add(async () => { + // 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 forwardedFor = req.headers['x-forwarded-for']; + const ip = + (Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(',')[0])?.trim() ?? + req.ip; + const now = Date.now(); + + if (isProdEnv) { + const lastRequests = await fastify.db.getSBTCFaucetRequests(recipientAddress); + const requestsInWindow = lastRequests.results + .map(r => now - r.occurred_at) + .filter(r => r <= FAUCET_DEFAULT_WINDOW); + if (requestsInWindow.length >= FAUCET_DEFAULT_TRIGGER_COUNT) { + logger.warn(`SbtcFaucet rate limit hit for address ${recipientAddress}`); + return await reply.status(429).send({ + error: 'Too many requests', + success: false, + }); + } + } + + const senderKey = STX_FAUCET_KEYS[0]; + const senderAddress = getAddressFromPrivateKey(senderKey, 'testnet'); + const sbtcAmount = BigInt(ENV.TESTNET_SBTC_FAUCET_AMOUNT); + const network = STX_FAUCET_NETWORK(); + const rpcClient = clientFromNetwork(network); + + logger.debug(`SbtcFaucet attempting faucet transaction from sender: ${senderAddress}`); + const nonces = await fastify.db.getAddressNonces({ stxAddress: senderAddress }); + const tx = await buildSBTCFaucetTx( + recipientAddress, + sbtcAmount, + network, + senderKey, + BigInt(nonces.possibleNextNonce) + ); + const rawTxHex = tx.serialize(); + const res = await rpcClient.sendTransaction(Buffer.from(rawTxHex, 'hex')); + logger.info( + `SbtcFaucet success. Sent ${sbtcAmount} sBTC sats from ${senderAddress} to ${recipientAddress} (txId: ${res.txId}).` + ); + + await fastify.writeDb?.insertFaucetRequest({ + ip: `${ip}`, + address: recipientAddress, + currency: DbFaucetRequestCurrency.SBTC, + occurred_at: now, + }); + await reply.send({ + success: true, + txId: res.txId, + txRaw: rawTxHex, + }); + }); + } + ); }; diff --git a/src/datastore/common.ts b/src/datastore/common.ts index 4a1bb17c3..dc35f0947 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -362,6 +362,7 @@ export interface DbSmartContract { export enum DbFaucetRequestCurrency { BTC = 'btc', STX = 'stx', + SBTC = 'sbtc', } export interface DbFaucetRequest { diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index 5bc3910dc..5f8982814 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -3336,6 +3336,18 @@ export class PgStore extends BasePgStore { return { results }; } + async getSBTCFaucetRequests(address: string) { + const queryResult = await this.sql` + SELECT ip, address, currency, occurred_at + FROM faucet_requests + WHERE address = ${address} AND currency = 'sbtc' + ORDER BY occurred_at DESC + LIMIT 5 + `; + const results = queryResult.map(r => parseFaucetRequestQueryResult(r)); + return { results }; + } + async getRawTx(txId: string) { // Note the extra "limit 1" statements are only query hints const result = await this.sql` diff --git a/src/env.ts b/src/env.ts index 27cbd261c..11df10d4f 100644 --- a/src/env.ts +++ b/src/env.ts @@ -210,6 +210,23 @@ const schema = Type.Object({ TESTNET_BTC_FAUCET_ENABLED: Type.Boolean({ default: true }), /** Enable the STX faucet endpoints on Stacks testnet. */ TESTNET_STX_FAUCET_ENABLED: Type.Boolean({ default: true }), + /** + * Enable the sBTC faucet endpoints on Stacks testnet. Requires the first key in + * `FAUCET_PRIVATE_KEY` to hold a balance of the token configured in + * `TESTNET_SBTC_FAUCET_ASSET_IDENTIFIER`. + */ + TESTNET_SBTC_FAUCET_ENABLED: Type.Boolean({ default: false }), + /** + * Fully qualified asset identifier for the testnet sBTC SIP-010 token which the sBTC faucet + * will send via its `transfer` function, in the form + * `{contract_address}.{contract_name}::{asset_name}`. + */ + TESTNET_SBTC_FAUCET_ASSET_IDENTIFIER: Type.String({ + default: 'ST1F7QA2MDF17S807EPA36TSS8AMEFY4KA9TVGWXT.sbtc-token::sbtc-token', + pattern: '^[A-Z0-9]+\\.[a-zA-Z]([a-zA-Z0-9]|[-_])*::[a-zA-Z]([a-zA-Z0-9]|[-_])*$', + }), + /** Amount of sBTC (in satoshis) sent per sBTC faucet request. */ + TESTNET_SBTC_FAUCET_AMOUNT: Type.Integer({ default: 10_000, minimum: 1 }), /** * A comma-separated list of STX private keys which will send faucet transactions to accounts that * request them. Attempts will always be made from the first account, only once transaction diff --git a/tests/krypton/faucet-sbtc/faucet-sbtc.test.ts b/tests/krypton/faucet-sbtc/faucet-sbtc.test.ts new file mode 100644 index 000000000..2800ad60f --- /dev/null +++ b/tests/krypton/faucet-sbtc/faucet-sbtc.test.ts @@ -0,0 +1,110 @@ +import supertest from 'supertest'; +import { ClarityVersion, makeContractDeploy } from '@stacks/transactions'; +import { ENV } from '../../../src/env.ts'; +import { RunFaucetResponse } from '../../../src/api/schemas/v1/responses/responses.ts'; +import { AddressBalance } from '../../../src/api/schemas/v1/entities/addresses.ts'; +import { FAUCET_TESTNET_KEYS } from '../../../src/api/routes/v1/faucets.ts'; +import { + Account, + accountFromKey, + standByForTxSuccess, + fetchGet, + KryptonContext, + getKryptonContext, + stopKryptonContext, +} from '../krypton-env.ts'; +import assert from 'node:assert/strict'; +import { after, before, describe, test } from 'node:test'; + +const SBTC_CONTRACT_NAME = 'sbtc-token'; +const SBTC_ASSET_NAME = 'sbtc-token'; +const SBTC_CONTRACT_SOURCE = ` +(define-fungible-token ${SBTC_ASSET_NAME}) +(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34)))) + (begin + (asserts! (or (is-eq tx-sender sender) (is-eq contract-caller sender)) (err u4)) + (ft-transfer? ${SBTC_ASSET_NAME} amount sender recipient))) +(ft-mint? ${SBTC_ASSET_NAME} u1000000000 tx-sender) +`; + +describe('sBTC Faucet', () => { + const reqAccountKey = 'b1ee37d996b1cf95ff67996a38426cff398d3adfeccf8ae8b3651a530837dd5801'; + let reqAccount: Account; + let faucetAccount: Account; + let assetIdentifier: string; + let reqTx: RunFaucetResponse; + let ctx: KryptonContext; + + before(async () => { + ctx = await getKryptonContext(); + reqAccount = accountFromKey(reqAccountKey); + // The sBTC faucet sends from the first `FAUCET_PRIVATE_KEY` key, which defaults to the first + // testnet seeded key. Deploy the token contract from that same account so it owns the minted + // supply. + faucetAccount = accountFromKey(FAUCET_TESTNET_KEYS[0].secretKey); + assetIdentifier = `${faucetAccount.stxAddr}.${SBTC_CONTRACT_NAME}::${SBTC_ASSET_NAME}`; + + const nonces = await ctx.db.getAddressNonces({ stxAddress: faucetAccount.stxAddr }); + const deployTx = await makeContractDeploy({ + contractName: SBTC_CONTRACT_NAME, + codeBody: SBTC_CONTRACT_SOURCE, + senderKey: faucetAccount.secretKey, + network: ctx.stacksNetwork, + nonce: BigInt(nonces.possibleNextNonce), + fee: 10000n, + clarityVersion: ClarityVersion.Clarity2, + }); + const deployResult = await ctx.client.sendTransaction(Buffer.from(deployTx.serialize(), 'hex')); + await standByForTxSuccess(deployResult.txId, ctx); + + ENV.TESTNET_SBTC_FAUCET_ENABLED = true; + ENV.TESTNET_SBTC_FAUCET_ASSET_IDENTIFIER = assetIdentifier; + }); + + after(async () => { + await stopKryptonContext(ctx); + }); + + test('sBTC faucet address required', async () => { + const response = await supertest(ctx.api.server).post(`/extended/v1/faucets/sbtc`); + assert.equal(response.status, 400); + assert.equal(response.body.success, false); + assert.equal(response.body.error, 'address required'); + }); + + test('sBTC faucet http request succeeds', async () => { + const response = await supertest(ctx.api.server).post( + `/extended/v1/faucets/sbtc?address=${reqAccount.stxAddr}` + ); + assert.equal(response.status, 200); + reqTx = response.body; + assert.equal(reqTx.success, true); + }); + + test('sBTC faucet tx mined successfully', async () => { + const tx = await standByForTxSuccess(reqTx.txId, ctx); + assert.equal(tx.sender_address, faucetAccount.stxAddr); + assert.equal(tx.contract_call_contract_id, `${faucetAccount.stxAddr}.${SBTC_CONTRACT_NAME}`); + assert.equal(tx.contract_call_function_name, 'transfer'); + }); + + test('sBTC faucet recipient balance', async () => { + const addrBalance = await fetchGet( + `/extended/v1/address/${reqAccount.stxAddr}/balances`, + ctx + ); + const ftBalance = addrBalance.fungible_tokens[assetIdentifier]; + assert.ok(ftBalance); + assert.equal(BigInt(ftBalance.balance), BigInt(ENV.TESTNET_SBTC_FAUCET_AMOUNT)); + }); + + test('sBTC faucet disabled', async () => { + ENV.TESTNET_SBTC_FAUCET_ENABLED = false; + const response = await supertest(ctx.api.server).post( + `/extended/v1/faucets/sbtc?address=${reqAccount.stxAddr}` + ); + assert.equal(response.status, 403); + assert.equal(response.body.error, 'sBTC faucet is not enabled'); + ENV.TESTNET_SBTC_FAUCET_ENABLED = true; + }); +});