diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83b0024e17..f7b716d7ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,19 +26,20 @@ jobs: with: node-version-file: ".nvmrc" + # https://github.com/actions/cache/blob/main/examples.md#node---npm + - name: Get npm cache directory + id: npm-cache-dir + shell: bash + run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT} + - name: Cache node modules uses: actions/cache@v4 - env: - cache-name: cache-node-modules + id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' with: - path: | - ~/.npm - **/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-node- - name: Install deps run: npm ci --audit=false @@ -75,19 +76,20 @@ jobs: with: node-version-file: ".nvmrc" + # https://github.com/actions/cache/blob/main/examples.md#node---npm + - name: Get npm cache directory + id: npm-cache-dir + shell: bash + run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT} + - name: Cache node modules uses: actions/cache@v4 - env: - cache-name: cache-node-modules + id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' with: - path: | - ~/.npm - **/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-node- - name: Install deps run: npm ci --audit=false @@ -157,19 +159,20 @@ jobs: with: node-version-file: ".nvmrc" + # https://github.com/actions/cache/blob/main/examples.md#node---npm + - name: Get npm cache directory + id: npm-cache-dir + shell: bash + run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT} + - name: Cache node modules uses: actions/cache@v4 - env: - cache-name: cache-node-modules + id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' with: - path: | - ~/.npm - **/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-node- - name: Install deps run: npm ci --audit=false @@ -220,19 +223,20 @@ jobs: with: node-version-file: ".nvmrc" + # https://github.com/actions/cache/blob/main/examples.md#node---npm + - name: Get npm cache directory + id: npm-cache-dir + shell: bash + run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT} + - name: Cache node modules uses: actions/cache@v4 - env: - cache-name: cache-node-modules + id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' with: - path: | - ~/.npm - **/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-node- - name: Install deps run: npm ci --audit=false diff --git a/.github/workflows/vercel.yml b/.github/workflows/vercel.yml index 8902fa9276..998e2677dd 100644 --- a/.github/workflows/vercel.yml +++ b/.github/workflows/vercel.yml @@ -20,28 +20,29 @@ jobs: url: ${{ github.ref_name == 'master' && 'https://stacks-blockchain-api.vercel.app/' || 'https://stacks-blockchain-api-pbcblockstack-blockstack.vercel.app/' }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Use Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' + # https://github.com/actions/cache/blob/main/examples.md#node---npm + - name: Get npm cache directory + id: npm-cache-dir + shell: bash + run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT} + - name: Cache node modules - uses: actions/cache@v2 - env: - cache-name: cache-node-modules + uses: actions/cache@v4 + id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' with: - path: | - ~/.npm - **/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + path: ${{ steps.npm-cache-dir.outputs.dir }} + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-node- - name: Install deps run: npm ci --audit=false diff --git a/migrations/1741805265249_principal_activity_txs_idx-sort.js b/migrations/1741805265249_principal_activity_txs_idx-sort.js new file mode 100644 index 0000000000..b104f2954e --- /dev/null +++ b/migrations/1741805265249_principal_activity_txs_idx-sort.js @@ -0,0 +1,164 @@ +// @ts-check +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.up = pgm => { + /** + * A previous migration used `order` instead of `sort` in the index definition which caused it to be ignored and default to ASC. + * The `@ts-check` directive at the top of the file will catch these errors in the future. + */ + + pgm.dropIndex('principal_stx_txs', [], { name: 'idx_principal_stx_txs_optimized' }); + pgm.dropIndex('ft_events', [], { name: 'idx_ft_events_optimized' }); + pgm.dropIndex('nft_events', [], { name: 'idx_nft_events_optimized' }); + pgm.dropIndex('mempool_txs', [], { name: 'idx_mempool_txs_optimized' }); + + pgm.createIndex( + 'principal_stx_txs', + [ + 'principal', + { name: 'block_height', sort: 'DESC' }, + { name: 'microblock_sequence', sort: 'DESC' }, + { name: 'tx_index', sort: 'DESC' }], + { + name: 'idx_principal_stx_txs_optimized', + where: 'canonical = TRUE AND microblock_canonical = TRUE', + } + ); + + pgm.createIndex( + 'nft_events', + [ + 'sender', + 'recipient', + { name: 'block_height', sort: 'DESC' }, + { name: 'microblock_sequence', sort: 'DESC' }, + { name: 'tx_index', sort: 'DESC' }, + { name: 'event_index', sort: 'DESC' } + ], + { + name: 'idx_nft_events_optimized', + where: 'canonical = TRUE AND microblock_canonical = TRUE', + } + ); + + pgm.createIndex( + 'ft_events', + [ + 'sender', + { name: 'block_height', sort: 'DESC' }, + { name: 'microblock_sequence', sort: 'DESC' }, + { name: 'tx_index', sort: 'DESC' }, + { name: 'event_index', sort: 'DESC' } + ], + { + name: 'idx_ft_events_optimized_sender', + where: 'canonical = TRUE AND microblock_canonical = TRUE', + } + ); + + pgm.createIndex( + 'ft_events', + [ + 'recipient', + { name: 'block_height', sort: 'DESC' }, + { name: 'microblock_sequence', sort: 'DESC' }, + { name: 'tx_index', sort: 'DESC' }, + { name: 'event_index', sort: 'DESC' } + ], + { + name: 'idx_ft_events_optimized_recipient', + where: 'canonical = TRUE AND microblock_canonical = TRUE', + } + ); + + pgm.createIndex( + 'mempool_txs', + [ + { name: 'receipt_time', sort: 'DESC' } + ], + { + name: 'idx_mempool_txs_optimized', + where: 'pruned = FALSE', + } + ); + +}; + +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.down = pgm => { + pgm.dropIndex('principal_stx_txs', [], { name: 'idx_principal_stx_txs_optimized' }); + pgm.dropIndex('ft_events', [], { name: 'idx_ft_events_optimized_sender' }); + pgm.dropIndex('ft_events', [], { name: 'idx_ft_events_optimized_recipient' }); + pgm.dropIndex('nft_events', [], { name: 'idx_nft_events_optimized' }); + pgm.dropIndex('mempool_txs', [], { name: 'idx_mempool_txs_optimized' }); + + pgm.createIndex( + 'principal_stx_txs', + [ + 'principal', + // @ts-ignore + { name: 'block_height', order: 'DESC' }, + // @ts-ignore + { name: 'microblock_sequence', order: 'DESC' }, + // @ts-ignore + { name: 'tx_index', order: 'DESC' }], + { + name: 'idx_principal_stx_txs_optimized', + where: 'canonical = TRUE AND microblock_canonical = TRUE', + } + ); + + pgm.createIndex( + 'ft_events', + [ + 'sender', + 'recipient', + // @ts-ignore + { name: 'block_height', order: 'DESC' }, + // @ts-ignore + { name: 'microblock_sequence', order: 'DESC' }, + // @ts-ignore + { name: 'tx_index', order: 'DESC' }, + // @ts-ignore + { name: 'event_index', order: 'DESC' } + ], + { + name: 'idx_ft_events_optimized', + where: 'canonical = TRUE AND microblock_canonical = TRUE', + } + ); + + pgm.createIndex( + 'nft_events', + [ + 'sender', + 'recipient', + // @ts-ignore + { name: 'block_height', order: 'DESC' }, + // @ts-ignore + { name: 'microblock_sequence', order: 'DESC' }, + // @ts-ignore + { name: 'tx_index', order: 'DESC' }, + // @ts-ignore + { name: 'event_index', order: 'DESC' } + ], + { + name: 'idx_nft_events_optimized', + where: 'canonical = TRUE AND microblock_canonical = TRUE', + } + ); + + pgm.createIndex( + 'mempool_txs', + [ + 'sender_address', + 'sponsor_address', + 'token_transfer_recipient_address', + // @ts-ignore + { name: 'receipt_time', order: 'DESC' } + ], + { + name: 'idx_mempool_txs_optimized', + where: 'pruned = FALSE', + } + ); +}; diff --git a/src/api/controllers/cache-controller.ts b/src/api/controllers/cache-controller.ts index 2144cf2020..22ba5bcbc9 100644 --- a/src/api/controllers/cache-controller.ts +++ b/src/api/controllers/cache-controller.ts @@ -15,7 +15,7 @@ import { BlockParams } from '../routes/v2/schemas'; * state of the chain depending on the type of information being requested by the endpoint. * This entry will have an `ETag` string as the value. */ -enum ETagType { +export enum ETagType { /** ETag based on the latest `index_block_hash` or `microblock_hash`. */ chainTip = 'chain_tip', /** ETag based on a digest of all pending mempool `tx_id`s. */ @@ -149,7 +149,7 @@ async function calculateETag( } } -async function handleCache(type: ETagType, request: FastifyRequest, reply: FastifyReply) { +export async function handleCache(type: ETagType, request: FastifyRequest, reply: FastifyReply) { const metrics = getETagMetrics(); const ifNoneMatch = parseIfNoneMatchHeader(request.headers['if-none-match']); const etag = await calculateETag(request.server.db, type, request); diff --git a/src/api/pagination.ts b/src/api/pagination.ts index aef16a3d08..dc6c6ae351 100644 --- a/src/api/pagination.ts +++ b/src/api/pagination.ts @@ -40,6 +40,7 @@ export enum ResourceType { PoxCycle, TokenHolders, BlockSignerSignature, + FtBalance, } export const pagingQueryLimits: Record = { @@ -99,6 +100,10 @@ export const pagingQueryLimits: Record, @@ -118,5 +126,143 @@ export const AddressRoutesV2: FastifyPluginAsync< } ); + fastify.get( + '/:principal/balances/stx', + { + preHandler: (req, reply) => { + // TODO: use `ETagType.principal` instead of chaintip cache type when it's optimized + const etagType = req.query.include_mempool ? ETagType.principalMempool : ETagType.chainTip; + return handleCache(etagType, req, reply); + }, + schema: { + operationId: 'get_principal_stx_balance', + summary: 'Get principal STX balance', + description: `Retrieves STX account balance information for a given Address or Contract Identifier.`, + tags: ['Accounts'], + params: Type.Object({ + principal: PrincipalSchema, + }), + querystring: Type.Object({ + include_mempool: Type.Optional( + Type.Boolean({ + description: 'Include pending mempool transactions in the balance calculation', + default: false, + }) + ), + }), + response: { + 200: StxBalanceSchema, + }, + }, + }, + async (req, reply) => { + const stxAddress = req.params.principal; + validatePrincipal(stxAddress); + + const result = await fastify.db.sqlTransaction(async sql => { + const chainTip = await fastify.db.getChainTip(sql); + + // Get stx balance (sum of credits, debits, and fees) for address + const stxBalancesResult = await fastify.db.v2.getStxHolderBalance({ + sql, + stxAddress, + }); + let stxBalance = stxBalancesResult.found ? stxBalancesResult.result.balance : 0n; + + // Get pox-locked info for STX token + const stxPoxLockedResult = await fastify.db.v2.getStxPoxLockedAtBlock({ + sql, + stxAddress, + blockHeight: chainTip.block_height, + burnBlockHeight: chainTip.burn_block_height, + }); + + // Get miner rewards + const { totalMinerRewardsReceived } = await fastify.db.v2.getStxMinerRewardsAtBlock({ + sql, + stxAddress, + blockHeight: chainTip.block_height, + }); + stxBalance += totalMinerRewardsReceived; + + const result: StxBalance = { + balance: stxBalance.toString(), + total_miner_rewards_received: totalMinerRewardsReceived.toString(), + lock_tx_id: stxPoxLockedResult.lockTxId, + locked: stxPoxLockedResult.locked.toString(), + lock_height: stxPoxLockedResult.lockHeight, + burnchain_lock_height: stxPoxLockedResult.burnchainLockHeight, + burnchain_unlock_height: stxPoxLockedResult.burnchainUnlockHeight, + }; + + if (req.query.include_mempool) { + const mempoolResult = await fastify.db.getPrincipalMempoolStxBalanceDelta( + sql, + stxAddress + ); + const mempoolBalance = stxBalance + mempoolResult.delta; + result.estimated_balance = mempoolBalance.toString(); + result.pending_balance_inbound = mempoolResult.inbound.toString(); + result.pending_balance_outbound = mempoolResult.outbound.toString(); + } + + return result; + }); + await reply.send(result); + } + ); + + fastify.get( + '/:principal/balances/ft', + { + preHandler: handleChainTipCache, // TODO: use handlePrincipalCache once it's optimized + schema: { + operationId: 'get_principal_ft_balances', + summary: 'Get principal FT balances', + description: `Retrieves Fungible-token account balance information for a given Address or Contract Identifier.`, + tags: ['Accounts'], + params: Type.Object({ + principal: PrincipalSchema, + }), + querystring: Type.Object({ + limit: LimitParam(ResourceType.FtBalance), + offset: OffsetParam(), + }), + response: { + 200: PaginatedResponse(PrincipalFtBalanceSchema), + }, + }, + }, + async (req, reply) => { + const stxAddress = req.params.principal; + validatePrincipal(stxAddress); + const limit = getPagingQueryLimit(ResourceType.FtBalance, req.query.limit); + const offset = req.query.offset ?? 0; + const result = await fastify.db.sqlTransaction(async sql => { + // Get balances for fungible tokens + const ftBalancesResult = await fastify.db.v2.getFungibleTokenHolderBalances({ + sql, + stxAddress, + limit, + offset, + }); + const ftBalances: PrincipalFtBalance[] = ftBalancesResult.results.map( + ({ token, balance }) => ({ + token, + balance, + }) + ); + const result = { + limit, + offset, + total: ftBalancesResult.total, + results: ftBalances, + }; + return result; + }); + await reply.send(result); + } + ); + await Promise.resolve(); }; diff --git a/src/api/schemas/entities/addresses.ts b/src/api/schemas/entities/addresses.ts index a225c4c9ea..7c7fc44ab4 100644 --- a/src/api/schemas/entities/addresses.ts +++ b/src/api/schemas/entities/addresses.ts @@ -267,6 +267,12 @@ export const AddressBalanceSchema = Type.Object( ); export type AddressBalance = Static; +export const PrincipalFtBalanceSchema = Type.Object({ + token: Type.String(), + balance: Type.String(), +}); +export type PrincipalFtBalance = Static; + enum InboundStxTransferType { bulkSend = 'bulk-send', stxTransfer = 'stx-transfer', diff --git a/src/api/schemas/entities/balances.ts b/src/api/schemas/entities/balances.ts index c23c58491e..d4097994dd 100644 --- a/src/api/schemas/entities/balances.ts +++ b/src/api/schemas/entities/balances.ts @@ -36,9 +36,9 @@ export const StxBalanceSchema = Type.Object( description: 'Outbound STX balance from pending mempool transactions', }) ), - total_sent: Type.String(), - total_received: Type.String(), - total_fees_sent: Type.String(), + total_sent: Type.Optional(Type.String()), + total_received: Type.Optional(Type.String()), + total_fees_sent: Type.Optional(Type.String()), total_miner_rewards_received: Type.String(), lock_tx_id: Type.String({ description: 'The transaction where the lock event occurred. Empty if no tokens are locked.', @@ -62,3 +62,4 @@ export const StxBalanceSchema = Type.Object( }, { title: 'StxBalance' } ); +export type StxBalance = Static; diff --git a/src/datastore/pg-store-v2.ts b/src/datastore/pg-store-v2.ts index 236fba9cd9..08f7d951ca 100644 --- a/src/datastore/pg-store-v2.ts +++ b/src/datastore/pg-store-v2.ts @@ -18,7 +18,7 @@ import { BlockSignerSignatureLimitParamSchema, } from '../api/routes/v2/schemas'; import { InvalidRequestError, InvalidRequestErrorType } from '../errors'; -import { normalizeHashString } from '../helpers'; +import { FoundOrNot, normalizeHashString } from '../helpers'; import { DbPaginatedResult, DbBlock, @@ -38,6 +38,7 @@ import { DbPoxCycleSigner, DbPoxCycleSignerStacker, DbCursorPaginatedResult, + PoxSyntheticEventQueryResult, } from './common'; import { BLOCK_COLUMNS, @@ -45,7 +46,10 @@ import { TX_COLUMNS, parseTxQueryResult, parseAccountTransferSummaryTxQueryResult, + POX4_SYNTHETIC_EVENT_COLUMNS, + parseDbPoxSyntheticEvent, } from './helpers'; +import { SyntheticPoxEventName } from '../pox-helpers'; async function assertAddressExists(sql: PgSqlClient, address: string) { const addressCheck = @@ -973,4 +977,144 @@ export class PgStoreV2 extends BasePgStoreModule { }; }); } + + async getStxMinerRewardsAtBlock({ + sql, + stxAddress, + blockHeight, + }: { + sql: PgSqlClient; + stxAddress: string; + blockHeight: number; + }) { + const minerRewardQuery = await sql<{ amount: string }[]>` + SELECT sum( + coinbase_amount + tx_fees_anchored + tx_fees_streamed_confirmed + tx_fees_streamed_produced + ) amount + FROM miner_rewards + WHERE canonical = true AND recipient = ${stxAddress} AND mature_block_height <= ${blockHeight} + `; + const totalRewards = BigInt(minerRewardQuery[0]?.amount ?? 0); + return { + totalMinerRewardsReceived: totalRewards, + }; + } + + async getFungibleTokenHolderBalances(args: { + sql: PgSqlClient; + stxAddress: string; + limit: number; + offset: number; + }): Promise< + DbPaginatedResult<{ + token: string; + balance: string; + }> + > { + const queryResp = await args.sql<{ token: string; balance: string; total: string }[]>` + WITH filtered AS ( + SELECT token, balance + FROM ft_balances + WHERE address = ${args.stxAddress} + AND balance > 0 + AND token != 'stx' + ) + SELECT token, balance, COUNT(*) OVER() AS total + FROM filtered + ORDER BY LOWER(token) + LIMIT ${args.limit} + OFFSET ${args.offset}; + `; + const parsed = queryResp.map(({ token, balance }) => ({ token, balance })); + const total = queryResp.length > 0 ? parseInt(queryResp[0].total) : 0; + return { + limit: args.limit, + offset: args.offset, + total, + results: parsed, + }; + } + + async getStxHolderBalance(args: { + sql: PgSqlClient; + stxAddress: string; + }): Promise> { + const [result] = await args.sql<{ balance: string }[]>` + SELECT token, balance FROM ft_balances + WHERE address = ${args.stxAddress} + AND token = 'stx' + LIMIT 1 + `; + if (!result) { + return { found: false }; + } + return { + found: true, + result: { balance: BigInt(result.balance) }, + }; + } + + async getStxPoxLockedAtBlock({ + sql, + stxAddress, + blockHeight, + burnBlockHeight, + }: { + sql: PgSqlClient; + stxAddress: string; + blockHeight: number; + burnBlockHeight: number; + }) { + let lockTxId: string = ''; + let locked: bigint = 0n; + let lockHeight = 0; + let burnchainLockHeight = 0; + let burnchainUnlockHeight = 0; + + // == PoX-4 ================================================================ + // Query for the latest lock event that still applies to the current burn block height. + // Special case for `handle-unlock` which should be returned if it is the last received event. + + const pox4EventQuery = await sql` + SELECT ${sql(POX4_SYNTHETIC_EVENT_COLUMNS)} + FROM pox4_events + WHERE canonical = true AND microblock_canonical = true AND stacker = ${stxAddress} + AND block_height <= ${blockHeight} + AND ( + (name != ${ + SyntheticPoxEventName.HandleUnlock + } AND burnchain_unlock_height >= ${burnBlockHeight}) + OR + (name = ${ + SyntheticPoxEventName.HandleUnlock + } AND burnchain_unlock_height < ${burnBlockHeight}) + ) + ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC + LIMIT 1 + `; + if (pox4EventQuery.length > 0) { + const pox4Event = parseDbPoxSyntheticEvent(pox4EventQuery[0]); + if (pox4Event.name !== SyntheticPoxEventName.HandleUnlock) { + lockTxId = pox4Event.tx_id; + locked = pox4Event.locked; + burnchainUnlockHeight = Number(pox4Event.burnchain_unlock_height); + lockHeight = pox4Event.block_height; + + const [burnBlockQuery] = await sql<{ burn_block_height: string }[]>` + SELECT burn_block_height FROM blocks + WHERE block_height = ${blockHeight} AND canonical = true + LIMIT 1 + `; + burnchainLockHeight = parseInt(burnBlockQuery?.burn_block_height ?? '0'); + } + } + + return { + lockTxId, + locked, + lockHeight, + burnchainLockHeight, + burnchainUnlockHeight, + }; + } } diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index bd090626e3..b5157ea970 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -2482,20 +2482,25 @@ export class PgStore extends BasePgStore { */ async getPrincipalMempoolStxBalanceDelta(sql: PgSqlClient, principal: string) { const results = await sql<{ inbound: string; outbound: string; delta: string }[]>` - WITH sent AS ( + WITH latest AS ( + SELECT * FROM mempool_txs + WHERE pruned = false + ORDER BY receipt_time DESC + ), + sent AS ( SELECT SUM(COALESCE(token_transfer_amount, 0) + fee_rate) AS total - FROM mempool_txs - WHERE pruned = false AND sender_address = ${principal} + FROM latest + WHERE sender_address = ${principal} ), sponsored AS ( SELECT SUM(fee_rate) AS total - FROM mempool_txs - WHERE pruned = false AND sponsor_address = ${principal} AND sponsored = true + FROM latest + WHERE sponsor_address = ${principal} AND sponsored = true ), received AS ( SELECT SUM(COALESCE(token_transfer_amount, 0)) AS total - FROM mempool_txs - WHERE pruned = false AND token_transfer_recipient_address = ${principal} + FROM latest + WHERE token_transfer_recipient_address = ${principal} ), values AS ( SELECT diff --git a/tests/api/address.test.ts b/tests/api/address.test.ts index 43ab73cdb7..84f171c779 100644 --- a/tests/api/address.test.ts +++ b/tests/api/address.test.ts @@ -1626,6 +1626,66 @@ describe('address tests', () => { }; expect(JSON.parse(fetchAddrBalance2.text)).toEqual(expectedResp2); + const fetchAddrV2BalanceStx = await supertest(api.server).get( + `/extended/v2/addresses/${testContractAddr}/balances/stx` + ); + expect(fetchAddrV2BalanceStx.status).toBe(200); + expect(fetchAddrV2BalanceStx.type).toBe('application/json'); + expect(fetchAddrV2BalanceStx.body).toEqual({ + balance: '131', + total_miner_rewards_received: '0', + lock_tx_id: '', + locked: '0', + lock_height: 0, + burnchain_lock_height: 0, + burnchain_unlock_height: 0, + }); + + const fetchAddrV2BalanceStxWithMempool = await supertest(api.server).get( + `/extended/v2/addresses/${testContractAddr}/balances/stx?include_mempool=true` + ); + expect(fetchAddrV2BalanceStxWithMempool.status).toBe(200); + expect(fetchAddrV2BalanceStxWithMempool.type).toBe('application/json'); + expect(fetchAddrV2BalanceStxWithMempool.body).toEqual({ + balance: '131', + estimated_balance: '131', + pending_balance_inbound: '0', + pending_balance_outbound: '0', + total_miner_rewards_received: '0', + lock_tx_id: '', + locked: '0', + lock_height: 0, + burnchain_lock_height: 0, + burnchain_unlock_height: 0, + }); + + const fetchAddrV2BalanceFts = await supertest(api.server).get( + `/extended/v2/addresses/${testContractAddr}/balances/ft` + ); + expect(fetchAddrV2BalanceFts.status).toBe(200); + expect(fetchAddrV2BalanceFts.type).toBe('application/json'); + expect(fetchAddrV2BalanceFts.body).toEqual({ + limit: 100, + offset: 0, + total: 2, + results: [ + { token: 'bux', balance: '375' }, + { token: 'gox', balance: '585' }, + ], + }); + + const fetchAddrV2BalanceFtsPaginated = await supertest(api.server).get( + `/extended/v2/addresses/${testContractAddr}/balances/ft?limit=1&offset=1` + ); + expect(fetchAddrV2BalanceFtsPaginated.status).toBe(200); + expect(fetchAddrV2BalanceFtsPaginated.type).toBe('application/json'); + expect(fetchAddrV2BalanceFtsPaginated.body).toEqual({ + limit: 1, + offset: 1, + total: 2, + results: [{ token: 'gox', balance: '585' }], + }); + const tokenLocked: DbTokenOfferingLocked = { address: testContractAddr, value: BigInt(4139391122), diff --git a/tests/api/mempool.test.ts b/tests/api/mempool.test.ts index b299352505..2a6eeda420 100644 --- a/tests/api/mempool.test.ts +++ b/tests/api/mempool.test.ts @@ -2284,6 +2284,14 @@ describe('mempool tests', () => { expect(balance3.body.pending_balance_inbound).toEqual('100'); expect(balance3.body.pending_balance_outbound).toEqual('250'); + const balanceV2_1 = await supertest(api.server).get( + `/extended/v2/addresses/${address}/balances/stx?include_mempool=true` + ); + expect(balanceV2_1.body.balance).toEqual('2000'); + expect(balanceV2_1.body.estimated_balance).toEqual('1850'); // Plus amount + expect(balanceV2_1.body.pending_balance_inbound).toEqual('100'); + expect(balanceV2_1.body.pending_balance_outbound).toEqual('250'); + // Confirm all txs await db.update( new TestBlockBuilder({ @@ -2329,5 +2337,13 @@ describe('mempool tests', () => { expect(balance4.body.estimated_balance).toEqual('1850'); expect(balance4.body.pending_balance_inbound).toEqual('0'); expect(balance4.body.pending_balance_outbound).toEqual('0'); + + const balanceV2_2 = await supertest(api.server).get( + `/extended/v2/addresses/${address}/balances/stx?include_mempool=true` + ); + expect(balanceV2_2.body.balance).toEqual('1850'); + expect(balanceV2_2.body.estimated_balance).toEqual('1850'); + expect(balanceV2_2.body.pending_balance_inbound).toEqual('0'); + expect(balanceV2_2.body.pending_balance_outbound).toEqual('0'); }); });