Skip to content

Commit d7d47ca

Browse files
authored
fix: collect high-value RAVs before dust and expose deferrals (#1248)
1 parent 7fe61cb commit d7d47ca

3 files changed

Lines changed: 527 additions & 133 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// Imported from source rather than from '@graphprotocol/indexer-common', whose main
2+
// entry point is the compiled ./dist bundle. Going through the package would test
3+
// whatever was last built instead of the code in this working tree.
4+
import { defineQueryFeeModels, QueryFeeModels } from '../../query-fees/models'
5+
import { TapCollector } from '../tap-collector'
6+
import { GraphTallyCollector } from '../graph-tally-collector'
7+
import {
8+
connectDatabase,
9+
createLogger,
10+
Logger,
11+
toAddress,
12+
} from '@graphprotocol/common-ts'
13+
import { Sequelize } from 'sequelize'
14+
15+
// Make global Jest variables available
16+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
17+
declare const __DATABASE__: any
18+
declare const __LOG_LEVEL__: never
19+
20+
let logger: Logger
21+
let sequelize: Sequelize
22+
let queryFeeModels: QueryFeeModels
23+
24+
const SIGNATURE = Uint8Array.from(Buffer.alloc(65, 1))
25+
const PAYER = toAddress('deadbeefcafebabedeadbeefcafebabedeadbeef')
26+
const DATA_SERVICE = toAddress('0000000000000000000000000000000000000001')
27+
const SERVICE_PROVIDER = toAddress('0000000000000000000000000000000000000002')
28+
29+
// Values are deliberately inserted out of order so a passing assertion cannot be
30+
// explained by Postgres happening to return rows in insertion order.
31+
const V1_RAVS = [
32+
{ allocationId: toAddress('1111111111111111111111111111111111111111'), value: 5n },
33+
{ allocationId: toAddress('2222222222222222222222222222222222222222'), value: 100n },
34+
{ allocationId: toAddress('3333333333333333333333333333333333333333'), value: 1n },
35+
{ allocationId: toAddress('4444444444444444444444444444444444444444'), value: 50n },
36+
]
37+
38+
const V2_RAVS = [
39+
{ collectionId: `0x${'a'.repeat(64)}`, value: 7n },
40+
{ collectionId: `0x${'b'.repeat(64)}`, value: 900n },
41+
{ collectionId: `0x${'c'.repeat(64)}`, value: 3n },
42+
{ collectionId: `0x${'d'.repeat(64)}`, value: 42n },
43+
]
44+
45+
const setup = async () => {
46+
logger = createLogger({
47+
name: 'pending-ravs-order',
48+
async: false,
49+
level: __LOG_LEVEL__ ?? 'error',
50+
})
51+
sequelize = await connectDatabase(__DATABASE__)
52+
queryFeeModels = defineQueryFeeModels(sequelize)
53+
sequelize = await sequelize.sync({ force: true })
54+
}
55+
56+
const teardown = async () => {
57+
await sequelize.drop({})
58+
await sequelize.close()
59+
}
60+
61+
beforeAll(setup, 30000)
62+
afterAll(teardown, 30000)
63+
beforeEach(async () => {
64+
sequelize = await sequelize.sync({ force: true })
65+
})
66+
67+
describe('pendingRAVs ordering', () => {
68+
test('TAPv1 returns pending RAVs ordered by value, highest first', async () => {
69+
// Arrange
70+
for (const { allocationId, value } of V1_RAVS) {
71+
await queryFeeModels.receiptAggregateVouchers.create({
72+
allocationId,
73+
senderAddress: PAYER,
74+
signature: SIGNATURE,
75+
timestampNs: 1n,
76+
valueAggregate: value,
77+
last: true,
78+
final: false,
79+
redeemedAt: null,
80+
})
81+
}
82+
const collector: TapCollector = Object.assign(Object.create(TapCollector.prototype), {
83+
logger,
84+
models: queryFeeModels,
85+
})
86+
87+
// Act
88+
const ravs = await collector['pendingRAVs']()
89+
90+
// Assert
91+
expect(ravs.map((rav) => rav.valueAggregate)).toEqual([100n, 50n, 5n, 1n])
92+
})
93+
94+
test('TAPv2 returns pending RAVs ordered by value, highest first', async () => {
95+
// Arrange
96+
for (const { collectionId, value } of V2_RAVS) {
97+
await queryFeeModels.receiptAggregateVouchersV2.create({
98+
collectionId,
99+
payer: PAYER,
100+
dataService: DATA_SERVICE,
101+
serviceProvider: SERVICE_PROVIDER,
102+
signature: SIGNATURE,
103+
metadata: '0x',
104+
timestampNs: 1n,
105+
valueAggregate: value,
106+
last: true,
107+
final: false,
108+
redeemedAt: null,
109+
})
110+
}
111+
const collector: GraphTallyCollector = Object.assign(
112+
Object.create(GraphTallyCollector.prototype),
113+
{ logger, models: queryFeeModels },
114+
)
115+
116+
// Act
117+
const ravs = await collector['pendingRAVs']()
118+
119+
// Assert
120+
expect(ravs.map((rav) => rav.valueAggregate)).toEqual([900n, 42n, 7n, 3n])
121+
})
122+
123+
test('TAPv1 ordering surfaces high value RAVs from beyond a full batch', async () => {
124+
// Arrange: 1,000 dust RAVs that would fill the batch on their own, plus one
125+
// valuable RAV inserted last so insertion order alone would exclude it.
126+
await queryFeeModels.receiptAggregateVouchers.bulkCreate(
127+
Array.from({ length: 1000 }, (_, i) => ({
128+
allocationId: toAddress(i.toString(16).padStart(40, '0')),
129+
senderAddress: PAYER,
130+
signature: SIGNATURE,
131+
timestampNs: 1n,
132+
valueAggregate: 1n,
133+
last: true,
134+
final: false,
135+
redeemedAt: null,
136+
})),
137+
)
138+
await queryFeeModels.receiptAggregateVouchers.create({
139+
allocationId: toAddress('ffffffffffffffffffffffffffffffffffffffff'),
140+
senderAddress: PAYER,
141+
signature: SIGNATURE,
142+
timestampNs: 1n,
143+
valueAggregate: 10n ** 21n, // 1,000 GRT
144+
last: true,
145+
final: false,
146+
redeemedAt: null,
147+
})
148+
const collector: TapCollector = Object.assign(Object.create(TapCollector.prototype), {
149+
logger,
150+
models: queryFeeModels,
151+
})
152+
153+
// Act
154+
const ravs = await collector['pendingRAVs']()
155+
156+
// Assert
157+
expect(ravs).toHaveLength(1000)
158+
expect(ravs[0].valueAggregate).toEqual(10n ** 21n)
159+
})
160+
161+
test('TAPv2 ordering surfaces high value RAVs from beyond a full batch', async () => {
162+
// Arrange
163+
await queryFeeModels.receiptAggregateVouchersV2.bulkCreate(
164+
Array.from({ length: 1000 }, (_, i) => ({
165+
collectionId: `0x${i.toString(16).padStart(64, '0')}`,
166+
payer: PAYER,
167+
dataService: DATA_SERVICE,
168+
serviceProvider: SERVICE_PROVIDER,
169+
signature: SIGNATURE,
170+
metadata: '0x',
171+
timestampNs: 1n,
172+
valueAggregate: 1n,
173+
last: true,
174+
final: false,
175+
redeemedAt: null,
176+
})),
177+
)
178+
await queryFeeModels.receiptAggregateVouchersV2.create({
179+
collectionId: `0x${'f'.repeat(64)}`,
180+
payer: PAYER,
181+
dataService: DATA_SERVICE,
182+
serviceProvider: SERVICE_PROVIDER,
183+
signature: SIGNATURE,
184+
metadata: '0x',
185+
timestampNs: 1n,
186+
valueAggregate: 10n ** 21n, // 1,000 GRT
187+
last: true,
188+
final: false,
189+
redeemedAt: null,
190+
})
191+
const collector: GraphTallyCollector = Object.assign(
192+
Object.create(GraphTallyCollector.prototype),
193+
{ logger, models: queryFeeModels },
194+
)
195+
196+
// Act
197+
const ravs = await collector['pendingRAVs']()
198+
199+
// Assert
200+
expect(ravs).toHaveLength(1000)
201+
expect(ravs[0].valueAggregate).toEqual(10n ** 21n)
202+
})
203+
204+
test('findTransactionsForRavs chunks the allocation id filter and pins one block', async () => {
205+
// Arrange: 250 pending RAVs with distinct allocations, and a fake subgraph client
206+
// that records each request it receives.
207+
const ravs = Array.from({ length: 250 }, (_, i) => ({
208+
collectionId: `0x${i.toString(16).padStart(64, '0')}`,
209+
payer: PAYER,
210+
redeemedAt: null,
211+
})) as unknown as Parameters<GraphTallyCollector['findTransactionsForRavs']>[0]
212+
const query = jest.fn().mockResolvedValue({
213+
data: {
214+
paymentsEscrowTransactions: [],
215+
_meta: { block: { hash: 'pinned-block', timestamp: 1 } },
216+
},
217+
})
218+
const collector: GraphTallyCollector = Object.assign(
219+
Object.create(GraphTallyCollector.prototype),
220+
{ logger, networkSubgraph: { query } },
221+
)
222+
223+
// Act
224+
const response = await collector.findTransactionsForRavs(ravs)
225+
226+
// Assert: 250 ids split into chunks of at most 100, so 3 requests
227+
expect(query).toHaveBeenCalledTimes(3)
228+
const variables = query.mock.calls.map((call) => call[1])
229+
for (const vars of variables) {
230+
expect(vars.unfinalizedRavsAllocationIds.length).toBeLessThanOrEqual(100)
231+
}
232+
expect(variables.flatMap((vars) => vars.unfinalizedRavsAllocationIds)).toHaveLength(
233+
250,
234+
)
235+
// The first request floats to the chain head; all later ones are pinned to it
236+
expect(variables[0].block).toBeUndefined()
237+
expect(variables[1].block).toEqual({ hash: 'pinned-block' })
238+
expect(variables[2].block).toEqual({ hash: 'pinned-block' })
239+
expect(response._meta.block.hash).toEqual('pinned-block')
240+
})
241+
242+
test('pendingRAVs excludes final and non-last RAVs regardless of value', async () => {
243+
// Arrange
244+
await queryFeeModels.receiptAggregateVouchers.create({
245+
allocationId: V1_RAVS[0].allocationId,
246+
senderAddress: PAYER,
247+
signature: SIGNATURE,
248+
timestampNs: 1n,
249+
valueAggregate: 10n ** 21n,
250+
last: true,
251+
final: true, // already finalized
252+
redeemedAt: null,
253+
})
254+
await queryFeeModels.receiptAggregateVouchers.create({
255+
allocationId: V1_RAVS[1].allocationId,
256+
senderAddress: PAYER,
257+
signature: SIGNATURE,
258+
timestampNs: 1n,
259+
valueAggregate: 10n ** 21n,
260+
last: false, // superseded by a newer RAV
261+
final: false,
262+
redeemedAt: null,
263+
})
264+
await queryFeeModels.receiptAggregateVouchers.create({
265+
allocationId: V1_RAVS[2].allocationId,
266+
senderAddress: PAYER,
267+
signature: SIGNATURE,
268+
timestampNs: 1n,
269+
valueAggregate: 5n,
270+
last: true,
271+
final: false,
272+
redeemedAt: null,
273+
})
274+
const collector: TapCollector = Object.assign(Object.create(TapCollector.prototype), {
275+
logger,
276+
models: queryFeeModels,
277+
})
278+
279+
// Act
280+
const ravs = await collector['pendingRAVs']()
281+
282+
// Assert
283+
expect(ravs).toHaveLength(1)
284+
expect(ravs[0].valueAggregate).toEqual(5n)
285+
})
286+
})

0 commit comments

Comments
 (0)