diff --git a/docs/api/tokens/get-ft-holders.example.json b/docs/api/tokens/get-ft-holders.example.json new file mode 100644 index 0000000000..3a08e584b1 --- /dev/null +++ b/docs/api/tokens/get-ft-holders.example.json @@ -0,0 +1,20 @@ +{ + "limit": 100, + "offset": 0, + "total": 3, + "total_supply": "11700", + "results": [ + { + "address": "SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA2K", + "balance": "10000" + }, + { + "address": "SP3WJYXJZ4QK2V5V9VX2VXVZ6VXVZ6V2V5V2V2V2V", + "balance": "900" + }, + { + "address": "SP3WJYXJZ4QK2V5V9VX2VXVZ6VXVZ6V2V5V2V2V2V", + "balance": "800" + } + ] +} diff --git a/docs/api/tokens/get-ft-holders.schema.json b/docs/api/tokens/get-ft-holders.schema.json new file mode 100644 index 0000000000..3ce0509bda --- /dev/null +++ b/docs/api/tokens/get-ft-holders.schema.json @@ -0,0 +1,38 @@ +{ + "description": "List of Fungible Token holders", + "title": "FungibleTokenHolderList", + "type": "object", + "required": [ + "total_supply", + "results", + "limit", + "offset", + "total" + ], + "additionalProperties": false, + "properties": { + "limit": { + "type": "integer", + "maximum": 200, + "description": "The number of holders to return" + }, + "offset": { + "type": "integer", + "description": "The number to holders to skip (starting at `0`)" + }, + "total": { + "type": "integer", + "description": "The number of holders available" + }, + "total_supply": { + "type": "string", + "description": "The total supply of the token (the sum of all balances)" + }, + "results": { + "type": "array", + "items": { + "$ref": "../../entities/tokens/ft-holder-entry.schema.json" + } + } + } +} diff --git a/docs/entities/tokens/ft-holder-entry.schema.json b/docs/entities/tokens/ft-holder-entry.schema.json new file mode 100644 index 0000000000..1454ae8904 --- /dev/null +++ b/docs/entities/tokens/ft-holder-entry.schema.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "title": "FtHolderEntry", + "required": ["address", "balance"], + "additionalProperties": false, + "properties": { + "address": { + "type": "string" + }, + "balance": { + "type": "string" + } + } +} diff --git a/docs/generated.d.ts b/docs/generated.d.ts index a2a0851ff4..5a42986cca 100644 --- a/docs/generated.d.ts +++ b/docs/generated.d.ts @@ -103,6 +103,7 @@ export type SchemaMergeRootStub = | PoxCycleSignerStackersListResponse | PoxCycleSignersListResponse | PoxCycleListResponse + | FungibleTokenHolderList | { [k: string]: unknown | undefined; } @@ -193,6 +194,7 @@ export type SchemaMergeRootStub = | PoxCycle | PoxSigner | PoxStacker + | FtHolderEntry | NonFungibleTokenHistoryEventWithTxId | NonFungibleTokenHistoryEventWithTxMetadata | NonFungibleTokenHistoryEvent @@ -3437,6 +3439,32 @@ export interface PoxCycle { total_stacked_amount: string; total_signers: number; } +/** + * List of Fungible Token holders + */ +export interface FungibleTokenHolderList { + /** + * The number of holders to return + */ + limit: number; + /** + * The number to holders to skip (starting at `0`) + */ + offset: number; + /** + * The number of holders available + */ + total: number; + /** + * The total supply of the token (the sum of all balances) + */ + total_supply: string; + results: FtHolderEntry[]; +} +export interface FtHolderEntry { + address: string; + balance: string; +} /** * List of Non-Fungible Token history events */ diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e15cdb10b9..f68756d350 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3678,6 +3678,38 @@ paths: value: $ref: ./api/tokens/get-non-fungible-token-mints-tx-metadata.example.schema.json + /extended/v1/tokens/ft/{token}/holders: + get: + operationId: get_ft_holders + summary: Fungible token holders + description: | + Retrieves the list of Fungible Token holders for a given token ID. Specify `stx` for the `token` parameter to get the list of STX holders. + tags: + - Fungible Tokens + parameters: + - name: token + in: path + description: fungible token identifier + required: true + schema: + type: string + examples: + stx: + value: stx + summary: STX token + ft: + value: SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.token-aeusdc::aeUSDC + summary: fungible token + responses: + 200: + description: Fungible Token holders + content: + application/json: + schema: + $ref: ./api/tokens/get-ft-holders.schema.json + example: + $ref: ./api/tokens/get-ft-holders.example.json + /extended/v1/fee_rate: post: operationId: fetch_fee_rate diff --git a/migrations/1720532894811_ft_balances.js b/migrations/1720532894811_ft_balances.js new file mode 100644 index 0000000000..94ae1686c0 --- /dev/null +++ b/migrations/1720532894811_ft_balances.js @@ -0,0 +1,125 @@ +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.up = pgm => { + pgm.createTable('ft_balances', { + id: { + type: 'bigserial', + primaryKey: true, + }, + address: { + type: 'text', + notNull: true, + }, + token: { + type: 'text', + notNull: true, + }, + balance: { + type: 'numeric', + notNull: true, + } + }); + + pgm.addConstraint('ft_balances', 'unique_address_token', `UNIQUE(address, token)`); + + // Speeds up "grab the addresses with the highest balance for a given token" queries + pgm.createIndex('ft_balances', [{ name: 'token' }, { name: 'balance', sort: 'DESC' }]); + + // Speeds up "get the total supply of a given token" queries + pgm.createIndex('ft_balances', 'token'); + + // Populate the table with the current stx balances + pgm.sql(` + WITH all_balances AS ( + SELECT sender AS address, -SUM(amount) AS balance_change + FROM stx_events + WHERE asset_event_type_id IN (1, 3) -- Transfers and Burns affect the sender's balance + AND canonical = true AND microblock_canonical = true + GROUP BY sender + UNION ALL + SELECT recipient AS address, SUM(amount) AS balance_change + FROM stx_events + WHERE asset_event_type_id IN (1, 2) -- Transfers and Mints affect the recipient's balance + AND canonical = true AND microblock_canonical = true + GROUP BY recipient + ), + net_balances AS ( + SELECT address, SUM(balance_change) AS balance + FROM all_balances + GROUP BY address + ), + fees AS ( + SELECT address, SUM(total_fees) AS total_fees + FROM ( + SELECT sender_address AS address, SUM(fee_rate) AS total_fees + FROM txs + WHERE canonical = true AND microblock_canonical = true AND sponsored = false + GROUP BY sender_address + UNION ALL + SELECT sponsor_address AS address, SUM(fee_rate) AS total_fees + FROM txs + WHERE canonical = true AND microblock_canonical = true AND sponsored = true + GROUP BY sponsor_address + ) AS subquery + GROUP BY address + ), + rewards AS ( + SELECT + recipient AS address, + SUM( + coinbase_amount + tx_fees_anchored + tx_fees_streamed_confirmed + tx_fees_streamed_produced + ) AS total_rewards + FROM miner_rewards + WHERE canonical = true + GROUP BY recipient + ), + all_addresses AS ( + SELECT address FROM net_balances + UNION + SELECT address FROM fees + UNION + SELECT address FROM rewards + ) + INSERT INTO ft_balances (address, balance, token) + SELECT + aa.address, + COALESCE(nb.balance, 0) - COALESCE(f.total_fees, 0) + COALESCE(r.total_rewards, 0) AS balance, + 'stx' AS token + FROM all_addresses aa + LEFT JOIN net_balances nb ON aa.address = nb.address + LEFT JOIN fees f ON aa.address = f.address + LEFT JOIN rewards r ON aa.address = r.address + `); + + // Populate the table with the current FT balances + pgm.sql(` + WITH all_balances AS ( + SELECT sender AS address, asset_identifier, -SUM(amount) AS balance_change + FROM ft_events + WHERE asset_event_type_id IN (1, 3) -- Transfers and Burns affect the sender's balance + AND canonical = true + AND microblock_canonical = true + GROUP BY sender, asset_identifier + UNION ALL + SELECT recipient AS address, asset_identifier, SUM(amount) AS balance_change + FROM ft_events + WHERE asset_event_type_id IN (1, 2) -- Transfers and Mints affect the recipient's balance + AND canonical = true + AND microblock_canonical = true + GROUP BY recipient, asset_identifier + ), + net_balances AS ( + SELECT address, asset_identifier, SUM(balance_change) AS balance + FROM all_balances + GROUP BY address, asset_identifier + ) + INSERT INTO ft_balances (address, balance, token) + SELECT address, balance, asset_identifier AS token + FROM net_balances + `); + +}; + +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.down = pgm => { + pgm.dropTable('ft_balances'); +}; diff --git a/src/api/pagination.ts b/src/api/pagination.ts index 5fed3e05ed..fc0b23bbbe 100644 --- a/src/api/pagination.ts +++ b/src/api/pagination.ts @@ -38,6 +38,7 @@ export enum ResourceType { BurnBlock, Signer, PoxCycle, + TokenHolders, } export const pagingQueryLimits: Record = { @@ -89,6 +90,10 @@ export const pagingQueryLimits: Record { + const token = req.params.token; + const limit = getPagingQueryLimit(ResourceType.TokenHolders, req.query.limit); + const offset = parsePagingQueryInput(req.query.offset ?? 0); + const { results, total, totalSupply } = await db.getTokenHolders({ token, limit, offset }); + const response: FungibleTokenHolderList = { + limit: limit, + offset: offset, + total: total, + total_supply: totalSupply, + results: results, + }; + setETagCacheHeaders(res); + res.status(200).json(response); + }) + ); + return router; } diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index 5f1838e834..e6fc79d1af 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -3288,6 +3288,39 @@ export class PgStore extends BasePgStore { return { found: true, result: queryResult }; } + async getTokenHolders(args: { token: string; limit: number; offset: number }): Promise<{ + results: { address: string; balance: string }[]; + totalSupply: string; + total: number; + }> { + const holderResults = await this.sql< + { address: string; balance: string; count: number; total_supply: string }[] + >` + WITH totals AS ( + SELECT + SUM(balance) AS total, + COUNT(*)::int AS total_count + FROM ft_balances + WHERE token = ${args.token} + ) + SELECT + fb.address, + fb.balance, + ts.total AS total_supply, + ts.total_count AS count + FROM ft_balances fb + JOIN totals ts ON true + WHERE token = ${args.token} + ORDER BY balance DESC + LIMIT ${args.limit} + OFFSET ${args.offset} + `; + const results = holderResults.map(row => ({ address: row.address, balance: row.balance })); + const total = holderResults.length > 0 ? holderResults[0].count : 0; + const totalSupply = holderResults.length > 0 ? holderResults[0].total_supply : '0'; + return { results, totalSupply, total }; + } + /** * Returns a list of NFTs owned by the given principal filtered by optional `asset_identifiers`, * including optional transaction metadata. diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index 160ae4f4fb..bbd15368ee 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -273,6 +273,10 @@ export class PgWriteStore extends PgStore { if ((await this.updateBlock(sql, data.block)) !== 0) { const q = new PgWriteQueue(); q.enqueue(() => this.updateMinerRewards(sql, data.minerRewards)); + if (isCanonical) { + q.enqueue(() => this.updateStxBalances(sql, batchedTxData, data.minerRewards)); + q.enqueue(() => this.updateFtBalances(sql, batchedTxData)); + } if (data.poxSetSigners && data.poxSetSigners.signers) { const poxSet = data.poxSetSigners; q.enqueue(() => this.updatePoxSetsBatch(sql, data.block, poxSet)); @@ -1015,6 +1019,110 @@ export class PgWriteStore extends PgStore { } } + async updateStxBalances( + sql: PgSqlClient, + entries: { tx: DbTx; stxEvents: DbStxEvent[] }[], + minerRewards: DbMinerReward[] + ) { + const balanceMap = new Map(); + + for (const { tx, stxEvents } of entries) { + if (tx.sponsored) { + // Decrease the tx sponsor balance by the fee + const balance = balanceMap.get(tx.sponsor_address as string) ?? BigInt(0); + balanceMap.set(tx.sponsor_address as string, balance - BigInt(tx.fee_rate)); + } else { + // Decrease the tx sender balance by the fee + const balance = balanceMap.get(tx.sender_address) ?? BigInt(0); + balanceMap.set(tx.sender_address, balance - BigInt(tx.fee_rate)); + } + + for (const event of stxEvents) { + if (event.sender) { + // Decrease the tx sender balance by the transfer amount + const balance = balanceMap.get(event.sender) ?? BigInt(0); + balanceMap.set(event.sender, balance - BigInt(event.amount)); + } + if (event.recipient) { + // Increase the tx recipient balance by the transfer amount + const balance = balanceMap.get(event.recipient) ?? BigInt(0); + balanceMap.set(event.recipient, balance + BigInt(event.amount)); + } + } + } + + for (const reward of minerRewards) { + const balance = balanceMap.get(reward.recipient) ?? BigInt(0); + const amount = + reward.coinbase_amount + + reward.tx_fees_anchored + + reward.tx_fees_streamed_confirmed + + reward.tx_fees_streamed_produced; + balanceMap.set(reward.recipient, balance + BigInt(amount)); + } + + const values = Array.from(balanceMap, ([address, balance]) => ({ + address, + token: 'stx', + balance: balance.toString(), + })); + + for (const batch of batchIterate(values, INSERT_BATCH_SIZE)) { + const res = await sql` + INSERT INTO ft_balances ${sql(batch)} + ON CONFLICT (address, token) + DO UPDATE + SET balance = ft_balances.balance + EXCLUDED.balance + `; + assert(res.count === values.length, `Expecting ${values.length} inserts, got ${res.count}`); + } + } + + async updateFtBalances(sql: PgSqlClient, entries: { ftEvents: DbFtEvent[] }[]) { + const balanceMap = new Map(); + + for (const { ftEvents } of entries) { + for (const event of ftEvents) { + if (event.sender) { + // Decrease the sender balance by the transfer amount + const key = `${event.sender}|${event.asset_identifier}`; + const balance = balanceMap.get(key)?.balance ?? BigInt(0); + balanceMap.set(key, { + address: event.sender, + token: event.asset_identifier, + balance: balance - BigInt(event.amount), + }); + } + if (event.recipient) { + // Increase the recipient balance by the transfer amount + const key = `${event.recipient}|${event.asset_identifier}`; + const balance = balanceMap.get(key)?.balance ?? BigInt(0); + balanceMap.set(key, { + address: event.recipient, + token: event.asset_identifier, + balance: balance + BigInt(event.amount), + }); + } + } + } + + const values = Array.from(balanceMap, ([, entry]) => ({ + address: entry.address, + token: entry.token, + balance: entry.balance.toString(), + })); + + for (const batch of batchIterate(values, INSERT_BATCH_SIZE)) { + const res = await sql` + INSERT INTO ft_balances ${sql(batch)} + ON CONFLICT (address, token) + DO UPDATE + SET balance = ft_balances.balance + EXCLUDED.balance + `; + assert(res.count === values.length, `Expecting ${values.length} inserts, got ${res.count}`); + } + } + async updateStxEvents(sql: PgSqlClient, entries: { tx: DbTx; stxEvents: DbStxEvent[] }[]) { const values: StxEventInsertValues[] = []; for (const { tx, stxEvents } of entries) { @@ -2669,11 +2777,48 @@ export class PgWriteStore extends PgStore { const q = new PgWriteQueue(); q.enqueue(async () => { - const txResult = await sql<{ tx_id: string }[]>` - UPDATE txs - SET canonical = ${canonical} - WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} - RETURNING tx_id + const txResult = await sql<{ tx_id: string; update_balances_count: number }[]>` + WITH updated_txs AS ( + UPDATE txs + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING tx_id, sender_address, sponsor_address, fee_rate, sponsored, canonical + ), + affected_addresses AS ( + SELECT + sender_address AS address, + fee_rate AS fee_change, + canonical, + sponsored + FROM updated_txs + WHERE sponsored = false + UNION ALL + SELECT + sponsor_address AS address, + fee_rate AS fee_change, + canonical, + sponsored + FROM updated_txs + WHERE sponsored = true + ), + balances_update AS ( + SELECT + a.address, + SUM(CASE WHEN a.canonical THEN -a.fee_change ELSE a.fee_change END) AS balance_change + FROM affected_addresses a + GROUP BY a.address + ), + update_ft_balances AS ( + INSERT INTO ft_balances (address, token, balance) + SELECT b.address, 'stx', b.balance_change + FROM balances_update b + ON CONFLICT (address, token) + DO UPDATE + SET balance = ft_balances.balance + EXCLUDED.balance + RETURNING ft_balances.address + ) + SELECT tx_id, (SELECT COUNT(*)::int FROM update_ft_balances) AS update_balances_count + FROM updated_txs `; const txIds = txResult.map(row => row.tx_id); if (canonical) { @@ -2683,24 +2828,51 @@ export class PgWriteStore extends PgStore { updatedEntities.markedNonCanonical.txs += txResult.count; result.txsMarkedNonCanonical = txIds; } - if (txResult.count) + if (txResult.count) { await sql` UPDATE principal_stx_txs SET canonical = ${canonical} WHERE tx_id IN ${sql(txIds)} AND index_block_hash = ${indexBlockHash} AND canonical != ${canonical} `; + } }); q.enqueue(async () => { - const minerRewardResults = await sql` - UPDATE miner_rewards - SET canonical = ${canonical} - WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + const minerRewardResults = await sql<{ updated_rewards_count: number }[]>` + WITH updated_rewards AS ( + UPDATE miner_rewards + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING recipient, coinbase_amount, tx_fees_anchored, tx_fees_streamed_confirmed, tx_fees_streamed_produced, canonical + ), + reward_changes AS ( + SELECT + recipient AS address, + SUM(CASE WHEN canonical THEN + (coinbase_amount + tx_fees_anchored + tx_fees_streamed_confirmed + tx_fees_streamed_produced) + ELSE + -(coinbase_amount + tx_fees_anchored + tx_fees_streamed_confirmed + tx_fees_streamed_produced) + END) AS balance_change + FROM updated_rewards + GROUP BY recipient + ), + update_balances AS ( + INSERT INTO ft_balances (address, token, balance) + SELECT rc.address, 'stx', rc.balance_change + FROM reward_changes rc + ON CONFLICT (address, token) + DO UPDATE + SET balance = ft_balances.balance + EXCLUDED.balance + RETURNING ft_balances.address + ) + SELECT + (SELECT COUNT(*)::int FROM updated_rewards) AS updated_rewards_count `; + const updateCount = minerRewardResults[0]?.updated_rewards_count ?? 0; if (canonical) { - updatedEntities.markedCanonical.minerRewards += minerRewardResults.count; + updatedEntities.markedCanonical.minerRewards += updateCount; } else { - updatedEntities.markedNonCanonical.minerRewards += minerRewardResults.count; + updatedEntities.markedNonCanonical.minerRewards += updateCount; } }); q.enqueue(async () => { @@ -2716,27 +2888,95 @@ export class PgWriteStore extends PgStore { } }); q.enqueue(async () => { - const stxResults = await sql` - UPDATE stx_events - SET canonical = ${canonical} - WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + const stxResults = await sql<{ updated_events_count: number }[]>` + WITH updated_events AS ( + UPDATE stx_events + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING sender, recipient, amount, asset_event_type_id, canonical + ), + event_changes AS ( + SELECT + address, + SUM(balance_change) AS balance_change + FROM ( + SELECT + sender AS address, + SUM(CASE WHEN canonical THEN -amount ELSE amount END) AS balance_change + FROM updated_events + WHERE asset_event_type_id IN (1, 3) -- Transfers and Burns affect the sender's balance + GROUP BY sender + UNION ALL + SELECT + recipient AS address, + SUM(CASE WHEN canonical THEN amount ELSE -amount END) AS balance_change + FROM updated_events + WHERE asset_event_type_id IN (1, 2) -- Transfers and Mints affect the recipient's balance + GROUP BY recipient + ) AS subquery + GROUP BY address + ), + update_balances AS ( + INSERT INTO ft_balances (address, token, balance) + SELECT ec.address, 'stx', ec.balance_change + FROM event_changes ec + ON CONFLICT (address, token) + DO UPDATE + SET balance = ft_balances.balance + EXCLUDED.balance + RETURNING ft_balances.address + ) + SELECT + (SELECT COUNT(*)::int FROM updated_events) AS updated_events_count `; + const updateCount = stxResults[0]?.updated_events_count ?? 0; if (canonical) { - updatedEntities.markedCanonical.stxEvents += stxResults.count; + updatedEntities.markedCanonical.stxEvents += updateCount; } else { - updatedEntities.markedNonCanonical.stxEvents += stxResults.count; + updatedEntities.markedNonCanonical.stxEvents += updateCount; } }); q.enqueue(async () => { - const ftResult = await sql` - UPDATE ft_events - SET canonical = ${canonical} - WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + const ftResult = await sql<{ updated_events_count: number }[]>` + WITH updated_events AS ( + UPDATE ft_events + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING sender, recipient, amount, asset_event_type_id, asset_identifier, canonical + ), + event_changes AS ( + SELECT address, asset_identifier, SUM(balance_change) AS balance_change + FROM ( + SELECT sender AS address, asset_identifier, + SUM(CASE WHEN canonical THEN -amount ELSE amount END) AS balance_change + FROM updated_events + WHERE asset_event_type_id IN (1, 3) -- Transfers and Burns affect the sender's balance + GROUP BY sender, asset_identifier + UNION ALL + SELECT recipient AS address, asset_identifier, + SUM(CASE WHEN canonical THEN amount ELSE -amount END) AS balance_change + FROM updated_events + WHERE asset_event_type_id IN (1, 2) -- Transfers and Mints affect the recipient's balance + GROUP BY recipient, asset_identifier + ) AS subquery + GROUP BY address, asset_identifier + ), + update_balances AS ( + INSERT INTO ft_balances (address, token, balance) + SELECT ec.address, ec.asset_identifier, ec.balance_change + FROM event_changes ec + ON CONFLICT (address, token) + DO UPDATE + SET balance = ft_balances.balance + EXCLUDED.balance + RETURNING ft_balances.address + ) + SELECT + (SELECT COUNT(*)::int FROM updated_events) AS updated_events_count `; + const updateCount = ftResult[0]?.updated_events_count ?? 0; if (canonical) { - updatedEntities.markedCanonical.ftEvents += ftResult.count; + updatedEntities.markedCanonical.ftEvents += updateCount; } else { - updatedEntities.markedNonCanonical.ftEvents += ftResult.count; + updatedEntities.markedNonCanonical.ftEvents += updateCount; } }); q.enqueue(async () => { diff --git a/src/tests/datastore-tests.ts b/src/tests/datastore-tests.ts index 94e96a759c..fe36ea8aaa 100644 --- a/src/tests/datastore-tests.ts +++ b/src/tests/datastore-tests.ts @@ -4400,6 +4400,629 @@ describe('postgres datastore', () => { const sc1 = await db.getSmartContract(contract1.contract_id); expect(sc1.found && sc1.result?.canonical).toBe(true); + + // Ensure STX holder balances have tracked correctly through the reorgs + const holders1 = await db.getTokenHolders({ token: 'stx', limit: 100, offset: 0 }); + for (const holder of holders1.results) { + const holderBalance = await db.getStxBalance({ + stxAddress: holder.address, + includeUnanchored: false, + }); + expect(holder.balance).toBe(holderBalance.balance.toString()); + } + }); + + test('pg balance reorg handling', async () => { + const block1: DbBlock = { + block_hash: '0x11', + index_block_hash: '0xaa', + parent_index_block_hash: '0x00', + parent_block_hash: '0x00', + parent_microblock_hash: '0x00', + block_height: 1, + block_time: 1234, + burn_block_time: 1234, + burn_block_hash: '0x1234', + burn_block_height: 123, + miner_txid: '0x4321', + canonical: true, + parent_microblock_sequence: 0, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + tx_count: 1, + signer_bitvec: null, + }; + + const block2: DbBlock = { + block_hash: '0x22', + index_block_hash: '0xbb', + parent_index_block_hash: block1.index_block_hash, + parent_block_hash: block1.block_hash, + parent_microblock_hash: '0x00', + block_height: 2, + block_time: 1234, + burn_block_time: 1234, + burn_block_hash: '0x1234', + burn_block_height: 123, + miner_txid: '0x4321', + canonical: true, + parent_microblock_sequence: 0, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + tx_count: 1, + signer_bitvec: null, + }; + + const block2b: DbBlock = { + block_hash: '0x22bb', + index_block_hash: '0xbbbb', + parent_index_block_hash: block1.index_block_hash, + parent_block_hash: block1.block_hash, + parent_microblock_hash: '0x00', + block_height: 2, + block_time: 1234, + burn_block_time: 1234, + burn_block_hash: '0x1234', + burn_block_height: 123, + miner_txid: '0x4321', + canonical: true, + parent_microblock_sequence: 0, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + tx_count: 1, + signer_bitvec: null, + }; + + const block3: DbBlock = { + block_hash: '0x33', + index_block_hash: '0xcc', + parent_index_block_hash: block2.index_block_hash, + parent_block_hash: block2.block_hash, + parent_microblock_hash: '0x00', + block_height: 3, + block_time: 1234, + burn_block_time: 1234, + burn_block_hash: '0x1234', + burn_block_height: 123, + miner_txid: '0x4321', + canonical: true, + parent_microblock_sequence: 0, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + tx_count: 1, + signer_bitvec: null, + }; + + const block3b: DbBlock = { + block_hash: '0x33bb', + index_block_hash: '0xccbb', + parent_index_block_hash: block2b.index_block_hash, + parent_block_hash: block2b.block_hash, + parent_microblock_hash: '0x00', + block_height: 3, + block_time: 1234, + burn_block_time: 1234, + burn_block_hash: '0x1234', + burn_block_height: 123, + miner_txid: '0x4321', + canonical: true, + parent_microblock_sequence: 0, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + tx_count: 1, + signer_bitvec: null, + }; + + const block4b: DbBlock = { + block_hash: '0x44bb', + index_block_hash: '0xddbb', + parent_index_block_hash: block3b.index_block_hash, + parent_block_hash: block3b.block_hash, + parent_microblock_hash: '0x00', + block_height: 4, + block_time: 1234, + burn_block_time: 1234, + burn_block_hash: '0x1234', + burn_block_height: 123, + miner_txid: '0x4321', + canonical: true, + parent_microblock_sequence: 0, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + tx_count: 1, + signer_bitvec: null, + }; + + const minerReward1: DbMinerReward = { + ...block1, + mature_block_height: 3, + from_index_block_hash: '0x11', + recipient: 'addr1', + miner_address: 'addr1', + coinbase_amount: 2n, + tx_fees_anchored: 2n, + tx_fees_streamed_confirmed: 2n, + tx_fees_streamed_produced: 2n, + }; + + // test miner reward that gets orphaned + const minerReward2: DbMinerReward = { + ...block2, + mature_block_height: 4, + from_index_block_hash: '0x22', + recipient: 'addr1', + miner_address: 'addr1', + coinbase_amount: 3n, + tx_fees_anchored: 3n, + tx_fees_streamed_confirmed: 3n, + tx_fees_streamed_produced: 3n, + }; + + const tx1: DbTxRaw = { + tx_id: '0x01', + tx_index: 0, + anchor_mode: 3, + nonce: 0, + raw_tx: '0x', + index_block_hash: block1.index_block_hash, + block_hash: block1.block_hash, + block_height: block1.block_height, + block_time: block1.block_height, + burn_block_height: block1.burn_block_height, + burn_block_time: block1.burn_block_time, + parent_burn_block_time: 1626122935, + type_id: DbTxTypeId.Coinbase, + status: 1, + raw_result: '0x0100000000000000000000000000000001', // u1 + canonical: true, + post_conditions: '0x', + fee_rate: 10n, + sponsored: false, + sponsor_address: undefined, + sender_address: 'addr1', + origin_hash_mode: 1, + coinbase_payload: bufferToHex(Buffer.from('hi')), + event_count: 0, + parent_index_block_hash: '0x00', + parent_block_hash: '0x00', + microblock_canonical: true, + microblock_sequence: I32_MAX, + microblock_hash: '0x00', + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + }; + + const tx2: DbTxRaw = { + tx_id: '0x02', + tx_index: 0, + anchor_mode: 3, + nonce: 0, + raw_tx: '0x', + index_block_hash: block2.index_block_hash, + block_hash: block2.block_hash, + block_height: block2.block_height, + block_time: block2.burn_block_time, + burn_block_height: block2.burn_block_height, + burn_block_time: block2.burn_block_time, + parent_burn_block_time: 1626122935, + type_id: DbTxTypeId.Coinbase, + status: 1, + raw_result: '0x0100000000000000000000000000000001', // u1 + canonical: true, + post_conditions: '0x', + fee_rate: 10n, + sponsored: false, + sender_address: 'addr1', + sponsor_address: undefined, + origin_hash_mode: 1, + coinbase_payload: bufferToHex(Buffer.from('hi')), + event_count: 1, + parent_index_block_hash: '0x00', + parent_block_hash: '0x00', + microblock_canonical: true, + microblock_sequence: I32_MAX, + microblock_hash: '0x00', + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + }; + + // test sponsored tx + const tx3: DbTxRaw = { + tx_id: '0x0201', + tx_index: 0, + anchor_mode: 3, + nonce: 0, + raw_tx: '0x', + index_block_hash: block2.index_block_hash, + block_hash: block2.block_hash, + block_height: block2.block_height, + block_time: block2.burn_block_time, + burn_block_height: block2.burn_block_height, + burn_block_time: block2.burn_block_time, + parent_burn_block_time: 1626122935, + type_id: DbTxTypeId.Coinbase, + status: 1, + raw_result: '0x0100000000000000000000000000000001', // u1 + canonical: true, + post_conditions: '0x', + fee_rate: 25n, + sponsored: true, + sender_address: 'other-addr', + sponsor_address: 'addr1', + origin_hash_mode: 1, + coinbase_payload: bufferToHex(Buffer.from('hi')), + event_count: 1, + parent_index_block_hash: '0x00', + parent_block_hash: '0x00', + microblock_canonical: true, + microblock_sequence: I32_MAX, + microblock_hash: '0x00', + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + }; + + const tx4: DbTxRaw = { + tx_id: '0x03', + tx_index: 0, + anchor_mode: 3, + nonce: 0, + raw_tx: '0x', + index_block_hash: block2b.index_block_hash, + block_hash: block2b.block_hash, + block_height: block2b.block_height, + block_time: block2b.burn_block_time, + burn_block_height: block2b.burn_block_height, + burn_block_time: block2b.burn_block_time, + parent_burn_block_time: 1626122935, + type_id: DbTxTypeId.Coinbase, + status: 1, + raw_result: '0x0100000000000000000000000000000001', // u1 + canonical: true, + post_conditions: '0x', + fee_rate: 10n, + sponsored: false, + sponsor_address: undefined, + sender_address: 'addr1', + origin_hash_mode: 1, + coinbase_payload: bufferToHex(Buffer.from('hi')), + event_count: 0, + parent_index_block_hash: '0x00', + parent_block_hash: '0x00', + microblock_canonical: true, + microblock_sequence: I32_MAX, + microblock_hash: '0x00', + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + }; + + // test stx mint + const stxEvent1: DbStxEvent = { + event_index: 1, + tx_id: tx1.tx_id, + tx_index: tx1.tx_index, + block_height: block1.block_height, + canonical: true, + asset_event_type_id: DbAssetEventTypeId.Mint, + sender: undefined, + recipient: 'addr1', + event_type: DbEventTypeId.StxAsset, + amount: 1000n, + }; + + // test stx mint gets orphaned + const stxEvent2: DbStxEvent = { + event_index: 1, + tx_id: tx2.tx_id, + tx_index: tx2.tx_index, + block_height: block2.block_height, + canonical: true, + asset_event_type_id: DbAssetEventTypeId.Mint, + sender: undefined, + recipient: 'addr1', + event_type: DbEventTypeId.StxAsset, + amount: 5555n, + }; + + // test stx transfer to addr + const stxEvent3: DbStxEvent = { + event_index: 1, + tx_id: tx2.tx_id, + tx_index: tx2.tx_index, + block_height: block2.block_height, + canonical: true, + asset_event_type_id: DbAssetEventTypeId.Transfer, + sender: 'other-addr', + recipient: 'addr1', + event_type: DbEventTypeId.StxAsset, + amount: 4444n, + }; + + // test stx transfer from addr + const stxEvent4: DbStxEvent = { + event_index: 1, + tx_id: tx2.tx_id, + tx_index: tx2.tx_index, + block_height: block2.block_height, + canonical: true, + asset_event_type_id: DbAssetEventTypeId.Transfer, + sender: 'addr1', + recipient: 'other-addr', + event_type: DbEventTypeId.StxAsset, + amount: 1111n, + }; + + const ftBEvent1: DbFtEvent = { + event_index: 1, + tx_id: tx1.tx_id, + tx_index: tx1.tx_index, + block_height: block1.block_height, + canonical: true, + asset_event_type_id: DbAssetEventTypeId.Mint, + sender: undefined, + recipient: 'addr1', + event_type: DbEventTypeId.FungibleTokenAsset, + amount: 8000n, + asset_identifier: 'my-ft-b', + }; + + const ftAEvent1: DbFtEvent = { + event_index: 1, + tx_id: tx1.tx_id, + tx_index: tx1.tx_index, + block_height: block1.block_height, + canonical: true, + asset_event_type_id: DbAssetEventTypeId.Mint, + sender: undefined, + recipient: 'addr1', + event_type: DbEventTypeId.FungibleTokenAsset, + amount: 8000n, + asset_identifier: 'my-ft-a', + }; + + const ftAEvent2: DbFtEvent = { + event_index: 1, + tx_id: tx2.tx_id, + tx_index: tx2.tx_index, + block_height: block2.block_height, + canonical: true, + asset_event_type_id: DbAssetEventTypeId.Mint, + sender: undefined, + recipient: 'addr1', + event_type: DbEventTypeId.FungibleTokenAsset, + amount: 1000n, + asset_identifier: 'my-ft-a', + }; + + const ftAEvent3: DbFtEvent = { + event_index: 1, + tx_id: tx2.tx_id, + tx_index: tx2.tx_index, + block_height: block2.block_height, + canonical: true, + asset_event_type_id: DbAssetEventTypeId.Burn, + sender: 'addr1', + recipient: undefined, + event_type: DbEventTypeId.FungibleTokenAsset, + amount: 600n, + asset_identifier: 'my-ft-a', + }; + + const ftAEvent4: DbFtEvent = { + event_index: 1, + tx_id: tx2.tx_id, + tx_index: tx2.tx_index, + block_height: block2.block_height, + canonical: true, + asset_event_type_id: DbAssetEventTypeId.Transfer, + sender: 'other-addr', + recipient: 'addr1', + event_type: DbEventTypeId.FungibleTokenAsset, + amount: 500n, + asset_identifier: 'my-ft-a', + }; + + // Start canonical chain + await db.update({ + block: block1, + microblocks: [], + minerRewards: [minerReward1], + txs: [ + { + tx: tx1, + stxLockEvents: [], + stxEvents: [stxEvent1], + ftEvents: [ftAEvent1, ftBEvent1], + nftEvents: [], + contractLogEvents: [], + smartContracts: [], + names: [], + namespaces: [], + pox2Events: [], + pox3Events: [], + pox4Events: [], + }, + ], + }); + + await db.update({ + block: block2, + microblocks: [], + minerRewards: [minerReward2], + txs: [ + { + tx: tx2, + stxLockEvents: [], + stxEvents: [stxEvent2, stxEvent3, stxEvent4], + ftEvents: [ftAEvent2, ftAEvent3, ftAEvent4], + nftEvents: [], + contractLogEvents: [], + smartContracts: [], + names: [], + namespaces: [], + pox2Events: [], + pox3Events: [], + pox4Events: [], + }, + { + tx: tx3, + stxLockEvents: [], + stxEvents: [], + ftEvents: [], + nftEvents: [], + contractLogEvents: [], + smartContracts: [], + names: [], + namespaces: [], + pox2Events: [], + pox3Events: [], + pox4Events: [], + }, + ], + }); + + const holdersBlock2 = await db.getTokenHolders({ token: 'stx', limit: 100, offset: 0 }); + expect(holdersBlock2.results.find(b => b.address === 'addr1')?.balance).toBe( + ( + minerReward1.coinbase_amount + + minerReward1.tx_fees_anchored + + minerReward1.tx_fees_streamed_confirmed + + minerReward1.tx_fees_streamed_produced + + minerReward2.coinbase_amount + + minerReward2.tx_fees_anchored + + minerReward2.tx_fees_streamed_confirmed + + minerReward2.tx_fees_streamed_produced + + stxEvent1.amount + + stxEvent2.amount + + stxEvent3.amount - + stxEvent4.amount - + tx1.fee_rate - + tx2.fee_rate - + tx3.fee_rate + ).toString() + ); + + const holdersFtABlock2 = await db.getTokenHolders({ token: 'my-ft-a', limit: 100, offset: 0 }); + expect(holdersFtABlock2.results.find(b => b.address === 'addr1')?.balance).toBe( + (ftAEvent1.amount + ftAEvent2.amount - ftAEvent3.amount + ftAEvent4.amount).toString() + ); + + const holdersFtBBlock2 = await db.getTokenHolders({ token: 'my-ft-b', limit: 100, offset: 0 }); + expect(holdersFtBBlock2.results.find(b => b.address === 'addr1')?.balance).toBe( + ftBEvent1.amount.toString() + ); + + await db.update({ block: block3, microblocks: [], minerRewards: [], txs: [] }); + + // Insert non-canonical block + await db.update({ + block: block2b, + microblocks: [], + minerRewards: [], + txs: [ + { + tx: tx4, + stxLockEvents: [], + stxEvents: [], + ftEvents: [], + nftEvents: [], + contractLogEvents: [], + smartContracts: [], + names: [], + namespaces: [], + pox2Events: [], + pox3Events: [], + pox4Events: [], + }, + ], + }); + await db.update({ block: block3b, microblocks: [], minerRewards: [], txs: [] }); + await db.update({ block: block4b, microblocks: [], minerRewards: [], txs: [] }); + + const b1 = await db.getBlock({ hash: block1.block_hash }); + const b2 = await db.getBlock({ hash: block2.block_hash }); + const b2b = await db.getBlock({ hash: block2b.block_hash }); + const b3 = await db.getBlock({ hash: block3.block_hash }); + const b3b = await db.getBlock({ hash: block3b.block_hash }); + const b4 = await db.getBlock({ hash: block4b.block_hash }); + expect(b1.result?.canonical).toBe(true); + expect(b2.result?.canonical).toBe(false); + expect(b2b.result?.canonical).toBe(true); + expect(b3.result?.canonical).toBe(false); + expect(b3b.result?.canonical).toBe(true); + expect(b4.result?.canonical).toBe(true); + + const t1 = await db.getTx({ txId: tx1.tx_id, includeUnanchored: false }); + const t2 = await db.getTx({ txId: tx2.tx_id, includeUnanchored: false }); + const t3 = await db.getTx({ txId: tx3.tx_id, includeUnanchored: false }); + const t4 = await db.getTx({ txId: tx4.tx_id, includeUnanchored: false }); + expect(t1.result?.canonical).toBe(true); + expect(t2.result?.canonical).toBe(false); + expect(t3.result?.canonical).toBe(false); + expect(t4.result?.canonical).toBe(true); + + const holders1 = await db.getTokenHolders({ token: 'stx', limit: 100, offset: 0 }); + expect(holders1.results.find(b => b.address === 'addr1')?.balance).toBe( + ( + minerReward1.coinbase_amount + + minerReward1.tx_fees_anchored + + minerReward1.tx_fees_streamed_confirmed + + minerReward1.tx_fees_streamed_produced + + stxEvent1.amount - + tx1.fee_rate - + tx4.fee_rate + ).toString() + ); + + // Ensure STX holder balances have tracked correctly through the reorgs + for (const holder of holders1.results) { + const holderBalance = await db.getStxBalance({ + stxAddress: holder.address, + includeUnanchored: false, + }); + expect(holder.balance).toBe(holderBalance.balance.toString()); + } + + // Ensure FT (token-a) holder balance have tracked correctly through the reorgs + const holdersFtTokenA = await db.getTokenHolders({ token: 'my-ft-a', limit: 100, offset: 0 }); + expect(holdersFtTokenA.results.find(b => b.address === 'addr1')?.balance).toBe( + ftAEvent1.amount.toString() + ); + + // Ensure FT (token-a) holder balance have tracked correctly through the reorgs + const holdersFtTokenB = await db.getTokenHolders({ token: 'my-ft-b', limit: 100, offset: 0 }); + expect(holdersFtTokenB.results.find(b => b.address === 'addr1')?.balance).toBe( + ftBEvent1.amount.toString() + ); }); test('pg get raw tx', async () => { diff --git a/src/tests/token-tests.ts b/src/tests/token-tests.ts index 77572694c0..cca4760e05 100644 --- a/src/tests/token-tests.ts +++ b/src/tests/token-tests.ts @@ -5,6 +5,7 @@ import { TestBlockBuilder, TestMicroblockStreamBuilder } from '../test-utils/tes import { DbAssetEventTypeId } from '../datastore/common'; import { PgWriteStore } from '../datastore/pg-write-store'; import { migrate } from '../test-utils/test-helpers'; +import { FungibleTokenHolderList } from '@stacks/stacks-blockchain-api-types'; describe('/extended/v1/tokens tests', () => { let db: PgWriteStore; @@ -1011,4 +1012,47 @@ describe('/extended/v1/tokens tests', () => { expect(result11.results[0].value.hex).toEqual('0x01000000000000000000000000000009cb'); expect(result11.results[1].value.hex).toEqual('0x01000000000000000000000000000009ca'); }); + + test('/ft/holders - stx', async () => { + const addr1 = 'SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR'; + + // Transfer stx to addr + const block1 = new TestBlockBuilder({ block_height: 1, index_block_hash: '0x01' }) + .addTx({ tx_id: '0x5454' }) + .addTxStxEvent({ recipient: addr1, amount: 1000n }) + .build(); + await db.update(block1); + + const request1 = await supertest(api.server).get(`/extended/v1/tokens/ft/stx/holders`); + expect(request1.status).toBe(200); + expect(request1.type).toBe('application/json'); + + const request1Body: FungibleTokenHolderList = request1.body; + const balance1 = request1Body.results.find(b => b.address === addr1)?.balance; + expect(balance1).toBe('1000'); + }); + + test('/ft/holders - ft', async () => { + const addr1 = 'SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR'; + const ftID = 'SPA0SZQ6KCCYMJV5XVKSNM7Y1DGDXH39A11ZX2Y8.gamestop::GME'; + + // Transfer ft to addr + const block1 = new TestBlockBuilder({ block_height: 1, index_block_hash: '0x01' }) + .addTx({ tx_id: '0x5454' }) + .addTxFtEvent({ + recipient: addr1, + amount: 1000n, + asset_identifier: ftID, + }) + .build(); + await db.update(block1); + + const request1 = await supertest(api.server).get(`/extended/v1/tokens/ft/${ftID}/holders`); + expect(request1.status).toBe(200); + expect(request1.type).toBe('application/json'); + + const request1Body: FungibleTokenHolderList = request1.body; + const balance1 = request1Body.results.find(b => b.address === addr1)?.balance; + expect(balance1).toBe('1000'); + }); });