Skip to content

Commit e217cea

Browse files
authored
feat: return estimated balance in account balance endpoints (#2104)
* feat: mempool principal cache * fix: unify principal activity query * feat: progress * chore: mempool etag * fix: missing column name * fix: pruned * fix: tests * test: feature * fix: coalesce fee rate * fix: token amount coalesce
1 parent 66e6800 commit e217cea

File tree

7 files changed

+193
-3
lines changed

7 files changed

+193
-3
lines changed

src/api/routes/address.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const AddressRoutes: FastifyPluginAsync<
8989
fastify.get(
9090
'/:principal/stx',
9191
{
92-
preHandler: handlePrincipalCache,
92+
preHandler: handlePrincipalMempoolCache,
9393
schema: {
9494
operationId: 'get_account_stx_balance',
9595
summary: 'Get account STX balance',
@@ -120,8 +120,14 @@ export const AddressRoutes: FastifyPluginAsync<
120120
stxAddress,
121121
blockHeight
122122
);
123+
let mempoolBalance: bigint | undefined = undefined;
124+
if (req.query.until_block === undefined) {
125+
const delta = await fastify.db.getPrincipalMempoolStxBalanceDelta(sql, stxAddress);
126+
mempoolBalance = stxBalanceResult.balance + delta;
127+
}
123128
const result: AddressStxBalance = {
124129
balance: stxBalanceResult.balance.toString(),
130+
estimated_balance: mempoolBalance?.toString(),
125131
total_sent: stxBalanceResult.totalSent.toString(),
126132
total_received: stxBalanceResult.totalReceived.toString(),
127133
total_fees_sent: stxBalanceResult.totalFeesSent.toString(),
@@ -145,7 +151,7 @@ export const AddressRoutes: FastifyPluginAsync<
145151
fastify.get(
146152
'/:principal/balances',
147153
{
148-
preHandler: handlePrincipalCache,
154+
preHandler: handlePrincipalMempoolCache,
149155
schema: {
150156
operationId: 'get_account_balance',
151157
summary: 'Get account balances',
@@ -204,9 +210,16 @@ export const AddressRoutes: FastifyPluginAsync<
204210
};
205211
});
206212

213+
let mempoolBalance: bigint | undefined = undefined;
214+
if (req.query.until_block === undefined) {
215+
const delta = await fastify.db.getPrincipalMempoolStxBalanceDelta(sql, stxAddress);
216+
mempoolBalance = stxBalanceResult.balance + delta;
217+
}
218+
207219
const result: AddressBalance = {
208220
stx: {
209221
balance: stxBalanceResult.balance.toString(),
222+
estimated_balance: mempoolBalance?.toString(),
210223
total_sent: stxBalanceResult.totalSent.toString(),
211224
total_received: stxBalanceResult.totalReceived.toString(),
212225
total_fees_sent: stxBalanceResult.totalFeesSent.toString(),

src/api/schemas/entities/balances.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export const NftBalanceSchema = Type.Object(
2121
export const StxBalanceSchema = Type.Object(
2222
{
2323
balance: Type.String(),
24+
estimated_balance: Type.Optional(
25+
Type.String({
26+
description: 'Total STX balance considering pending mempool transactions',
27+
})
28+
),
2429
total_sent: Type.String(),
2530
total_received: Type.String(),
2631
total_fees_sent: Type.String(),

src/datastore/pg-store.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2476,6 +2476,36 @@ export class PgStore extends BasePgStore {
24762476
};
24772477
}
24782478

2479+
/**
2480+
* Returns the total STX balance delta affecting a principal from transactions currently in the
2481+
* mempool.
2482+
*/
2483+
async getPrincipalMempoolStxBalanceDelta(sql: PgSqlClient, principal: string): Promise<bigint> {
2484+
const results = await sql<{ delta: string }[]>`
2485+
WITH sent AS (
2486+
SELECT SUM(COALESCE(token_transfer_amount, 0) + fee_rate) AS total
2487+
FROM mempool_txs
2488+
WHERE pruned = false AND sender_address = ${principal}
2489+
),
2490+
sponsored AS (
2491+
SELECT SUM(fee_rate) AS total
2492+
FROM mempool_txs
2493+
WHERE pruned = false AND sponsor_address = ${principal} AND sponsored = true
2494+
),
2495+
received AS (
2496+
SELECT SUM(COALESCE(token_transfer_amount, 0)) AS total
2497+
FROM mempool_txs
2498+
WHERE pruned = false AND token_transfer_recipient_address = ${principal}
2499+
)
2500+
SELECT
2501+
COALESCE((SELECT total FROM received), 0)
2502+
- COALESCE((SELECT total FROM sent), 0)
2503+
- COALESCE((SELECT total FROM sponsored), 0)
2504+
AS delta
2505+
`;
2506+
return BigInt(results[0]?.delta ?? '0');
2507+
}
2508+
24792509
async getUnlockedStxSupply(
24802510
args:
24812511
| {

tests/api/address.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,6 +1544,7 @@ describe('address tests', () => {
15441544
const expectedResp1 = {
15451545
stx: {
15461546
balance: '88679',
1547+
estimated_balance: '88679',
15471548
total_sent: '6385',
15481549
total_received: '100000',
15491550
total_fees_sent: '4936',
@@ -1587,6 +1588,7 @@ describe('address tests', () => {
15871588
const expectedResp2 = {
15881589
stx: {
15891590
balance: '91',
1591+
estimated_balance: '91',
15901592
total_sent: '15',
15911593
total_received: '1350',
15921594
total_fees_sent: '1244',
@@ -1622,6 +1624,7 @@ describe('address tests', () => {
16221624
expect(fetchAddrStxBalance1.type).toBe('application/json');
16231625
const expectedStxResp1 = {
16241626
balance: '91',
1627+
estimated_balance: '91',
16251628
total_sent: '15',
16261629
total_received: '1350',
16271630
total_fees_sent: '1244',
@@ -1652,6 +1655,7 @@ describe('address tests', () => {
16521655
expect(fetchAddrStxBalance1.type).toBe('application/json');
16531656
const expectedStxResp1Sponsored = {
16541657
balance: '3766',
1658+
estimated_balance: '3766',
16551659
total_sent: '0',
16561660
total_received: '5000',
16571661
total_fees_sent: '1234',

tests/api/mempool.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2128,4 +2128,137 @@ describe('mempool tests', () => {
21282128
);
21292129
expect(check2.body.results).toHaveLength(2);
21302130
});
2131+
2132+
test('account estimated balance from mempool activity', async () => {
2133+
const address = 'SP3FXEKSA6D4BW3TFP2BWTSREV6FY863Y90YY7D8G';
2134+
const url = `/extended/v1/address/${address}/stx`;
2135+
await db.update(
2136+
new TestBlockBuilder({
2137+
block_height: 1,
2138+
index_block_hash: '0x01',
2139+
parent_index_block_hash: '0x00',
2140+
})
2141+
.addTx({
2142+
tx_id: '0x0001',
2143+
token_transfer_recipient_address: address,
2144+
token_transfer_amount: 2000n,
2145+
})
2146+
.addTxStxEvent({ recipient: address, amount: 2000n })
2147+
.build()
2148+
);
2149+
2150+
// Base balance
2151+
const balance0 = await supertest(api.server).get(url);
2152+
expect(balance0.body.balance).toEqual('2000');
2153+
expect(balance0.body.estimated_balance).toEqual('2000');
2154+
2155+
// STX transfer in mempool
2156+
await db.updateMempoolTxs({
2157+
mempoolTxs: [
2158+
testMempoolTx({
2159+
tx_id: '0x0002',
2160+
sender_address: address,
2161+
token_transfer_amount: 100n,
2162+
fee_rate: 50n,
2163+
}),
2164+
],
2165+
});
2166+
const balance1 = await supertest(api.server).get(url);
2167+
expect(balance1.body.balance).toEqual('2000');
2168+
expect(balance1.body.estimated_balance).toEqual('1850'); // Minus amount and fee
2169+
2170+
// Contract call in mempool
2171+
await db.updateMempoolTxs({
2172+
mempoolTxs: [
2173+
testMempoolTx({
2174+
tx_id: '0x0002aa',
2175+
sender_address: address,
2176+
type_id: DbTxTypeId.ContractCall,
2177+
token_transfer_amount: 0n,
2178+
contract_call_contract_id: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world',
2179+
contract_call_function_args: '',
2180+
contract_call_function_name: 'test',
2181+
fee_rate: 50n,
2182+
}),
2183+
],
2184+
});
2185+
const balance1b = await supertest(api.server).get(url);
2186+
expect(balance1b.body.balance).toEqual('2000');
2187+
expect(balance1b.body.estimated_balance).toEqual('1800'); // Minus fee
2188+
2189+
// Sponsored tx in mempool
2190+
await db.updateMempoolTxs({
2191+
mempoolTxs: [
2192+
testMempoolTx({
2193+
tx_id: '0x0003',
2194+
sponsor_address: address,
2195+
sponsored: true,
2196+
token_transfer_amount: 100n,
2197+
fee_rate: 50n,
2198+
}),
2199+
],
2200+
});
2201+
const balance2 = await supertest(api.server).get(url);
2202+
expect(balance2.body.balance).toEqual('2000');
2203+
expect(balance2.body.estimated_balance).toEqual('1750'); // Minus fee
2204+
2205+
// STX received in mempool
2206+
await db.updateMempoolTxs({
2207+
mempoolTxs: [
2208+
testMempoolTx({
2209+
tx_id: '0x0004',
2210+
token_transfer_recipient_address: address,
2211+
token_transfer_amount: 100n,
2212+
fee_rate: 50n,
2213+
}),
2214+
],
2215+
});
2216+
const balance3 = await supertest(api.server).get(url);
2217+
expect(balance3.body.balance).toEqual('2000');
2218+
expect(balance3.body.estimated_balance).toEqual('1850'); // Plus amount
2219+
2220+
// Confirm all txs
2221+
await db.update(
2222+
new TestBlockBuilder({
2223+
block_height: 2,
2224+
index_block_hash: '0x02',
2225+
parent_index_block_hash: '0x01',
2226+
})
2227+
.addTx({
2228+
tx_id: '0x0002',
2229+
sender_address: address,
2230+
token_transfer_amount: 100n,
2231+
fee_rate: 50n,
2232+
})
2233+
.addTxStxEvent({ sender: address, amount: 100n })
2234+
.addTx({
2235+
tx_id: '0x0002aa',
2236+
sender_address: address,
2237+
type_id: DbTxTypeId.ContractCall,
2238+
token_transfer_amount: 0n,
2239+
contract_call_contract_id: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world',
2240+
contract_call_function_args: '',
2241+
contract_call_function_name: 'test',
2242+
fee_rate: 50n,
2243+
})
2244+
.addTx({
2245+
tx_id: '0x0003',
2246+
sponsor_address: address,
2247+
sponsored: true,
2248+
token_transfer_amount: 100n,
2249+
fee_rate: 50n,
2250+
})
2251+
.addTx({
2252+
tx_id: '0x0004',
2253+
token_transfer_recipient_address: address,
2254+
token_transfer_amount: 100n,
2255+
fee_rate: 50n,
2256+
})
2257+
.addTxStxEvent({ recipient: address, amount: 100n })
2258+
.build()
2259+
);
2260+
const balance4 = await supertest(api.server).get(url);
2261+
expect(balance4.body.balance).toEqual('1850');
2262+
expect(balance4.body.estimated_balance).toEqual('1850');
2263+
});
21312264
});

tests/api/tx.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,7 @@ describe('tx tests', () => {
10121012

10131013
const expectedSponsoredRespBefore = {
10141014
balance: '0',
1015+
estimated_balance: '0',
10151016
total_sent: '0',
10161017
total_received: '0',
10171018
total_fees_sent: '0',
@@ -1119,6 +1120,7 @@ describe('tx tests', () => {
11191120

11201121
const expectedResp = {
11211122
balance: '0',
1123+
estimated_balance: '0',
11221124
total_sent: '0',
11231125
total_received: '0',
11241126
total_fees_sent: '0',
@@ -1137,6 +1139,7 @@ describe('tx tests', () => {
11371139
const expectedRespBalance = {
11381140
stx: {
11391141
balance: '0',
1142+
estimated_balance: '0',
11401143
total_sent: '0',
11411144
total_received: '0',
11421145
total_fees_sent: '0',
@@ -1159,6 +1162,7 @@ describe('tx tests', () => {
11591162

11601163
const expectedSponsoredRespAfter = {
11611164
balance: '-300',
1165+
estimated_balance: '-300',
11621166
total_sent: '0',
11631167
total_received: '0',
11641168
total_fees_sent: '300',

tests/utils/test-builders.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ interface TestMempoolTxArgs {
302302
fee_rate?: bigint;
303303
raw_tx?: string;
304304
sponsor_address?: string;
305+
sponsored?: boolean;
305306
receipt_time?: number;
306307
}
307308

@@ -322,7 +323,7 @@ export function testMempoolTx(args?: TestMempoolTxArgs): DbMempoolTxRaw {
322323
status: args?.status ?? DbTxStatus.Pending,
323324
post_conditions: '0x01f5',
324325
fee_rate: args?.fee_rate ?? 1234n,
325-
sponsored: false,
326+
sponsored: args?.sponsored ?? false,
326327
sponsor_address: args?.sponsor_address,
327328
origin_hash_mode: 1,
328329
sender_address: args?.sender_address ?? SENDER_ADDRESS,

0 commit comments

Comments
 (0)