@exponent-labs/exponent-vaults-fetcher 0.9.25 → 0.9.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,12 @@ import { web3 } from "@coral-xyz/anchor"
2
2
  import assert from "node:assert/strict"
3
3
  import test from "node:test"
4
4
 
5
+ import { ExponentVaultsPDA } from "@exponent-labs/exponent-vaults-pda"
6
+
5
7
  import { ExponentVaultsFetcher } from "./fetcher"
6
8
 
7
9
  const EXPONENT_PRICES_DISCRIMINATOR = Buffer.from([188, 97, 196, 52, 192, 41, 211, 196])
10
+ const PRICE_INTERFACE_ACCOUNTS_DISCRIMINATOR = Buffer.from([166, 196, 173, 204, 35, 6, 104, 230])
8
11
  const PROGRAM_ID = new web3.PublicKey(Uint8Array.from({ length: 32 }, (_, index) => index + 1))
9
12
  const PRICE_MINT = new web3.PublicKey(Uint8Array.from({ length: 32 }, (_, index) => index + 33))
10
13
 
@@ -21,36 +24,144 @@ const EXPONENT_PRICES_SIZE = PRICE_NODES_OFFSET + MAX_PRICES * PRICE_NODE_SIZE +
21
24
  const PRICE_DECIMALS = 6
22
25
  const UNDERLYING_DECIMALS = 9
23
26
  const SY_PRICE_ID = 0x0807060504030201n
27
+ const ZERO_PUBKEY = new web3.PublicKey(new Uint8Array(PUBKEY_BYTES))
28
+
29
+ type ScopeConfigFixture = {
30
+ maximumAgeSeconds: bigint
31
+ priceChain: readonly [number, number, number, number]
32
+ }
33
+
34
+ type AccountFixture = {
35
+ data: Buffer
36
+ owner?: web3.PublicKey
37
+ }
38
+
39
+ type ConnectionOptions = {
40
+ accounts?: ReadonlyMap<string, AccountFixture | null>
41
+ rpcError?: Error
42
+ shortResponse?: boolean
43
+ }
24
44
 
25
- function createExponentPricesData(reservedByte: number): Buffer {
45
+ function createPublicKey(seed: number): web3.PublicKey {
46
+ const bytes = new Uint8Array(PUBKEY_BYTES).fill(255)
47
+ new DataView(bytes.buffer).setUint32(0, seed, true)
48
+ return new web3.PublicKey(bytes)
49
+ }
50
+
51
+ function createExponentPricesData(priceInterfaceAccounts: readonly web3.PublicKey[], reservedByte = 0): Buffer {
26
52
  const data = Buffer.alloc(EXPONENT_PRICES_SIZE)
27
53
  EXPONENT_PRICES_DISCRIMINATOR.copy(data)
28
- data.writeBigUInt64LE(1n, 8 + 8 + MAX_PRICE_MANAGERS * PUBKEY_BYTES)
29
- PRICE_MINT.toBuffer().copy(data, FIRST_PRICE_VALUE_OFFSET)
30
-
31
- data.writeUInt8(PRICE_DECIMALS, FIRST_PRICE_VALUE_OFFSET + 160)
32
- data.writeUInt8(UNDERLYING_DECIMALS, FIRST_PRICE_VALUE_OFFSET + 161)
33
- data.fill(reservedByte, FIRST_PRICE_VALUE_OFFSET + 162, FIRST_PRICE_VALUE_OFFSET + 168)
34
- data.writeBigUInt64LE(SY_PRICE_ID, FIRST_PRICE_VALUE_OFFSET + 168)
35
- data.fill(reservedByte, FIRST_PRICE_VALUE_OFFSET + 176, FIRST_PRICE_VALUE_OFFSET + EXPONENT_PRICE_SIZE)
54
+ data.writeBigUInt64LE(BigInt(priceInterfaceAccounts.length), 8 + 8 + MAX_PRICE_MANAGERS * PUBKEY_BYTES)
55
+
56
+ for (const [index, priceInterfaceAccount] of priceInterfaceAccounts.entries()) {
57
+ const valueOffset = FIRST_PRICE_VALUE_OFFSET + index * PRICE_NODE_SIZE
58
+ PRICE_MINT.toBuffer().copy(data, valueOffset)
59
+ priceInterfaceAccount.toBuffer().copy(data, valueOffset + 128)
60
+ data.writeUInt8(PRICE_DECIMALS, valueOffset + 160)
61
+ data.writeUInt8(UNDERLYING_DECIMALS, valueOffset + 161)
62
+ data.fill(reservedByte, valueOffset + 162, valueOffset + 168)
63
+ data.writeBigUInt64LE(SY_PRICE_ID, valueOffset + 168)
64
+ data.fill(reservedByte, valueOffset + 176, valueOffset + EXPONENT_PRICE_SIZE)
65
+ }
66
+
36
67
  return data
37
68
  }
38
69
 
39
- function createConnection(data: Buffer): web3.Connection {
40
- return {
41
- getAccountInfo: async () => ({
42
- data,
43
- executable: false,
44
- lamports: 1,
45
- owner: PROGRAM_ID,
46
- rentEpoch: 0,
47
- }),
70
+ function createPriceInterfaceAccountsData(
71
+ interfaceAccounts: readonly web3.PublicKey[],
72
+ scopeConfig?: ScopeConfigFixture,
73
+ ): Buffer {
74
+ const data = Buffer.alloc(12 + interfaceAccounts.length * PUBKEY_BYTES + (scopeConfig ? 17 : 0))
75
+ PRICE_INTERFACE_ACCOUNTS_DISCRIMINATOR.copy(data)
76
+ data.writeUInt32LE(interfaceAccounts.length, 8)
77
+ interfaceAccounts.forEach((account, index) => account.toBuffer().copy(data, 12 + index * PUBKEY_BYTES))
78
+
79
+ if (scopeConfig) {
80
+ const stateOffset = 12 + interfaceAccounts.length * PUBKEY_BYTES
81
+ data.writeUInt8(2, stateOffset)
82
+ scopeConfig.priceChain.forEach((price, index) => data.writeUInt16LE(price, stateOffset + 1 + index * 2))
83
+ data.writeBigUInt64LE(scopeConfig.maximumAgeSeconds, stateOffset + 9)
84
+ }
85
+
86
+ return data
87
+ }
88
+
89
+ function createConnection(registryData: Buffer, options: ConnectionOptions = {}) {
90
+ const registryAddress = new ExponentVaultsPDA(PROGRAM_ID).exponentPrices()[0]
91
+ const singleAccountReads: web3.PublicKey[] = []
92
+ const multipleAccountReads: web3.PublicKey[][] = []
93
+ let activeMultipleAccountReads = 0
94
+ let maxConcurrentMultipleAccountReads = 0
95
+
96
+ const connection = {
97
+ async getAccountInfo(address: web3.PublicKey) {
98
+ singleAccountReads.push(address)
99
+ if (!address.equals(registryAddress)) {
100
+ throw new Error(`Unexpected getAccountInfo for PriceInterfaceAccounts: ${address.toBase58()}`)
101
+ }
102
+ return {
103
+ data: registryData,
104
+ executable: false,
105
+ lamports: 1,
106
+ owner: PROGRAM_ID,
107
+ rentEpoch: 0,
108
+ }
109
+ },
110
+ async getMultipleAccountsInfo(addresses: web3.PublicKey[]) {
111
+ multipleAccountReads.push(addresses)
112
+ activeMultipleAccountReads += 1
113
+ maxConcurrentMultipleAccountReads = Math.max(maxConcurrentMultipleAccountReads, activeMultipleAccountReads)
114
+ try {
115
+ await Promise.resolve()
116
+ if (options.rpcError) throw options.rpcError
117
+
118
+ const infos = addresses.map((address) => {
119
+ const fixture = options.accounts?.get(address.toBase58())
120
+ if (fixture === null || !fixture) return null
121
+ return {
122
+ data: fixture.data,
123
+ executable: false,
124
+ lamports: 1,
125
+ owner: fixture.owner ?? PROGRAM_ID,
126
+ rentEpoch: 0,
127
+ }
128
+ })
129
+ return options.shortResponse ? infos.slice(0, -1) : infos
130
+ } finally {
131
+ activeMultipleAccountReads -= 1
132
+ }
133
+ },
48
134
  } as unknown as web3.Connection
135
+
136
+ return {
137
+ connection,
138
+ multipleAccountReads,
139
+ get maxConcurrentMultipleAccountReads() {
140
+ return maxConcurrentMultipleAccountReads
141
+ },
142
+ registryAddress,
143
+ singleAccountReads,
144
+ }
145
+ }
146
+
147
+ function createAccountFixtures(keys: readonly web3.PublicKey[]): Map<string, AccountFixture> {
148
+ return new Map(
149
+ keys.map((key, index) => [
150
+ key.toBase58(),
151
+ {
152
+ data: createPriceInterfaceAccountsData([createPublicKey(128 + index)], {
153
+ maximumAgeSeconds: BigInt(index + 1),
154
+ priceChain: [index + 1, index + 2, index + 3, index + 4],
155
+ }),
156
+ },
157
+ ]),
158
+ )
49
159
  }
50
160
 
51
161
  test("decodes price metadata from exact wire offsets without consuming reserved bytes", async () => {
52
162
  for (const reservedByte of [0xa5, 0x5a]) {
53
- const fetcher = new ExponentVaultsFetcher(createConnection(createExponentPricesData(reservedByte)), PROGRAM_ID)
163
+ const { connection } = createConnection(createExponentPricesData([ZERO_PUBKEY], reservedByte))
164
+ const fetcher = new ExponentVaultsFetcher(connection, PROGRAM_ID)
54
165
  const prices = await fetcher.fetchExponentPrices()
55
166
  const price = prices.prices[1]
56
167
 
@@ -60,3 +171,176 @@ test("decodes price metadata from exact wire offsets without consuming reserved
60
171
  assert.equal(price.syPriceId, SY_PRICE_ID)
61
172
  }
62
173
  })
174
+
175
+ test("fetches unique price interface accounts sequentially in maximum 100-key batches", async () => {
176
+ const cases = [
177
+ { count: 0, expectedBatchSizes: [] },
178
+ { count: 1, expectedBatchSizes: [1] },
179
+ { count: 99, expectedBatchSizes: [99] },
180
+ { count: 100, expectedBatchSizes: [100] },
181
+ { count: 101, expectedBatchSizes: [100, 1] },
182
+ { count: 200, expectedBatchSizes: [100, 100] },
183
+ { count: 201, expectedBatchSizes: [100, 100, 1] },
184
+ { count: 1024, expectedBatchSizes: [100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 24] },
185
+ ]
186
+
187
+ for (const { count, expectedBatchSizes } of cases) {
188
+ const keys = Array.from({ length: count }, (_, index) => createPublicKey(index + 1))
189
+ const connectionState = createConnection(createExponentPricesData(keys), { accounts: createAccountFixtures(keys) })
190
+
191
+ const prices = await new ExponentVaultsFetcher(connectionState.connection, PROGRAM_ID).fetchExponentPrices()
192
+
193
+ assert.deepEqual(
194
+ connectionState.multipleAccountReads.map((batch) => batch.length),
195
+ expectedBatchSizes,
196
+ `count ${count}`,
197
+ )
198
+ assert.ok(
199
+ connectionState.multipleAccountReads.every((batch) => batch.length <= 100),
200
+ `count ${count}`,
201
+ )
202
+ assert.equal(connectionState.maxConcurrentMultipleAccountReads, count === 0 ? 0 : 1, `count ${count}`)
203
+ assert.deepEqual(
204
+ connectionState.singleAccountReads.map((address) => address.toBase58()),
205
+ [connectionState.registryAddress.toBase58()],
206
+ `count ${count}`,
207
+ )
208
+ assert.deepEqual(
209
+ connectionState.multipleAccountReads.flat().map((address) => address.toBase58()),
210
+ keys.map((key) => key.toBase58()),
211
+ `count ${count}`,
212
+ )
213
+ for (const [index, key] of keys.entries()) {
214
+ const price = prices.prices[index + 1]
215
+ assert.ok(price, `count ${count}, price ${index + 1}`)
216
+ assert.deepEqual(price.interfaceAccounts, [createPublicKey(128 + index)])
217
+ assert.deepEqual(price.scopeConfig, {
218
+ maximumAgeSeconds: BigInt(index + 1),
219
+ priceChain: [index + 1, index + 2, index + 3, index + 4],
220
+ })
221
+ assert.equal(price.priceInterfaceAccounts.toBase58(), key.toBase58())
222
+ }
223
+ }
224
+ })
225
+
226
+ test("deduplicates nonzero price interface accounts and hydrates every matching price", async () => {
227
+ const firstKey = createPublicKey(250)
228
+ const secondKey = createPublicKey(1)
229
+ const keys = [firstKey, ZERO_PUBKEY, firstKey, secondKey]
230
+ assert.notDeepEqual([firstKey.toBase58(), secondKey.toBase58()], [firstKey.toBase58(), secondKey.toBase58()].sort())
231
+ const { connection, multipleAccountReads, registryAddress, singleAccountReads } = createConnection(
232
+ createExponentPricesData(keys),
233
+ { accounts: createAccountFixtures([firstKey, secondKey]) },
234
+ )
235
+
236
+ const prices = await new ExponentVaultsFetcher(connection, PROGRAM_ID).fetchExponentPrices()
237
+
238
+ assert.deepEqual(
239
+ singleAccountReads.map((address) => address.toBase58()),
240
+ [registryAddress.toBase58()],
241
+ )
242
+ assert.deepEqual(
243
+ multipleAccountReads.map((batch) => batch.map((key) => key.toBase58())),
244
+ [[firstKey.toBase58(), secondKey.toBase58()]],
245
+ )
246
+ assert.deepEqual(prices.prices[1]?.interfaceAccounts, [createPublicKey(128)])
247
+ assert.deepEqual(prices.prices[2]?.interfaceAccounts, [])
248
+ assert.deepEqual(prices.prices[3]?.interfaceAccounts, [createPublicKey(128)])
249
+ assert.deepEqual(prices.prices[4]?.interfaceAccounts, [createPublicKey(129)])
250
+ assert.strictEqual(prices.prices[1]?.interfaceAccounts, prices.prices[3]?.interfaceAccounts)
251
+ assert.ok(prices.prices[1]?.scopeConfig)
252
+ assert.strictEqual(prices.prices[1]?.scopeConfig, prices.prices[3]?.scopeConfig)
253
+ })
254
+
255
+ test("preserves PriceInterfaceAccounts validation errors and malformed RPC response failures", async () => {
256
+ const key = createPublicKey(1)
257
+ const registryData = createExponentPricesData([key])
258
+
259
+ await assert.rejects(
260
+ () => new ExponentVaultsFetcher(createConnection(registryData).connection, PROGRAM_ID).fetchExponentPrices(),
261
+ new Error(`PriceInterfaceAccounts account not found: ${key.toBase58()}`),
262
+ )
263
+
264
+ await assert.rejects(
265
+ () =>
266
+ new ExponentVaultsFetcher(
267
+ createConnection(registryData, {
268
+ accounts: new Map([
269
+ [key.toBase58(), { data: createPriceInterfaceAccountsData([]), owner: createPublicKey(2) }],
270
+ ]),
271
+ }).connection,
272
+ PROGRAM_ID,
273
+ ).fetchExponentPrices(),
274
+ new Error(
275
+ `PriceInterfaceAccounts owner mismatch: expected ${PROGRAM_ID.toBase58()}, got ${createPublicKey(2).toBase58()}`,
276
+ ),
277
+ )
278
+
279
+ await assert.rejects(
280
+ () =>
281
+ new ExponentVaultsFetcher(
282
+ createConnection(registryData, { accounts: createAccountFixtures([key]), shortResponse: true }).connection,
283
+ PROGRAM_ID,
284
+ ).fetchExponentPrices(),
285
+ new Error("PriceInterfaceAccounts batch size mismatch: requested 1, got 0"),
286
+ )
287
+
288
+ const rpcError = new Error("RPC unavailable")
289
+ await assert.rejects(
290
+ () =>
291
+ new ExponentVaultsFetcher(
292
+ createConnection(registryData, { accounts: createAccountFixtures([key]), rpcError }).connection,
293
+ PROGRAM_ID,
294
+ ).fetchExponentPrices(),
295
+ rpcError,
296
+ )
297
+
298
+ await assert.rejects(
299
+ () =>
300
+ new ExponentVaultsFetcher(
301
+ createConnection(registryData, {
302
+ accounts: new Map([[key.toBase58(), { data: Buffer.alloc(1) }]]),
303
+ }).connection,
304
+ PROGRAM_ID,
305
+ ).fetchExponentPrices(),
306
+ new Error("PriceInterfaceAccounts account too small: 1 bytes"),
307
+ )
308
+ })
309
+
310
+ test("reports a missing account at its indexed position in the second batch", async () => {
311
+ const keys = Array.from({ length: 102 }, (_, index) => createPublicKey(index + 1))
312
+ const accounts = createAccountFixtures(keys)
313
+ const missingKey = keys[101]
314
+ accounts.set(missingKey.toBase58(), null)
315
+
316
+ await assert.rejects(
317
+ () =>
318
+ new ExponentVaultsFetcher(
319
+ createConnection(createExponentPricesData(keys), { accounts }).connection,
320
+ PROGRAM_ID,
321
+ ).fetchExponentPrices(),
322
+ new Error(`PriceInterfaceAccounts account not found: ${missingKey.toBase58()}`),
323
+ )
324
+ })
325
+
326
+ test("reports an owner mismatch at its indexed position in a later batch", async () => {
327
+ const keys = Array.from({ length: 202 }, (_, index) => createPublicKey(index + 1))
328
+ const accounts = createAccountFixtures(keys)
329
+ const mismatchedKey = keys[201]
330
+ const unexpectedOwner = createPublicKey(250)
331
+ accounts.set(mismatchedKey.toBase58(), {
332
+ data: createPriceInterfaceAccountsData([]),
333
+ owner: unexpectedOwner,
334
+ })
335
+
336
+ await assert.rejects(
337
+ () =>
338
+ new ExponentVaultsFetcher(
339
+ createConnection(createExponentPricesData(keys), { accounts }).connection,
340
+ PROGRAM_ID,
341
+ ).fetchExponentPrices(),
342
+ new Error(
343
+ `PriceInterfaceAccounts owner mismatch: expected ${PROGRAM_ID.toBase58()}, got ${unexpectedOwner.toBase58()}`,
344
+ ),
345
+ )
346
+ })
package/src/fetcher.ts CHANGED
@@ -4,14 +4,15 @@ import { ExponentVaults, IDL, PROGRAM_ID } from "@exponent-labs/exponent-vaults-
4
4
  import { ExponentVaultsPDA } from "@exponent-labs/exponent-vaults-pda"
5
5
 
6
6
  import { IGNORED_VAULTS } from "./constants"
7
- import { transformAnchorData } from "./typeUtils"
8
7
  import { decodePriceInterfaceAccounts } from "./priceInterfaceAccounts"
8
+ import { transformAnchorData } from "./typeUtils"
9
9
  import { ActionProposal, ExponentPrices, ExponentVault, WithdrawalAccount } from "./types"
10
10
 
11
11
  const EXPONENT_PRICES_META_DISCRIMINATOR = Buffer.from([188, 97, 196, 52, 192, 41, 211, 196])
12
12
  const MAX_PRICES = 1024
13
13
  const MAX_PRICE_MANAGERS = 8
14
14
  const MAX_AUTHORIZED_VAULT_CREATORS = 128
15
+ const MAX_GET_MULTIPLE_ACCOUNTS_INFO_KEYS = 100
15
16
  const PRICE_NODE_REGISTERS = 4
16
17
  const PRICE_USAGE_AMOUNT_REGISTER = 1
17
18
 
@@ -261,9 +262,9 @@ export class ExponentVaultsFetcher {
261
262
  }))
262
263
  }
263
264
 
264
- async fetchAllWithdrawalAccountsDeserialized(filters?: web3.GetProgramAccountsFilter[]): Promise<
265
- DecodedAccount<WithdrawalAccount>[]
266
- > {
265
+ async fetchAllWithdrawalAccountsDeserialized(
266
+ filters?: web3.GetProgramAccountsFilter[],
267
+ ): Promise<DecodedAccount<WithdrawalAccount>[]> {
267
268
  const accounts = await this.fetchAllWithdrawalAccounts(filters)
268
269
  return accounts.map(({ publicKey, account }) => ({
269
270
  publicKey,
@@ -432,20 +433,34 @@ export class ExponentVaultsFetcher {
432
433
  const activePriceEntries = prices.filter((entry) => entry !== null) as NonNullable<
433
434
  ExponentPrices["prices"][number]
434
435
  >[]
435
- const uniqueInterfaceAccounts = [
436
- ...new Map(
437
- activePriceEntries.map((entry) => [entry.priceInterfaceAccounts.toBase58(), entry.priceInterfaceAccounts]),
438
- ).values(),
439
- ].filter((pubkey) => !pubkey.equals(ZERO_PUBKEY))
440
-
441
- if (uniqueInterfaceAccounts.length > 0) {
442
- const infos = await Promise.all(
443
- uniqueInterfaceAccounts.map((pubkey) => this.program.provider.connection.getAccountInfo(pubkey)),
444
- )
436
+ const priceEntriesByInterfaceAccount = new Map<
437
+ string,
438
+ { entries: NonNullable<ExponentPrices["prices"][number]>[]; key: web3.PublicKey }
439
+ >()
440
+ for (const entry of activePriceEntries) {
441
+ const key = entry.priceInterfaceAccounts
442
+ if (key.equals(ZERO_PUBKEY)) continue
443
+
444
+ const keyString = key.toBase58()
445
+ const group = priceEntriesByInterfaceAccount.get(keyString)
446
+ if (group) {
447
+ group.entries.push(entry)
448
+ } else {
449
+ priceEntriesByInterfaceAccount.set(keyString, { entries: [entry], key })
450
+ }
451
+ }
445
452
 
446
- for (let i = 0; i < uniqueInterfaceAccounts.length; i++) {
447
- const key = uniqueInterfaceAccounts[i]
448
- const info = infos[i]
453
+ const priceInterfaceAccountGroups = [...priceEntriesByInterfaceAccount.values()]
454
+ for (let start = 0; start < priceInterfaceAccountGroups.length; start += MAX_GET_MULTIPLE_ACCOUNTS_INFO_KEYS) {
455
+ const groups = priceInterfaceAccountGroups.slice(start, start + MAX_GET_MULTIPLE_ACCOUNTS_INFO_KEYS)
456
+ const infos = await this.program.provider.connection.getMultipleAccountsInfo(groups.map((group) => group.key))
457
+ if (infos.length !== groups.length) {
458
+ throw new Error(`PriceInterfaceAccounts batch size mismatch: requested ${groups.length}, got ${infos.length}`)
459
+ }
460
+
461
+ for (let index = 0; index < groups.length; index++) {
462
+ const { entries, key } = groups[index]
463
+ const info = infos[index]
449
464
  if (!info) {
450
465
  throw new Error(`PriceInterfaceAccounts account not found: ${key.toBase58()}`)
451
466
  }
@@ -456,11 +471,9 @@ export class ExponentVaultsFetcher {
456
471
  }
457
472
 
458
473
  const decoded = decodePriceInterfaceAccounts(info.data)
459
- for (const entry of activePriceEntries) {
460
- if (entry.priceInterfaceAccounts.equals(key)) {
461
- entry.interfaceAccounts = decoded.interfaceAccounts
462
- entry.scopeConfig = decoded.scopeConfig
463
- }
474
+ for (const entry of entries) {
475
+ entry.interfaceAccounts = decoded.interfaceAccounts
476
+ entry.scopeConfig = decoded.scopeConfig
464
477
  }
465
478
  }
466
479
  }