Skip to content

Commit 6db395a

Browse files
authored
fix: include pox unlocks in principal etag calculations (#2520)
1 parent 071f193 commit 6db395a

3 files changed

Lines changed: 137 additions & 6 deletions

File tree

src/api/controllers/cache-controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,9 @@ async function calculateETag(
149149
etagType == ETagType.principalMempool
150150
);
151151
if (!activity.confirmed && !activity.mempool) return ETAG_EMPTY;
152-
return sha256(`${activity.confirmed ?? ''}:${activity.mempool ?? ''}`);
152+
return sha256(
153+
`${activity.confirmed ?? ''}:${activity.mempool ?? ''}:${activity.pox_state ?? ''}`
154+
);
153155
}
154156
}
155157
} catch (error) {

src/datastore/pg-store.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4443,15 +4443,18 @@ export class PgStore extends BasePgStore {
44434443

44444444
/**
44454445
* Retrieves the last transaction IDs with STX, FT or NFT activity for a principal, with or
4446-
* without mempool transactions.
4446+
* without mempool transactions. Also returns the current PoX lock state so that ETags
4447+
* invalidate when STX unlock at a PoX cycle boundary (no transaction is emitted for unlocks).
44474448
* @param includeMempool - include mempool transactions
4448-
* @returns the last confirmed and mempool transaction IDs for the principal
4449+
* @returns the last confirmed and mempool transaction IDs for the principal, plus PoX lock state
44494450
*/
44504451
async getPrincipalLastActivityTxIds(
44514452
principal: string,
44524453
includeMempool: boolean = false
4453-
): Promise<{ confirmed: string | null; mempool: string | null }> {
4454-
const result = await this.sql<{ confirmed: string | null; mempool: string | null }[]>`
4454+
): Promise<{ confirmed: string | null; mempool: string | null; pox_state: string | null }> {
4455+
const result = await this.sql<
4456+
{ confirmed: string | null; mempool: string | null; pox_state: string | null }[]
4457+
>`
44554458
SELECT (
44564459
SELECT '0x' || encode(tx_id, 'hex') AS tx_id
44574460
FROM principal_txs
@@ -4474,7 +4477,20 @@ export class PgStore extends BasePgStore {
44744477
)`
44754478
: this.sql`NULL`
44764479
}
4477-
AS mempool
4480+
AS mempool,
4481+
(
4482+
SELECT CASE
4483+
WHEN burnchain_unlock_height >= (SELECT burn_block_height FROM chain_tip)
4484+
AND name != ${SyntheticPoxEventName.HandleUnlock}
4485+
THEN 'locked'
4486+
ELSE 'unlocked'
4487+
END
4488+
FROM pox4_events
4489+
WHERE stacker = ${principal}
4490+
AND canonical = true AND microblock_canonical = true
4491+
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
4492+
LIMIT 1
4493+
) AS pox_state
44784494
`;
44794495
return result[0];
44804496
}

tests/api/cache/cache-control.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { beforeEach, afterEach, describe, test } from 'node:test';
1717
import assert from 'node:assert/strict';
1818
import { assertMatchesObject } from '../test-helpers.ts';
1919
import { STACKS_TESTNET } from '@stacks/network';
20+
import { SyntheticPoxEventName } from '../../../src/pox-helpers.ts';
2021

2122
describe('cache-control tests', () => {
2223
let db: PgWriteStore;
@@ -1104,4 +1105,116 @@ describe('cache-control tests', () => {
11041105
assert.equal(request7.status, 304);
11051106
assert.equal(request7.text, '');
11061107
});
1108+
1109+
test('principal cache control invalidates on PoX STX unlock', async () => {
1110+
const stacker = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6';
1111+
const url = `/extended/v2/addresses/${stacker}/transactions`;
1112+
1113+
// Block 1: initial block, no stacking activity.
1114+
await db.update(
1115+
new TestBlockBuilder({
1116+
block_height: 1,
1117+
index_block_hash: '0x01',
1118+
parent_index_block_hash: '0x00',
1119+
burn_block_height: 100,
1120+
}).build()
1121+
);
1122+
1123+
const request1 = await supertest(api.server).get(url);
1124+
assert.equal(request1.status, 200);
1125+
const etag0 = request1.headers['etag'];
1126+
1127+
// Block 2: stacker locks STX via stack-stx, unlocking at burn height 200.
1128+
const block2 = new TestBlockBuilder({
1129+
block_height: 2,
1130+
index_block_hash: '0x02',
1131+
parent_index_block_hash: '0x01',
1132+
burn_block_height: 100,
1133+
}).addTx({ tx_id: '0x0001', sender_address: stacker });
1134+
block2.txData.pox4Events.push({
1135+
event_index: 0,
1136+
tx_id: '0x0001',
1137+
tx_index: 0,
1138+
block_height: 2,
1139+
canonical: true,
1140+
stacker: stacker,
1141+
locked: 1000n,
1142+
balance: 5000n,
1143+
burnchain_unlock_height: 200n,
1144+
pox_addr: null,
1145+
pox_addr_raw: null,
1146+
name: SyntheticPoxEventName.StackStx,
1147+
data: {
1148+
lock_amount: 1000n,
1149+
lock_period: 1n,
1150+
start_burn_height: 100n,
1151+
unlock_burn_height: 200n,
1152+
signer_key: '0x0011223344',
1153+
end_cycle_id: null,
1154+
start_cycle_id: null,
1155+
},
1156+
});
1157+
await db.update(block2.build());
1158+
1159+
// ETag changed due to the new transaction.
1160+
const request2 = await supertest(api.server).get(url);
1161+
assert.equal(request2.status, 200);
1162+
const etag1 = request2.headers['etag'];
1163+
assert.notEqual(etag1, etag0);
1164+
1165+
// Cache works with current ETag.
1166+
const request3 = await supertest(api.server).get(url).set('If-None-Match', etag1);
1167+
assert.equal(request3.status, 304);
1168+
assert.equal(request3.text, '');
1169+
1170+
// Block 3: chain advances, burn height still below unlock — no new tx for stacker.
1171+
await db.update(
1172+
new TestBlockBuilder({
1173+
block_height: 3,
1174+
index_block_hash: '0x03',
1175+
parent_index_block_hash: '0x02',
1176+
burn_block_height: 150,
1177+
}).build()
1178+
);
1179+
1180+
// Cache still works: pox_state is still 'locked', no new activity.
1181+
const request4 = await supertest(api.server).get(url).set('If-None-Match', etag1);
1182+
assert.equal(request4.status, 304);
1183+
assert.equal(request4.text, '');
1184+
1185+
// Block 4: burn height crosses unlock threshold — STX are now unlocked.
1186+
await db.update(
1187+
new TestBlockBuilder({
1188+
block_height: 4,
1189+
index_block_hash: '0x04',
1190+
parent_index_block_hash: '0x03',
1191+
burn_block_height: 201,
1192+
}).build()
1193+
);
1194+
1195+
// Cache is now a miss because pox_state changed from 'locked' to 'unlocked'.
1196+
const request5 = await supertest(api.server).get(url).set('If-None-Match', etag1);
1197+
assert.equal(request5.status, 200);
1198+
const etag2 = request5.headers['etag'];
1199+
assert.notEqual(etag2, etag1);
1200+
1201+
// New ETag works.
1202+
const request6 = await supertest(api.server).get(url).set('If-None-Match', etag2);
1203+
assert.equal(request6.status, 304);
1204+
assert.equal(request6.text, '');
1205+
1206+
// Block 5: chain advances further, no new activity — ETag stays stable.
1207+
await db.update(
1208+
new TestBlockBuilder({
1209+
block_height: 5,
1210+
index_block_hash: '0x05',
1211+
parent_index_block_hash: '0x04',
1212+
burn_block_height: 250,
1213+
}).build()
1214+
);
1215+
1216+
const request7 = await supertest(api.server).get(url).set('If-None-Match', etag2);
1217+
assert.equal(request7.status, 304);
1218+
assert.equal(request7.text, '');
1219+
});
11071220
});

0 commit comments

Comments
 (0)