Skip to content

Commit 3418863

Browse files
authored
fix: consider microblock transactions in balance calculations (#2277)
* fix: balances after microblocks * fix: add test * chore: add migration * fix: truncate table first
1 parent 517ca68 commit 3418863

File tree

3 files changed

+223
-16
lines changed

3 files changed

+223
-16
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/* eslint-disable camelcase */
2+
3+
exports.shorthands = undefined;
4+
5+
exports.up = pgm => {
6+
// Remove old balances.
7+
pgm.sql(`TRUNCATE TABLE ft_balances`);
8+
9+
// Recalculate STX balances
10+
pgm.sql(`
11+
WITH all_balances AS (
12+
SELECT sender AS address, -SUM(amount) AS balance_change
13+
FROM stx_events
14+
WHERE asset_event_type_id IN (1, 3) -- Transfers and Burns affect the sender's balance
15+
AND canonical = true AND microblock_canonical = true
16+
GROUP BY sender
17+
UNION ALL
18+
SELECT recipient AS address, SUM(amount) AS balance_change
19+
FROM stx_events
20+
WHERE asset_event_type_id IN (1, 2) -- Transfers and Mints affect the recipient's balance
21+
AND canonical = true AND microblock_canonical = true
22+
GROUP BY recipient
23+
),
24+
net_balances AS (
25+
SELECT address, SUM(balance_change) AS balance
26+
FROM all_balances
27+
GROUP BY address
28+
),
29+
fees AS (
30+
SELECT address, SUM(total_fees) AS total_fees
31+
FROM (
32+
SELECT sender_address AS address, SUM(fee_rate) AS total_fees
33+
FROM txs
34+
WHERE canonical = true AND microblock_canonical = true AND sponsored = false
35+
GROUP BY sender_address
36+
UNION ALL
37+
SELECT sponsor_address AS address, SUM(fee_rate) AS total_fees
38+
FROM txs
39+
WHERE canonical = true AND microblock_canonical = true AND sponsored = true
40+
GROUP BY sponsor_address
41+
) AS subquery
42+
GROUP BY address
43+
),
44+
rewards AS (
45+
SELECT
46+
recipient AS address,
47+
SUM(
48+
coinbase_amount + tx_fees_anchored + tx_fees_streamed_confirmed + tx_fees_streamed_produced
49+
) AS total_rewards
50+
FROM miner_rewards
51+
WHERE canonical = true
52+
GROUP BY recipient
53+
),
54+
all_addresses AS (
55+
SELECT address FROM net_balances
56+
UNION
57+
SELECT address FROM fees
58+
UNION
59+
SELECT address FROM rewards
60+
)
61+
INSERT INTO ft_balances (address, balance, token)
62+
SELECT
63+
aa.address,
64+
COALESCE(nb.balance, 0) - COALESCE(f.total_fees, 0) + COALESCE(r.total_rewards, 0) AS balance,
65+
'stx' AS token
66+
FROM all_addresses aa
67+
LEFT JOIN net_balances nb ON aa.address = nb.address
68+
LEFT JOIN fees f ON aa.address = f.address
69+
LEFT JOIN rewards r ON aa.address = r.address
70+
`);
71+
72+
// Recalculate FT balances
73+
pgm.sql(`
74+
WITH all_balances AS (
75+
SELECT sender AS address, asset_identifier, -SUM(amount) AS balance_change
76+
FROM ft_events
77+
WHERE asset_event_type_id IN (1, 3) -- Transfers and Burns affect the sender's balance
78+
AND canonical = true
79+
AND microblock_canonical = true
80+
GROUP BY sender, asset_identifier
81+
UNION ALL
82+
SELECT recipient AS address, asset_identifier, SUM(amount) AS balance_change
83+
FROM ft_events
84+
WHERE asset_event_type_id IN (1, 2) -- Transfers and Mints affect the recipient's balance
85+
AND canonical = true
86+
AND microblock_canonical = true
87+
GROUP BY recipient, asset_identifier
88+
),
89+
net_balances AS (
90+
SELECT address, asset_identifier, SUM(balance_change) AS balance
91+
FROM all_balances
92+
GROUP BY address, asset_identifier
93+
)
94+
INSERT INTO ft_balances (address, balance, token)
95+
SELECT address, balance, asset_identifier AS token
96+
FROM net_balances
97+
`);
98+
};
99+
100+
exports.down = pgm => {};

src/datastore/pg-write-store.ts

+19-16
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ export class PgWriteStore extends PgStore {
203203

204204
async update(data: DataStoreBlockUpdateData): Promise<void> {
205205
let garbageCollectedMempoolTxs: string[] = [];
206-
let batchedTxData: DataStoreTxEventData[] = [];
206+
let newTxData: DataStoreTxEventData[] = [];
207207

208208
await this.sqlWriteTransaction(async sql => {
209209
const chainTip = await this.getChainTip(sql);
@@ -223,7 +223,7 @@ export class PgWriteStore extends PgStore {
223223
// Insert microblocks, if any. Clear already inserted microblock txs from the anchor-block
224224
// update data to avoid duplicate inserts.
225225
const insertedMicroblockHashes = await this.insertMicroblocksFromBlockUpdate(sql, data);
226-
batchedTxData = data.txs.filter(entry => {
226+
newTxData = data.txs.filter(entry => {
227227
return !insertedMicroblockHashes.has(entry.tx.microblock_hash);
228228
});
229229

@@ -264,7 +264,7 @@ export class PgWriteStore extends PgStore {
264264

265265
// Clear accepted microblock txs from the anchor-block update data to avoid duplicate
266266
// inserts.
267-
batchedTxData = batchedTxData.filter(entry => {
267+
newTxData = newTxData.filter(entry => {
268268
const matchingTx = acceptedMicroblockTxs.find(tx => tx.tx_id === entry.tx.tx_id);
269269
return !matchingTx;
270270
});
@@ -285,29 +285,32 @@ export class PgWriteStore extends PgStore {
285285
const q = new PgWriteQueue();
286286
q.enqueue(() => this.updateMinerRewards(sql, data.minerRewards));
287287
if (isCanonical) {
288-
q.enqueue(() => this.updateStxBalances(sql, batchedTxData, data.minerRewards));
289-
q.enqueue(() => this.updateFtBalances(sql, batchedTxData));
288+
// Use `data.txs` directly instead of `newTxData` for these STX/FT balance updates because
289+
// we don't want to skip balance changes in transactions that were previously confirmed
290+
// via microblocks.
291+
q.enqueue(() => this.updateStxBalances(sql, data.txs, data.minerRewards));
292+
q.enqueue(() => this.updateFtBalances(sql, data.txs));
290293
}
291294
if (data.poxSetSigners && data.poxSetSigners.signers) {
292295
const poxSet = data.poxSetSigners;
293296
q.enqueue(() => this.updatePoxSetsBatch(sql, data.block, poxSet));
294297
}
295-
if (batchedTxData.length > 0) {
298+
if (newTxData.length > 0) {
296299
q.enqueue(() =>
297300
this.updateTx(
298301
sql,
299-
batchedTxData.map(b => b.tx)
302+
newTxData.map(b => b.tx)
300303
)
301304
);
302-
q.enqueue(() => this.updateStxEvents(sql, batchedTxData));
303-
q.enqueue(() => this.updatePrincipalStxTxs(sql, batchedTxData));
304-
q.enqueue(() => this.updateSmartContractEvents(sql, batchedTxData));
305-
q.enqueue(() => this.updatePoxSyntheticEvents(sql, 'pox2_events', batchedTxData));
306-
q.enqueue(() => this.updatePoxSyntheticEvents(sql, 'pox3_events', batchedTxData));
307-
q.enqueue(() => this.updatePoxSyntheticEvents(sql, 'pox4_events', batchedTxData));
308-
q.enqueue(() => this.updateStxLockEvents(sql, batchedTxData));
309-
q.enqueue(() => this.updateFtEvents(sql, batchedTxData));
310-
for (const entry of batchedTxData) {
305+
q.enqueue(() => this.updateStxEvents(sql, newTxData));
306+
q.enqueue(() => this.updatePrincipalStxTxs(sql, newTxData));
307+
q.enqueue(() => this.updateSmartContractEvents(sql, newTxData));
308+
q.enqueue(() => this.updatePoxSyntheticEvents(sql, 'pox2_events', newTxData));
309+
q.enqueue(() => this.updatePoxSyntheticEvents(sql, 'pox3_events', newTxData));
310+
q.enqueue(() => this.updatePoxSyntheticEvents(sql, 'pox4_events', newTxData));
311+
q.enqueue(() => this.updateStxLockEvents(sql, newTxData));
312+
q.enqueue(() => this.updateFtEvents(sql, newTxData));
313+
for (const entry of newTxData) {
311314
q.enqueue(() => this.updateNftEvents(sql, entry.tx, entry.nftEvents));
312315
q.enqueue(() => this.updateSmartContracts(sql, entry.tx, entry.smartContracts));
313316
q.enqueue(() => this.updateNamespaces(sql, entry.tx, entry.namespaces));

tests/api/address.test.ts

+104
Original file line numberDiff line numberDiff line change
@@ -3057,4 +3057,108 @@ describe('address tests', () => {
30573057
expect(json6.results.length).toEqual(4);
30583058
expect(json6.results[0].tx_id).toEqual('0xffa1');
30593059
});
3060+
3061+
test('balance calculation after microblock confirmations', async () => {
3062+
const addr1 = 'SP3D6PV2ACBPEKYJTCMH7HEN02KP87QSP8KTEH335';
3063+
const addr2 = 'SP2TBW1RSC44JZA4XQ1C2G5SZRGSMM14C5NWAKSDD';
3064+
3065+
// Send some initial balance for addr1
3066+
await db.update(
3067+
new TestBlockBuilder({
3068+
block_height: 1,
3069+
index_block_hash: '0x0001',
3070+
parent_index_block_hash: '',
3071+
})
3072+
.addTx({
3073+
tx_id: '0x1101',
3074+
token_transfer_recipient_address: addr1,
3075+
type_id: DbTxTypeId.TokenTransfer,
3076+
token_transfer_amount: 20_000n,
3077+
fee_rate: 50n,
3078+
})
3079+
.addTxStxEvent({
3080+
amount: 20_000n,
3081+
block_height: 1,
3082+
recipient: addr1,
3083+
tx_id: '0x1101',
3084+
})
3085+
.build()
3086+
);
3087+
// Send STX to addr2 in a microblock in transaction 0x1102
3088+
await db.updateMicroblocks(
3089+
new TestMicroblockStreamBuilder()
3090+
.addMicroblock({
3091+
parent_index_block_hash: '0x0001',
3092+
microblock_hash: '0xff01',
3093+
microblock_sequence: 0,
3094+
})
3095+
.addTx({
3096+
tx_id: '0x1102',
3097+
sender_address: addr1,
3098+
token_transfer_recipient_address: addr2,
3099+
type_id: DbTxTypeId.TokenTransfer,
3100+
token_transfer_amount: 2000n,
3101+
fee_rate: 100n,
3102+
microblock_hash: '0xff01',
3103+
microblock_sequence: 0,
3104+
})
3105+
.addTxStxEvent({
3106+
amount: 2000n,
3107+
block_height: 2,
3108+
sender: addr1,
3109+
recipient: addr2,
3110+
tx_id: '0x1102',
3111+
})
3112+
.build()
3113+
);
3114+
await db.update(
3115+
new TestBlockBuilder({
3116+
block_height: 2,
3117+
index_block_hash: '0x0002',
3118+
parent_index_block_hash: '0x0001',
3119+
parent_microblock_hash: '0xff01',
3120+
parent_microblock_sequence: 0,
3121+
})
3122+
// Same transaction 0x1102 now appears confirmed in an anchor block
3123+
.addTx({
3124+
tx_id: '0x1102',
3125+
sender_address: addr1,
3126+
token_transfer_recipient_address: addr2,
3127+
type_id: DbTxTypeId.TokenTransfer,
3128+
token_transfer_amount: 2000n,
3129+
fee_rate: 100n,
3130+
microblock_hash: '0xff01',
3131+
microblock_sequence: 0,
3132+
})
3133+
.addTxStxEvent({
3134+
amount: 2000n,
3135+
block_height: 2,
3136+
sender: addr1,
3137+
recipient: addr2,
3138+
tx_id: '0x1102',
3139+
})
3140+
.build()
3141+
);
3142+
3143+
// Check that v1 balance matches v2 balance for both accounts.
3144+
let result = await supertest(api.server).get(`/extended/v1/address/${addr1}/stx`);
3145+
expect(result.status).toBe(200);
3146+
expect(result.type).toBe('application/json');
3147+
let v1balance = JSON.parse(result.text).balance;
3148+
expect(v1balance).toBe('17900');
3149+
result = await supertest(api.server).get(`/extended/v2/addresses/${addr1}/balances/stx`);
3150+
expect(result.status).toBe(200);
3151+
expect(result.type).toBe('application/json');
3152+
expect(JSON.parse(result.text).balance).toBe(v1balance);
3153+
3154+
result = await supertest(api.server).get(`/extended/v1/address/${addr2}/stx`);
3155+
expect(result.status).toBe(200);
3156+
expect(result.type).toBe('application/json');
3157+
v1balance = JSON.parse(result.text).balance;
3158+
expect(v1balance).toBe('2000');
3159+
result = await supertest(api.server).get(`/extended/v2/addresses/${addr2}/balances/stx`);
3160+
expect(result.status).toBe(200);
3161+
expect(result.type).toBe('application/json');
3162+
expect(JSON.parse(result.text).balance).toBe(v1balance);
3163+
});
30603164
});

0 commit comments

Comments
 (0)