Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: return estimated balance in account balance endpoints #2104

Merged
merged 12 commits into from
Oct 4, 2024
17 changes: 15 additions & 2 deletions src/api/routes/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const AddressRoutes: FastifyPluginAsync<
fastify.get(
'/:principal/stx',
{
preHandler: handlePrincipalCache,
preHandler: handlePrincipalMempoolCache,
schema: {
operationId: 'get_account_stx_balance',
summary: 'Get account STX balance',
Expand Down Expand Up @@ -120,8 +120,14 @@ export const AddressRoutes: FastifyPluginAsync<
stxAddress,
blockHeight
);
let mempoolBalance: bigint | undefined = undefined;
if (req.query.until_block === undefined) {
const delta = await fastify.db.getPrincipalMempoolStxBalanceDelta(sql, stxAddress);
mempoolBalance = stxBalanceResult.balance + delta;
}
const result: AddressStxBalance = {
balance: stxBalanceResult.balance.toString(),
estimated_balance: mempoolBalance?.toString(),
total_sent: stxBalanceResult.totalSent.toString(),
total_received: stxBalanceResult.totalReceived.toString(),
total_fees_sent: stxBalanceResult.totalFeesSent.toString(),
Expand All @@ -145,7 +151,7 @@ export const AddressRoutes: FastifyPluginAsync<
fastify.get(
'/:principal/balances',
{
preHandler: handlePrincipalCache,
preHandler: handlePrincipalMempoolCache,
schema: {
operationId: 'get_account_balance',
summary: 'Get account balances',
Expand Down Expand Up @@ -204,9 +210,16 @@ export const AddressRoutes: FastifyPluginAsync<
};
});

let mempoolBalance: bigint | undefined = undefined;
if (req.query.until_block === undefined) {
const delta = await fastify.db.getPrincipalMempoolStxBalanceDelta(sql, stxAddress);
mempoolBalance = stxBalanceResult.balance + delta;
}

const result: AddressBalance = {
stx: {
balance: stxBalanceResult.balance.toString(),
estimated_balance: mempoolBalance?.toString(),
total_sent: stxBalanceResult.totalSent.toString(),
total_received: stxBalanceResult.totalReceived.toString(),
total_fees_sent: stxBalanceResult.totalFeesSent.toString(),
Expand Down
5 changes: 5 additions & 0 deletions src/api/schemas/entities/balances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export const NftBalanceSchema = Type.Object(
export const StxBalanceSchema = Type.Object(
{
balance: Type.String(),
estimated_balance: Type.Optional(
Type.String({
description: 'Total STX balance considering pending mempool transactions',
})
),
total_sent: Type.String(),
total_received: Type.String(),
total_fees_sent: Type.String(),
Expand Down
30 changes: 30 additions & 0 deletions src/datastore/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2476,6 +2476,36 @@ export class PgStore extends BasePgStore {
};
}

/**
* Returns the total STX balance delta affecting a principal from transactions currently in the
* mempool.
*/
async getPrincipalMempoolStxBalanceDelta(sql: PgSqlClient, principal: string): Promise<bigint> {
const results = await sql<{ delta: string }[]>`
WITH sent AS (
SELECT SUM(COALESCE(token_transfer_amount, 0) + fee_rate) AS total
FROM mempool_txs
WHERE pruned = false AND sender_address = ${principal}
),
sponsored AS (
SELECT SUM(fee_rate) AS total
FROM mempool_txs
WHERE pruned = false AND 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}
)
SELECT
COALESCE((SELECT total FROM received), 0)
- COALESCE((SELECT total FROM sent), 0)
- COALESCE((SELECT total FROM sponsored), 0)
AS delta
`;
return BigInt(results[0]?.delta ?? '0');
}

async getUnlockedStxSupply(
args:
| {
Expand Down
4 changes: 4 additions & 0 deletions tests/api/address.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,7 @@ describe('address tests', () => {
const expectedResp1 = {
stx: {
balance: '88679',
estimated_balance: '88679',
total_sent: '6385',
total_received: '100000',
total_fees_sent: '4936',
Expand Down Expand Up @@ -1587,6 +1588,7 @@ describe('address tests', () => {
const expectedResp2 = {
stx: {
balance: '91',
estimated_balance: '91',
total_sent: '15',
total_received: '1350',
total_fees_sent: '1244',
Expand Down Expand Up @@ -1622,6 +1624,7 @@ describe('address tests', () => {
expect(fetchAddrStxBalance1.type).toBe('application/json');
const expectedStxResp1 = {
balance: '91',
estimated_balance: '91',
total_sent: '15',
total_received: '1350',
total_fees_sent: '1244',
Expand Down Expand Up @@ -1652,6 +1655,7 @@ describe('address tests', () => {
expect(fetchAddrStxBalance1.type).toBe('application/json');
const expectedStxResp1Sponsored = {
balance: '3766',
estimated_balance: '3766',
total_sent: '0',
total_received: '5000',
total_fees_sent: '1234',
Expand Down
133 changes: 133 additions & 0 deletions tests/api/mempool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2128,4 +2128,137 @@ describe('mempool tests', () => {
);
expect(check2.body.results).toHaveLength(2);
});

test('account estimated balance from mempool activity', async () => {
const address = 'SP3FXEKSA6D4BW3TFP2BWTSREV6FY863Y90YY7D8G';
const url = `/extended/v1/address/${address}/stx`;
await db.update(
new TestBlockBuilder({
block_height: 1,
index_block_hash: '0x01',
parent_index_block_hash: '0x00',
})
.addTx({
tx_id: '0x0001',
token_transfer_recipient_address: address,
token_transfer_amount: 2000n,
})
.addTxStxEvent({ recipient: address, amount: 2000n })
.build()
);

// Base balance
const balance0 = await supertest(api.server).get(url);
expect(balance0.body.balance).toEqual('2000');
expect(balance0.body.estimated_balance).toEqual('2000');

// STX transfer in mempool
await db.updateMempoolTxs({
mempoolTxs: [
testMempoolTx({
tx_id: '0x0002',
sender_address: address,
token_transfer_amount: 100n,
fee_rate: 50n,
}),
],
});
const balance1 = await supertest(api.server).get(url);
expect(balance1.body.balance).toEqual('2000');
expect(balance1.body.estimated_balance).toEqual('1850'); // Minus amount and fee

// Contract call in mempool
await db.updateMempoolTxs({
mempoolTxs: [
testMempoolTx({
tx_id: '0x0002aa',
sender_address: address,
type_id: DbTxTypeId.ContractCall,
token_transfer_amount: 0n,
contract_call_contract_id: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world',
contract_call_function_args: '',
contract_call_function_name: 'test',
fee_rate: 50n,
}),
],
});
const balance1b = await supertest(api.server).get(url);
expect(balance1b.body.balance).toEqual('2000');
expect(balance1b.body.estimated_balance).toEqual('1800'); // Minus fee

// Sponsored tx in mempool
await db.updateMempoolTxs({
mempoolTxs: [
testMempoolTx({
tx_id: '0x0003',
sponsor_address: address,
sponsored: true,
token_transfer_amount: 100n,
fee_rate: 50n,
}),
],
});
const balance2 = await supertest(api.server).get(url);
expect(balance2.body.balance).toEqual('2000');
expect(balance2.body.estimated_balance).toEqual('1750'); // Minus fee

// STX received in mempool
await db.updateMempoolTxs({
mempoolTxs: [
testMempoolTx({
tx_id: '0x0004',
token_transfer_recipient_address: address,
token_transfer_amount: 100n,
fee_rate: 50n,
}),
],
});
const balance3 = await supertest(api.server).get(url);
expect(balance3.body.balance).toEqual('2000');
expect(balance3.body.estimated_balance).toEqual('1850'); // Plus amount

// Confirm all txs
await db.update(
new TestBlockBuilder({
block_height: 2,
index_block_hash: '0x02',
parent_index_block_hash: '0x01',
})
.addTx({
tx_id: '0x0002',
sender_address: address,
token_transfer_amount: 100n,
fee_rate: 50n,
})
.addTxStxEvent({ sender: address, amount: 100n })
.addTx({
tx_id: '0x0002aa',
sender_address: address,
type_id: DbTxTypeId.ContractCall,
token_transfer_amount: 0n,
contract_call_contract_id: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world',
contract_call_function_args: '',
contract_call_function_name: 'test',
fee_rate: 50n,
})
.addTx({
tx_id: '0x0003',
sponsor_address: address,
sponsored: true,
token_transfer_amount: 100n,
fee_rate: 50n,
})
.addTx({
tx_id: '0x0004',
token_transfer_recipient_address: address,
token_transfer_amount: 100n,
fee_rate: 50n,
})
.addTxStxEvent({ recipient: address, amount: 100n })
.build()
);
const balance4 = await supertest(api.server).get(url);
expect(balance4.body.balance).toEqual('1850');
expect(balance4.body.estimated_balance).toEqual('1850');
});
});
4 changes: 4 additions & 0 deletions tests/api/tx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ describe('tx tests', () => {

const expectedSponsoredRespBefore = {
balance: '0',
estimated_balance: '0',
total_sent: '0',
total_received: '0',
total_fees_sent: '0',
Expand Down Expand Up @@ -1119,6 +1120,7 @@ describe('tx tests', () => {

const expectedResp = {
balance: '0',
estimated_balance: '0',
total_sent: '0',
total_received: '0',
total_fees_sent: '0',
Expand All @@ -1137,6 +1139,7 @@ describe('tx tests', () => {
const expectedRespBalance = {
stx: {
balance: '0',
estimated_balance: '0',
total_sent: '0',
total_received: '0',
total_fees_sent: '0',
Expand All @@ -1159,6 +1162,7 @@ describe('tx tests', () => {

const expectedSponsoredRespAfter = {
balance: '-300',
estimated_balance: '-300',
total_sent: '0',
total_received: '0',
total_fees_sent: '300',
Expand Down
3 changes: 2 additions & 1 deletion tests/utils/test-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ interface TestMempoolTxArgs {
fee_rate?: bigint;
raw_tx?: string;
sponsor_address?: string;
sponsored?: boolean;
receipt_time?: number;
}

Expand All @@ -322,7 +323,7 @@ export function testMempoolTx(args?: TestMempoolTxArgs): DbMempoolTxRaw {
status: args?.status ?? DbTxStatus.Pending,
post_conditions: '0x01f5',
fee_rate: args?.fee_rate ?? 1234n,
sponsored: false,
sponsored: args?.sponsored ?? false,
sponsor_address: args?.sponsor_address,
origin_hash_mode: 1,
sender_address: args?.sender_address ?? SENDER_ADDRESS,
Expand Down
Loading