@ar.io/sdk 4.0.0-solana.33 → 4.0.0-solana.35
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.
- package/lib/esm/solana/ant-readable.js +306 -20
- package/lib/esm/solana/io-readable.js +57 -16
- package/lib/esm/solana/io-writeable.js +132 -19
- package/lib/esm/solana/rpc-circuit-breaker.js +169 -1
- package/lib/esm/version.js +1 -1
- package/lib/types/solana/ant-readable.d.ts +47 -3
- package/lib/types/solana/io-readable.d.ts +8 -0
- package/lib/types/solana/io-writeable.d.ts +13 -0
- package/lib/types/solana/rpc-circuit-breaker.d.ts +29 -0
- package/lib/types/types/ant.d.ts +27 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* - AntControllers: list of controller pubkeys
|
|
23
23
|
* - AntRecord: undername records (transactionId, ttl, priority, etc.)
|
|
24
24
|
*/
|
|
25
|
-
import { address, fetchEncodedAccount, } from '@solana/kit';
|
|
25
|
+
import { address, fetchEncodedAccount, fetchEncodedAccounts, } from '@solana/kit';
|
|
26
26
|
import bs58 from 'bs58';
|
|
27
27
|
import { createHash as __createHash } from 'crypto';
|
|
28
28
|
import { ANT_RECORD_DISCRIMINATOR, ANT_RECORD_METADATA_DISCRIMINATOR, decodeAntConfig, decodeAntControllers, getAntRecordDecoder, getAntRecordMetadataDecoder, } from '@ar.io/solana-contracts/ant';
|
|
@@ -148,6 +148,38 @@ export class SolanaANTReadable {
|
|
|
148
148
|
controllers: decoded.controllers.map((c) => c),
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Fetch AntConfig + AntControllers in a single `getMultipleAccounts` round
|
|
153
|
+
* trip (instead of two single-account reads). Used by `getState` to shave one
|
|
154
|
+
* RPC per ANT — meaningful when a UI loads many ANTs.
|
|
155
|
+
*/
|
|
156
|
+
async _fetchConfigAndControllers() {
|
|
157
|
+
const [[configPda], [controllersPda]] = await Promise.all([
|
|
158
|
+
getAntConfigPDA(this.mint, this.antProgram),
|
|
159
|
+
getAntControllersPDA(this.mint, this.antProgram),
|
|
160
|
+
]);
|
|
161
|
+
const [configAccount, controllersAccount] = await withRetry(() => fetchEncodedAccounts(this.rpc, [configPda, controllersPda], {
|
|
162
|
+
commitment: this.commitment,
|
|
163
|
+
}));
|
|
164
|
+
if (!configAccount.exists) {
|
|
165
|
+
throw new Error(`ANT config not found for ${this.processId}`);
|
|
166
|
+
}
|
|
167
|
+
const decodedConfig = decodeAntConfig(configAccount).data;
|
|
168
|
+
const config = {
|
|
169
|
+
mint: decodedConfig.mint,
|
|
170
|
+
name: decodedConfig.name,
|
|
171
|
+
ticker: decodedConfig.ticker,
|
|
172
|
+
logo: decodedConfig.logo,
|
|
173
|
+
description: decodedConfig.description,
|
|
174
|
+
keywords: decodedConfig.keywords,
|
|
175
|
+
owner: decodedConfig.lastKnownOwner,
|
|
176
|
+
version: decodedConfig.version.major,
|
|
177
|
+
};
|
|
178
|
+
const controllers = controllersAccount.exists
|
|
179
|
+
? decodeAntControllers(controllersAccount).data.controllers.map((c) => c)
|
|
180
|
+
: [];
|
|
181
|
+
return { config, controllers };
|
|
182
|
+
}
|
|
151
183
|
async getOwner(_opts) {
|
|
152
184
|
const config = await this.fetchConfig();
|
|
153
185
|
return config.owner;
|
|
@@ -186,10 +218,9 @@ export class SolanaANTReadable {
|
|
|
186
218
|
getAntRecordPDA(this.mint, undername, this.antProgram),
|
|
187
219
|
getAntRecordMetadataPDA(this.mint, undername, this.antProgram),
|
|
188
220
|
]);
|
|
189
|
-
const [recordAccount, metaAccount] = await
|
|
190
|
-
this.
|
|
191
|
-
|
|
192
|
-
]);
|
|
221
|
+
const [recordAccount, metaAccount] = await withRetry(() => fetchEncodedAccounts(this.rpc, [recordPda, metaPda], {
|
|
222
|
+
commitment: this.commitment,
|
|
223
|
+
}));
|
|
193
224
|
if (!recordAccount.exists)
|
|
194
225
|
return undefined;
|
|
195
226
|
const recordDecoder = getAntRecordDecoder();
|
|
@@ -222,8 +253,12 @@ export class SolanaANTReadable {
|
|
|
222
253
|
: undefined,
|
|
223
254
|
};
|
|
224
255
|
}
|
|
225
|
-
async getRecords(
|
|
226
|
-
// Fetch all AntRecord
|
|
256
|
+
async getRecords(opts) {
|
|
257
|
+
// Fetch all AntRecord accounts for this mint. AntRecordMetadata
|
|
258
|
+
// (displayName/logo/description/keywords) is a SECOND program scan and is
|
|
259
|
+
// only needed in detail/edit views, so skip it unless `includeMetadata` is
|
|
260
|
+
// set — halving the per-ANT request cost on list reads. See AntReadOptions.
|
|
261
|
+
const includeMetadata = opts?.includeMetadata === true;
|
|
227
262
|
const gpaFilter = (discriminator) => [
|
|
228
263
|
{
|
|
229
264
|
memcmp: {
|
|
@@ -248,13 +283,15 @@ export class SolanaANTReadable {
|
|
|
248
283
|
filters: gpaFilter(bs58.encode(ANT_RECORD_DISCRIMINATOR)),
|
|
249
284
|
})
|
|
250
285
|
.send()),
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
286
|
+
includeMetadata
|
|
287
|
+
? withRetry(() => this.rpc
|
|
288
|
+
.getProgramAccounts(this.antProgram, {
|
|
289
|
+
commitment: this.commitment,
|
|
290
|
+
encoding: 'base64',
|
|
291
|
+
filters: gpaFilter(bs58.encode(ANT_RECORD_METADATA_DISCRIMINATOR)),
|
|
292
|
+
})
|
|
293
|
+
.send())
|
|
294
|
+
: Promise.resolve([]),
|
|
258
295
|
]));
|
|
259
296
|
const recordDecoder = getAntRecordDecoder();
|
|
260
297
|
const metaDecoder = getAntRecordMetadataDecoder();
|
|
@@ -312,6 +349,256 @@ export class SolanaANTReadable {
|
|
|
312
349
|
}
|
|
313
350
|
return result;
|
|
314
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* Bulk-load lightweight {@link ANTSummary} state for many ANTs in a handful
|
|
354
|
+
* of `getMultipleAccounts` calls instead of `N × getState`. For each mint it
|
|
355
|
+
* batches AntConfig + AntControllers + the apex (`@`) AntRecord — everything a
|
|
356
|
+
* portfolio/names table needs. Full undername records are NOT loaded here;
|
|
357
|
+
* fetch them lazily per-ANT via {@link getRecords}/{@link getState} when a
|
|
358
|
+
* name is opened.
|
|
359
|
+
*
|
|
360
|
+
* Requests: ~`ceil(3N / 100)` calls for N mints (10 → 1, 250 → 8), versus
|
|
361
|
+
* ~`4N` with per-ANT `getState`. Assumes every mint is deployed under this
|
|
362
|
+
* instance's `antProgram` (true for the standard AR.IO ANT program).
|
|
363
|
+
*
|
|
364
|
+
* Mints whose AntConfig doesn't exist are omitted from the result.
|
|
365
|
+
*/
|
|
366
|
+
async getANTSummaries(mints) {
|
|
367
|
+
const unique = Array.from(new Set(mints));
|
|
368
|
+
if (unique.length === 0)
|
|
369
|
+
return {};
|
|
370
|
+
// Derive config + controllers + apex('@') record PDAs for every mint.
|
|
371
|
+
const triples = await Promise.all(unique.map(async (m) => {
|
|
372
|
+
const mintAddr = address(m);
|
|
373
|
+
const [[configPda], [controllersPda], [apexPda]] = await Promise.all([
|
|
374
|
+
getAntConfigPDA(mintAddr, this.antProgram),
|
|
375
|
+
getAntControllersPDA(mintAddr, this.antProgram),
|
|
376
|
+
getAntRecordPDA(mintAddr, '@', this.antProgram),
|
|
377
|
+
]);
|
|
378
|
+
return { mint: m, configPda, controllersPda, apexPda };
|
|
379
|
+
}));
|
|
380
|
+
// Batch-fetch all PDAs (3 per mint) — getMultipleAccounts caps at 100.
|
|
381
|
+
const allPdas = triples.flatMap((t) => [
|
|
382
|
+
t.configPda,
|
|
383
|
+
t.controllersPda,
|
|
384
|
+
t.apexPda,
|
|
385
|
+
]);
|
|
386
|
+
const accounts = [];
|
|
387
|
+
for (let i = 0; i < allPdas.length; i += 100) {
|
|
388
|
+
const chunk = allPdas.slice(i, i + 100);
|
|
389
|
+
const res = await withRetry(() => fetchEncodedAccounts(this.rpc, chunk, { commitment: this.commitment }));
|
|
390
|
+
accounts.push(...res);
|
|
391
|
+
}
|
|
392
|
+
const recordDecoder = getAntRecordDecoder();
|
|
393
|
+
const result = {};
|
|
394
|
+
for (let i = 0; i < triples.length; i++) {
|
|
395
|
+
const { mint } = triples[i];
|
|
396
|
+
const configAccount = accounts[i * 3];
|
|
397
|
+
const controllersAccount = accounts[i * 3 + 1];
|
|
398
|
+
const apexAccount = accounts[i * 3 + 2];
|
|
399
|
+
if (!configAccount?.exists)
|
|
400
|
+
continue;
|
|
401
|
+
const config = decodeAntConfig(configAccount).data;
|
|
402
|
+
const controllers = controllersAccount?.exists
|
|
403
|
+
? decodeAntControllers(controllersAccount).data.controllers.map((c) => c)
|
|
404
|
+
: [];
|
|
405
|
+
let apexRecord;
|
|
406
|
+
if (apexAccount?.exists) {
|
|
407
|
+
const rec = recordDecoder.decode(new Uint8Array(apexAccount.data));
|
|
408
|
+
apexRecord = {
|
|
409
|
+
transactionId: rec.target,
|
|
410
|
+
targetProtocol: rec.targetProtocol,
|
|
411
|
+
ttlSeconds: rec.ttlSeconds,
|
|
412
|
+
priority: rec.priority?.__option === 'Some' ? rec.priority.value : undefined,
|
|
413
|
+
owner: rec.owner?.__option === 'Some'
|
|
414
|
+
? rec.owner.value
|
|
415
|
+
: undefined,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
result[mint] = {
|
|
419
|
+
processId: mint,
|
|
420
|
+
name: config.name,
|
|
421
|
+
ticker: config.ticker,
|
|
422
|
+
logo: config.logo,
|
|
423
|
+
description: config.description,
|
|
424
|
+
keywords: config.keywords,
|
|
425
|
+
owner: config.lastKnownOwner,
|
|
426
|
+
controllers,
|
|
427
|
+
apexRecord,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Bulk-load FULL {@link ANTState} (including all undername records) for many
|
|
434
|
+
* ANTs in a handful of calls instead of `N × getState`:
|
|
435
|
+
* - AntConfig + AntControllers for every mint via `getMultipleAccounts`
|
|
436
|
+
* (chunked at 100), and
|
|
437
|
+
* - ALL undername records via a SINGLE program-wide `getProgramAccounts`
|
|
438
|
+
* scan grouped by mint (offset 8), instead of one mint-filtered scan per
|
|
439
|
+
* ANT.
|
|
440
|
+
*
|
|
441
|
+
* Requests: ~`ceil(2N / 100) + 1` (+1 when `includeMetadata`) regardless of
|
|
442
|
+
* N — e.g. 10 ANTs → 2 calls, 250 → ~6 — versus ~`2N` with per-ANT
|
|
443
|
+
* `getState`. The records scan reads every ANT's records program-wide (cheap
|
|
444
|
+
* per account, one round trip); prefer per-ANT {@link getState} when you only
|
|
445
|
+
* need one ANT. Mints with no AntConfig are omitted.
|
|
446
|
+
*/
|
|
447
|
+
async getANTStates(mints, opts) {
|
|
448
|
+
const unique = Array.from(new Set(mints));
|
|
449
|
+
if (unique.length === 0)
|
|
450
|
+
return {};
|
|
451
|
+
// Config + controllers PDAs for every mint, batched (100 accounts/call).
|
|
452
|
+
const pairs = await Promise.all(unique.map(async (m) => {
|
|
453
|
+
const mintAddr = address(m);
|
|
454
|
+
const [[configPda], [controllersPda]] = await Promise.all([
|
|
455
|
+
getAntConfigPDA(mintAddr, this.antProgram),
|
|
456
|
+
getAntControllersPDA(mintAddr, this.antProgram),
|
|
457
|
+
]);
|
|
458
|
+
return { mint: m, configPda, controllersPda };
|
|
459
|
+
}));
|
|
460
|
+
const allPdas = pairs.flatMap((p) => [p.configPda, p.controllersPda]);
|
|
461
|
+
const accounts = [];
|
|
462
|
+
for (let i = 0; i < allPdas.length; i += 100) {
|
|
463
|
+
const res = await withRetry(() => fetchEncodedAccounts(this.rpc, allPdas.slice(i, i + 100), {
|
|
464
|
+
commitment: this.commitment,
|
|
465
|
+
}));
|
|
466
|
+
accounts.push(...res);
|
|
467
|
+
}
|
|
468
|
+
const recordsByMint = await this._recordsByMint(opts?.includeMetadata === true);
|
|
469
|
+
const result = {};
|
|
470
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
471
|
+
const { mint } = pairs[i];
|
|
472
|
+
const configAccount = accounts[i * 2];
|
|
473
|
+
const controllersAccount = accounts[i * 2 + 1];
|
|
474
|
+
if (!configAccount?.exists)
|
|
475
|
+
continue;
|
|
476
|
+
const config = decodeAntConfig(configAccount).data;
|
|
477
|
+
const controllers = controllersAccount?.exists
|
|
478
|
+
? decodeAntControllers(controllersAccount).data.controllers.map((c) => c)
|
|
479
|
+
: [];
|
|
480
|
+
const sorted = recordsByMint.get(mint) ?? {};
|
|
481
|
+
const plainRecords = {};
|
|
482
|
+
for (const [key, val] of Object.entries(sorted)) {
|
|
483
|
+
const { index: _, ...rec } = val;
|
|
484
|
+
plainRecords[key] = rec;
|
|
485
|
+
}
|
|
486
|
+
const owner = config.lastKnownOwner;
|
|
487
|
+
result[mint] = {
|
|
488
|
+
Name: config.name,
|
|
489
|
+
Ticker: config.ticker,
|
|
490
|
+
Description: config.description,
|
|
491
|
+
Keywords: config.keywords,
|
|
492
|
+
Denomination: 0,
|
|
493
|
+
Owner: owner,
|
|
494
|
+
Controllers: controllers,
|
|
495
|
+
Records: plainRecords,
|
|
496
|
+
Balances: { [owner]: 1 },
|
|
497
|
+
Logo: config.logo,
|
|
498
|
+
TotalSupply: 1,
|
|
499
|
+
Initialized: true,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Group every AntRecord (+ optional metadata) in the program by mint via a
|
|
506
|
+
* single `getProgramAccounts` scan (the mint sits at offset 8). Used by
|
|
507
|
+
* {@link getANTStates} to load all ANTs' undername records in one round trip
|
|
508
|
+
* instead of one mint-filtered scan per ANT.
|
|
509
|
+
*/
|
|
510
|
+
async _recordsByMint(includeMetadata) {
|
|
511
|
+
const discFilter = (discriminator) => [
|
|
512
|
+
{
|
|
513
|
+
memcmp: {
|
|
514
|
+
offset: 0n,
|
|
515
|
+
bytes: discriminator,
|
|
516
|
+
encoding: 'base58',
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
];
|
|
520
|
+
const [recordAccounts, metaAccounts] = (await Promise.all([
|
|
521
|
+
withRetry(() => this.rpc
|
|
522
|
+
.getProgramAccounts(this.antProgram, {
|
|
523
|
+
commitment: this.commitment,
|
|
524
|
+
encoding: 'base64',
|
|
525
|
+
filters: discFilter(bs58.encode(ANT_RECORD_DISCRIMINATOR)),
|
|
526
|
+
})
|
|
527
|
+
.send()),
|
|
528
|
+
includeMetadata
|
|
529
|
+
? withRetry(() => this.rpc
|
|
530
|
+
.getProgramAccounts(this.antProgram, {
|
|
531
|
+
commitment: this.commitment,
|
|
532
|
+
encoding: 'base64',
|
|
533
|
+
filters: discFilter(bs58.encode(ANT_RECORD_METADATA_DISCRIMINATOR)),
|
|
534
|
+
})
|
|
535
|
+
.send())
|
|
536
|
+
: Promise.resolve([]),
|
|
537
|
+
]));
|
|
538
|
+
const recordDecoder = getAntRecordDecoder();
|
|
539
|
+
const metaDecoder = getAntRecordMetadataDecoder();
|
|
540
|
+
// Metadata keyed by `${mint}:${undernameHash}` (mint at 8, hash at 40).
|
|
541
|
+
const metaByKey = new Map();
|
|
542
|
+
for (const { account } of metaAccounts) {
|
|
543
|
+
try {
|
|
544
|
+
const buf = Buffer.from(account.data[0], 'base64');
|
|
545
|
+
const mint = bs58.encode(buf.subarray(8, 40));
|
|
546
|
+
const hash = buf.subarray(40, 72).toString('hex');
|
|
547
|
+
metaByKey.set(`${mint}:${hash}`, metaDecoder.decode(new Uint8Array(buf)));
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
// Skip malformed
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const byMint = new Map();
|
|
554
|
+
const indexByMint = new Map();
|
|
555
|
+
for (const { account } of recordAccounts) {
|
|
556
|
+
try {
|
|
557
|
+
const buf = Buffer.from(account.data[0], 'base64');
|
|
558
|
+
const mint = bs58.encode(buf.subarray(8, 40));
|
|
559
|
+
const record = recordDecoder.decode(new Uint8Array(buf));
|
|
560
|
+
const hash = __createHash('sha256')
|
|
561
|
+
.update(record.undername.toLowerCase())
|
|
562
|
+
.digest('hex');
|
|
563
|
+
const meta = metaByKey.get(`${mint}:${hash}`);
|
|
564
|
+
const idx = indexByMint.get(mint) ?? 0;
|
|
565
|
+
let bucket = byMint.get(mint);
|
|
566
|
+
if (!bucket) {
|
|
567
|
+
bucket = {};
|
|
568
|
+
byMint.set(mint, bucket);
|
|
569
|
+
}
|
|
570
|
+
bucket[record.undername] = {
|
|
571
|
+
transactionId: record.target,
|
|
572
|
+
targetProtocol: record.targetProtocol,
|
|
573
|
+
ttlSeconds: record.ttlSeconds,
|
|
574
|
+
priority: record.priority?.__option === 'Some'
|
|
575
|
+
? record.priority.value
|
|
576
|
+
: undefined,
|
|
577
|
+
owner: record.owner?.__option === 'Some'
|
|
578
|
+
? record.owner.value
|
|
579
|
+
: undefined,
|
|
580
|
+
displayName: meta?.displayName?.__option === 'Some'
|
|
581
|
+
? meta.displayName.value
|
|
582
|
+
: undefined,
|
|
583
|
+
logo: meta?.recordLogo?.__option === 'Some'
|
|
584
|
+
? meta.recordLogo.value
|
|
585
|
+
: undefined,
|
|
586
|
+
description: meta?.recordDescription?.__option === 'Some'
|
|
587
|
+
? meta.recordDescription.value
|
|
588
|
+
: undefined,
|
|
589
|
+
keywords: meta?.recordKeywords?.__option === 'Some'
|
|
590
|
+
? meta.recordKeywords.value
|
|
591
|
+
: undefined,
|
|
592
|
+
index: idx,
|
|
593
|
+
};
|
|
594
|
+
indexByMint.set(mint, idx + 1);
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// Skip malformed
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return byMint;
|
|
601
|
+
}
|
|
315
602
|
// =========================================
|
|
316
603
|
// Balance reads (NFT model — owner has balance 1)
|
|
317
604
|
// =========================================
|
|
@@ -326,11 +613,10 @@ export class SolanaANTReadable {
|
|
|
326
613
|
// =========================================
|
|
327
614
|
// State / Info composites
|
|
328
615
|
// =========================================
|
|
329
|
-
async getState(
|
|
330
|
-
const [config,
|
|
331
|
-
this.
|
|
332
|
-
this.
|
|
333
|
-
this.getRecords(),
|
|
616
|
+
async getState(opts) {
|
|
617
|
+
const [{ config, controllers }, records] = await Promise.all([
|
|
618
|
+
this._fetchConfigAndControllers(),
|
|
619
|
+
this.getRecords(opts),
|
|
334
620
|
]);
|
|
335
621
|
// Convert SortedANTRecords to ANTRecords (strip index)
|
|
336
622
|
const plainRecords = {};
|
|
@@ -345,7 +631,7 @@ export class SolanaANTReadable {
|
|
|
345
631
|
Keywords: config.keywords,
|
|
346
632
|
Denomination: 0,
|
|
347
633
|
Owner: config.owner,
|
|
348
|
-
Controllers:
|
|
634
|
+
Controllers: controllers,
|
|
349
635
|
Records: plainRecords,
|
|
350
636
|
Balances: { [config.owner]: 1 },
|
|
351
637
|
Logo: config.logo,
|
|
@@ -105,6 +105,12 @@ function arnsRecordToWithName(record) {
|
|
|
105
105
|
: {}),
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* TTL for {@link SolanaARIOReadable.getCachedAccount}. DemandFactor / config
|
|
110
|
+
* accounts change on the order of epochs, so a few seconds of staleness is a
|
|
111
|
+
* safe trade for collapsing burst reads (e.g. per-row cost lookups).
|
|
112
|
+
*/
|
|
113
|
+
const CONFIG_CACHE_TTL_MS = 30_000;
|
|
108
114
|
function paginate(items, params) {
|
|
109
115
|
const limit = params?.limit ?? 100;
|
|
110
116
|
const startIdx = params?.cursor ? parseInt(params.cursor, 10) : 0;
|
|
@@ -146,6 +152,11 @@ export class SolanaARIOReadable {
|
|
|
146
152
|
// Memoized ARIO mint address (read once from ArioConfig.mint and reused
|
|
147
153
|
// for every SPL-ATA derivation in getBalance/getBalances).
|
|
148
154
|
_arioMint;
|
|
155
|
+
// Short-TTL cache for slow-changing config-ish accounts (DemandFactor,
|
|
156
|
+
// ArnsConfig, etc.). Collapses the many identical reads a UI fires in a
|
|
157
|
+
// burst — e.g. the returned-names page running `getCostDetails` per row,
|
|
158
|
+
// each re-reading the same DemandFactor PDA — into a single network call.
|
|
159
|
+
_accountCache = new Map();
|
|
149
160
|
constructor(config) {
|
|
150
161
|
this.rpc = config.rpc;
|
|
151
162
|
this.commitment = config.commitment ?? 'confirmed';
|
|
@@ -161,6 +172,24 @@ export class SolanaARIOReadable {
|
|
|
161
172
|
commitment: this.commitment,
|
|
162
173
|
}));
|
|
163
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Like {@link getAccount} but caches the result per-PDA for `ttlMs`. Use only
|
|
177
|
+
* for accounts that change slowly (DemandFactor, ArnsConfig) where a few
|
|
178
|
+
* seconds of staleness is acceptable in exchange for collapsing repeated
|
|
179
|
+
* reads. A successful fetch is cached; misses (`exists: false`) are not.
|
|
180
|
+
*/
|
|
181
|
+
async getCachedAccount(pda, ttlMs = CONFIG_CACHE_TTL_MS) {
|
|
182
|
+
const key = String(pda);
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
const hit = this._accountCache.get(key);
|
|
185
|
+
if (hit && hit.expiresAt > now)
|
|
186
|
+
return hit.account;
|
|
187
|
+
const account = await this.getAccount(pda);
|
|
188
|
+
if (account.exists) {
|
|
189
|
+
this._accountCache.set(key, { account, expiresAt: now + ttlMs });
|
|
190
|
+
}
|
|
191
|
+
return account;
|
|
192
|
+
}
|
|
164
193
|
/**
|
|
165
194
|
* Helper for `getProgramAccounts` with a discriminator memcmp filter.
|
|
166
195
|
*
|
|
@@ -959,7 +988,11 @@ export class SolanaARIOReadable {
|
|
|
959
988
|
}
|
|
960
989
|
async getArNSReturnedName({ name, }) {
|
|
961
990
|
const [pda] = await getReturnedNamePDA(name, this.arnsProgram);
|
|
962
|
-
|
|
991
|
+
// A ReturnedName account is immutable once created (its premium is derived
|
|
992
|
+
// client-side from the static start/end timestamps), so cache the read.
|
|
993
|
+
// The returned-names price table reads each name's PDA twice (lease +
|
|
994
|
+
// permabuy) and `getTokenCost` reads it again — all share one fetch.
|
|
995
|
+
const account = await this.getCachedAccount(pda);
|
|
963
996
|
if (!account.exists) {
|
|
964
997
|
throw new Error(`Returned name not found: ${name}`);
|
|
965
998
|
}
|
|
@@ -1239,7 +1272,7 @@ export class SolanaARIOReadable {
|
|
|
1239
1272
|
*/
|
|
1240
1273
|
async getTokenCost(params) {
|
|
1241
1274
|
const [dfPda] = await getDemandFactorPDA(this.arnsProgram);
|
|
1242
|
-
const dfAccount = await this.
|
|
1275
|
+
const dfAccount = await this.getCachedAccount(dfPda);
|
|
1243
1276
|
if (!dfAccount.exists)
|
|
1244
1277
|
throw new Error('DemandFactor account not found');
|
|
1245
1278
|
const df = deserializeDemandFactor(Buffer.from(dfAccount.data));
|
|
@@ -1327,14 +1360,22 @@ export class SolanaARIOReadable {
|
|
|
1327
1360
|
const discounts = [];
|
|
1328
1361
|
if (params.fromAddress) {
|
|
1329
1362
|
try {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1363
|
+
// Operator-discount check. Read the gateway PDA through the short-TTL
|
|
1364
|
+
// cache (NOT public `getGateway`, which stays fresh for gateway pages):
|
|
1365
|
+
// a price table calls `getCostDetails` many times for the SAME
|
|
1366
|
+
// `fromAddress`, so this collapses N redundant gateway reads to one.
|
|
1367
|
+
const [gwPda] = await getGatewayPDA(address(params.fromAddress), this.garProgram);
|
|
1368
|
+
const gwAccount = await this.getCachedAccount(gwPda);
|
|
1369
|
+
if (gwAccount.exists) {
|
|
1370
|
+
const gw = deserializeGateway(Buffer.from(gwAccount.data));
|
|
1371
|
+
if (gw.status === 'joined') {
|
|
1372
|
+
const discountAmount = Math.floor((tokenCost * 200_000) / RATE_SCALE);
|
|
1373
|
+
discounts.push({
|
|
1374
|
+
name: 'Gateway Operator',
|
|
1375
|
+
discountTotal: discountAmount,
|
|
1376
|
+
multiplier: 0.8,
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1338
1379
|
}
|
|
1339
1380
|
}
|
|
1340
1381
|
catch {
|
|
@@ -1441,7 +1482,7 @@ export class SolanaARIOReadable {
|
|
|
1441
1482
|
}
|
|
1442
1483
|
async getRegistrationFees() {
|
|
1443
1484
|
const [dfPda] = await getDemandFactorPDA(this.arnsProgram);
|
|
1444
|
-
const account = await this.
|
|
1485
|
+
const account = await this.getCachedAccount(dfPda);
|
|
1445
1486
|
if (!account.exists) {
|
|
1446
1487
|
throw new Error('DemandFactor account not found');
|
|
1447
1488
|
}
|
|
@@ -1464,7 +1505,7 @@ export class SolanaARIOReadable {
|
|
|
1464
1505
|
}
|
|
1465
1506
|
async getDemandFactor() {
|
|
1466
1507
|
const [pda] = await getDemandFactorPDA(this.arnsProgram);
|
|
1467
|
-
const account = await this.
|
|
1508
|
+
const account = await this.getCachedAccount(pda);
|
|
1468
1509
|
if (!account.exists) {
|
|
1469
1510
|
throw new Error('DemandFactor account not found');
|
|
1470
1511
|
}
|
|
@@ -1473,7 +1514,7 @@ export class SolanaARIOReadable {
|
|
|
1473
1514
|
}
|
|
1474
1515
|
async getDemandFactorSettings() {
|
|
1475
1516
|
const [pda] = await getDemandFactorPDA(this.arnsProgram);
|
|
1476
|
-
const account = await this.
|
|
1517
|
+
const account = await this.getCachedAccount(pda);
|
|
1477
1518
|
if (!account.exists) {
|
|
1478
1519
|
throw new Error('DemandFactor account not found');
|
|
1479
1520
|
}
|
|
@@ -1730,7 +1771,7 @@ export class SolanaARIOReadable {
|
|
|
1730
1771
|
*/
|
|
1731
1772
|
async getExpiredArnsRecords(now) {
|
|
1732
1773
|
const [arnsConfigPda] = await getArnsSettingsPDA(this.arnsProgram);
|
|
1733
|
-
const cfgAccount = await this.
|
|
1774
|
+
const cfgAccount = await this.getCachedAccount(arnsConfigPda);
|
|
1734
1775
|
if (!cfgAccount.exists)
|
|
1735
1776
|
return [];
|
|
1736
1777
|
const cfg = getArnsConfigDecoder().decode(cfgAccount.data);
|
|
@@ -1765,7 +1806,7 @@ export class SolanaARIOReadable {
|
|
|
1765
1806
|
*/
|
|
1766
1807
|
async getExpiredReturnedNames(now) {
|
|
1767
1808
|
const [arnsConfigPda] = await getArnsSettingsPDA(this.arnsProgram);
|
|
1768
|
-
const cfgAccount = await this.
|
|
1809
|
+
const cfgAccount = await this.getCachedAccount(arnsConfigPda);
|
|
1769
1810
|
if (!cfgAccount.exists)
|
|
1770
1811
|
return [];
|
|
1771
1812
|
const cfg = getArnsConfigDecoder().decode(cfgAccount.data);
|
|
@@ -2011,7 +2052,7 @@ export class SolanaARIOReadable {
|
|
|
2011
2052
|
*/
|
|
2012
2053
|
async getArnsConfigRaw() {
|
|
2013
2054
|
const [pda] = await getArnsSettingsPDA(this.arnsProgram);
|
|
2014
|
-
const account = await this.
|
|
2055
|
+
const account = await this.getCachedAccount(pda);
|
|
2015
2056
|
if (!account.exists)
|
|
2016
2057
|
return null;
|
|
2017
2058
|
const cfg = getArnsConfigDecoder().decode(account.data);
|
|
@@ -881,7 +881,16 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
881
881
|
params: buyNameParams,
|
|
882
882
|
}, { programAddress: this.arnsProgram });
|
|
883
883
|
}
|
|
884
|
-
else if (params.fundFrom === 'plan' ||
|
|
884
|
+
else if (params.fundFrom === 'plan' ||
|
|
885
|
+
params.fundFrom === 'any' ||
|
|
886
|
+
params.fundFrom === 'stakes' ||
|
|
887
|
+
params.fundFrom === 'withdrawal') {
|
|
888
|
+
// 'stakes'/'withdrawal' WITHOUT an explicit gatewayAddress/withdrawalId
|
|
889
|
+
// land here (the single-source branches above handle the explicit case).
|
|
890
|
+
// Route through the funding planner, which constrains sources to the
|
|
891
|
+
// chosen mode — it never silently spends liquid balance and auto-splits
|
|
892
|
+
// across the caller's delegations/vaults. (Previously these fell through
|
|
893
|
+
// to the balance path below, silently draining liquid ARIO.)
|
|
885
894
|
ix = await this._buildBuyNameFromFundingPlanIx({
|
|
886
895
|
params,
|
|
887
896
|
antPubkey,
|
|
@@ -892,8 +901,10 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
892
901
|
arnsConfig,
|
|
893
902
|
});
|
|
894
903
|
}
|
|
895
|
-
else
|
|
896
|
-
|
|
904
|
+
else if (!params.fundFrom ||
|
|
905
|
+
params.fundFrom === 'balance' ||
|
|
906
|
+
params.fundFrom === 'turbo') {
|
|
907
|
+
// Direct balance-funded buy.
|
|
897
908
|
ix = await getBuyNameInstructionAsync(await this.withArnsDefaults({
|
|
898
909
|
arnsRecord,
|
|
899
910
|
buyerTokenAccount: buyerATA,
|
|
@@ -904,6 +915,9 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
904
915
|
params: buyNameParams,
|
|
905
916
|
}), { programAddress: this.arnsProgram });
|
|
906
917
|
}
|
|
918
|
+
else {
|
|
919
|
+
throw new Error(`unsupported fundFrom mode '${params.fundFrom}' for buyRecord`);
|
|
920
|
+
}
|
|
907
921
|
// Sprint 4 / ADR-016: bundle `ant.sync_attributes` IFF the buyer
|
|
908
922
|
// owns the ANT (preserves BD-096 — non-holder buys defer the trait
|
|
909
923
|
// sync to a later `syncAttributes()` call by the actual owner).
|
|
@@ -1428,7 +1442,14 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
1428
1442
|
return getExtendLeaseFromWithdrawalInstructionAsync({ ...wBase, years: args.years }, { programAddress: this.arnsProgram });
|
|
1429
1443
|
return getIncreaseUndernameLimitFromWithdrawalInstructionAsync({ ...wBase, quantity: args.quantity }, { programAddress: this.arnsProgram });
|
|
1430
1444
|
}
|
|
1431
|
-
if (args.params.fundFrom === 'plan' ||
|
|
1445
|
+
if (args.params.fundFrom === 'plan' ||
|
|
1446
|
+
args.params.fundFrom === 'any' ||
|
|
1447
|
+
args.params.fundFrom === 'stakes' ||
|
|
1448
|
+
args.params.fundFrom === 'withdrawal') {
|
|
1449
|
+
// 'stakes'/'withdrawal' without an explicit gatewayAddress/withdrawalId
|
|
1450
|
+
// route here (the single-source branches above handle the explicit
|
|
1451
|
+
// case). The planner constrains sources to the chosen mode and never
|
|
1452
|
+
// spends liquid balance.
|
|
1432
1453
|
// Cost estimation for manage variants: each operation has its own
|
|
1433
1454
|
// pricing path. Keep it pragmatic — let the planner build the plan
|
|
1434
1455
|
// around the user's desired total (caller can pass explicit sources
|
|
@@ -1987,10 +2008,54 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
1987
2008
|
years: params.years ?? 1,
|
|
1988
2009
|
ant: antPubkey,
|
|
1989
2010
|
};
|
|
2011
|
+
// Returned-name price is a per-slot-decaying Dutch auction, so the
|
|
2012
|
+
// multi-source funding plan (which pre-commits exact source amounts) can't
|
|
2013
|
+
// match the execution-time cost → FundingPlanAmountMismatch (#6066). Prefer
|
|
2014
|
+
// a single-source stake path: it carries no amount, so the program computes
|
|
2015
|
+
// and draws the live cost itself. When the caller asked to fund from
|
|
2016
|
+
// stakes/withdrawal/any without naming a specific gateway/vault,
|
|
2017
|
+
// auto-resolve a single source with enough stake to cover the
|
|
2018
|
+
// (premium-inclusive) cost.
|
|
2019
|
+
let resolvedGateway = params.gatewayAddress;
|
|
2020
|
+
let resolvedFundAsOperator = params.fundAsOperator ?? false;
|
|
2021
|
+
let resolvedWithdrawalId = params.withdrawalId;
|
|
2022
|
+
const wantsStake = params.fundFrom === 'stakes' ||
|
|
2023
|
+
params.fundFrom === 'withdrawal' ||
|
|
2024
|
+
params.fundFrom === 'any';
|
|
2025
|
+
if (wantsStake &&
|
|
2026
|
+
resolvedGateway === undefined &&
|
|
2027
|
+
resolvedWithdrawalId === undefined &&
|
|
2028
|
+
!params.sources?.length) {
|
|
2029
|
+
const picked = await this._autoPickReturnedNameStakeSource(params);
|
|
2030
|
+
if (picked?.kind === 'delegation') {
|
|
2031
|
+
resolvedGateway = picked.gateway;
|
|
2032
|
+
resolvedFundAsOperator = false;
|
|
2033
|
+
}
|
|
2034
|
+
else if (picked?.kind === 'operatorStake') {
|
|
2035
|
+
resolvedGateway = picked.gateway;
|
|
2036
|
+
resolvedFundAsOperator = true;
|
|
2037
|
+
}
|
|
2038
|
+
else if (picked?.kind === 'withdrawal') {
|
|
2039
|
+
resolvedWithdrawalId = picked.withdrawalId;
|
|
2040
|
+
}
|
|
2041
|
+
else if (params.fundFrom !== 'any') {
|
|
2042
|
+
// 'stakes'/'withdrawal' explicitly requested but nothing covers it.
|
|
2043
|
+
throw new Error(`buyReturnedName: no ${params.fundFrom === 'withdrawal'
|
|
2044
|
+
? 'matured withdrawal vault'
|
|
2045
|
+
: 'delegation/operator stake'} large enough to fund '${params.name}' was found for ` +
|
|
2046
|
+
`${this.signer.address}. Fund from balance, or add stake first.`);
|
|
2047
|
+
}
|
|
2048
|
+
// 'any' with nothing found → falls through to the balance path.
|
|
2049
|
+
}
|
|
1990
2050
|
let ix;
|
|
1991
|
-
|
|
2051
|
+
const useBalance = !params.fundFrom ||
|
|
1992
2052
|
params.fundFrom === 'balance' ||
|
|
1993
|
-
params.fundFrom === 'turbo'
|
|
2053
|
+
params.fundFrom === 'turbo' ||
|
|
2054
|
+
(params.fundFrom === 'any' &&
|
|
2055
|
+
resolvedGateway === undefined &&
|
|
2056
|
+
resolvedWithdrawalId === undefined &&
|
|
2057
|
+
!params.sources?.length);
|
|
2058
|
+
if (useBalance) {
|
|
1994
2059
|
ix = await getBuyReturnedNameInstructionAsync(await this.withArnsDefaults({
|
|
1995
2060
|
arnsRecord,
|
|
1996
2061
|
returnedName: returnedNamePda,
|
|
@@ -2019,10 +2084,10 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2019
2084
|
garProgram: this.garProgram,
|
|
2020
2085
|
params: buyParams,
|
|
2021
2086
|
};
|
|
2022
|
-
if (
|
|
2023
|
-
const gatewayAddr = address(
|
|
2087
|
+
if (resolvedGateway !== undefined) {
|
|
2088
|
+
const gatewayAddr = address(resolvedGateway);
|
|
2024
2089
|
const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
|
|
2025
|
-
if (
|
|
2090
|
+
if (resolvedFundAsOperator) {
|
|
2026
2091
|
ix = await getBuyReturnedNameFromOperatorStakeInstructionAsync({ ...sharedReturnedBase, gateway: gatewayPda }, { programAddress: this.arnsProgram });
|
|
2027
2092
|
}
|
|
2028
2093
|
else {
|
|
@@ -2034,17 +2099,16 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2034
2099
|
}, { programAddress: this.arnsProgram });
|
|
2035
2100
|
}
|
|
2036
2101
|
}
|
|
2037
|
-
else if (
|
|
2038
|
-
|
|
2039
|
-
const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, params.withdrawalId, this.garProgram);
|
|
2102
|
+
else if (resolvedWithdrawalId !== undefined) {
|
|
2103
|
+
const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, resolvedWithdrawalId, this.garProgram);
|
|
2040
2104
|
ix = await getBuyReturnedNameFromWithdrawalInstructionAsync({ ...sharedReturnedBase, withdrawal: withdrawalPda }, { programAddress: this.arnsProgram });
|
|
2041
2105
|
}
|
|
2042
|
-
else if (params.fundFrom === 'plan'
|
|
2043
|
-
//
|
|
2044
|
-
//
|
|
2045
|
-
//
|
|
2046
|
-
//
|
|
2047
|
-
//
|
|
2106
|
+
else if (params.fundFrom === 'plan' && params.sources?.length) {
|
|
2107
|
+
// Explicit caller-supplied plan only: the caller owns the source
|
|
2108
|
+
// amounts and accepts the decay risk (the price moves per slot, so a
|
|
2109
|
+
// stale plan trips FundingPlanAmountMismatch). We do NOT auto-discover
|
|
2110
|
+
// a multi-source plan for returned names — see the single-source note
|
|
2111
|
+
// above.
|
|
2048
2112
|
const cost = await this._simulateTokenCost({
|
|
2049
2113
|
intent: CostIntent.BuyName,
|
|
2050
2114
|
name: params.name,
|
|
@@ -2068,14 +2132,63 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2068
2132
|
throw new Error(`unsupported fundFrom mode '${params.fundFrom}' for buyReturnedName`);
|
|
2069
2133
|
}
|
|
2070
2134
|
}
|
|
2135
|
+
// The on-chain `buy_returned_name*` handlers take `initiator_token_account`
|
|
2136
|
+
// and `buyer_token_account` as `Account<TokenAccount>` (NOT `init`), so
|
|
2137
|
+
// Anchor requires both ATAs to already exist or fails with
|
|
2138
|
+
// AccountNotInitialized (#3012). The original initiator may never have held
|
|
2139
|
+
// ARIO, and the premium always settles from the buyer's liquid ATA — bundle
|
|
2140
|
+
// idempotent ATA creates so the buy succeeds without a separate setup step
|
|
2141
|
+
// (mirrors the vault-ATA handling above). Idempotent: ~1500 CU each, no-op
|
|
2142
|
+
// when the account already exists.
|
|
2143
|
+
const createInitiatorAtaIx = buildCreateAtaIdempotentIx(this.signer.address, initiatorATA, initiator, arnsConfig.mint);
|
|
2144
|
+
const createBuyerAtaIx = buildCreateAtaIdempotentIx(this.signer.address, buyerATA, this.signer.address, arnsConfig.mint);
|
|
2071
2145
|
// Sprint 4 / ADR-016: bundle ant.sync_attributes after the buy so the
|
|
2072
2146
|
// Attributes plugin reflects the new record holder. assetOverride =
|
|
2073
2147
|
// antPubkey because the ArnsRecord PDA is created by buy_returned_name
|
|
2074
2148
|
// and doesn't exist on-chain at SDK build time.
|
|
2075
2149
|
const syncIx = await this._buildSyncAttributesIxIfOwner(params.name, antPubkey);
|
|
2076
|
-
const sig = await this.sendTransaction(
|
|
2150
|
+
const sig = await this.sendTransaction([
|
|
2151
|
+
createBuyerAtaIx,
|
|
2152
|
+
createInitiatorAtaIx,
|
|
2153
|
+
ix,
|
|
2154
|
+
...(syncIx ? [syncIx] : []),
|
|
2155
|
+
]);
|
|
2077
2156
|
return { id: sig };
|
|
2078
2157
|
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Pick a single stake-derived funding source that can cover a returned-name
|
|
2160
|
+
* purchase, for the single-source `buy_returned_name_from_*` paths.
|
|
2161
|
+
*
|
|
2162
|
+
* Returned-name prices decay per slot, so the multi-source funding plan
|
|
2163
|
+
* (which pre-commits exact amounts) can't match the execution-time cost. The
|
|
2164
|
+
* single-source paths carry no amount — the program draws the live cost — so
|
|
2165
|
+
* we only need to pick ONE source with enough stake. We size the pick against
|
|
2166
|
+
* the premium-inclusive estimate (an upper bound, since the price only falls
|
|
2167
|
+
* from now) and choose the largest matching source. Returns `null` when no
|
|
2168
|
+
* single source covers the estimate.
|
|
2169
|
+
*/
|
|
2170
|
+
async _autoPickReturnedNameStakeSource(params) {
|
|
2171
|
+
const estimate = BigInt(Math.ceil(await this.getTokenCost({
|
|
2172
|
+
intent: 'Buy-Name',
|
|
2173
|
+
name: params.name,
|
|
2174
|
+
type: params.type,
|
|
2175
|
+
years: params.years ?? 1,
|
|
2176
|
+
})));
|
|
2177
|
+
const arnsConfig = await this.getArnsConfig();
|
|
2178
|
+
const { discoverFundingSources } = await import('./funding-plan.js');
|
|
2179
|
+
const sources = await discoverFundingSources(this.rpc, this.signer.address, { arioMint: arnsConfig.mint, garProgram: this.garProgram });
|
|
2180
|
+
// 'withdrawal' mode → matured withdrawal vaults only; otherwise prefer
|
|
2181
|
+
// operator stake when the caller asked, else a delegation.
|
|
2182
|
+
const wantKind = params.fundFrom === 'withdrawal'
|
|
2183
|
+
? 'withdrawal'
|
|
2184
|
+
: params.fundAsOperator === true
|
|
2185
|
+
? 'operatorStake'
|
|
2186
|
+
: 'delegation';
|
|
2187
|
+
const candidates = sources
|
|
2188
|
+
.filter((s) => s.kind === wantKind && s.available >= estimate)
|
|
2189
|
+
.sort((a, b) => b.available > a.available ? 1 : b.available < a.available ? -1 : 0);
|
|
2190
|
+
return candidates[0] ?? null;
|
|
2191
|
+
}
|
|
2079
2192
|
// =========================================
|
|
2080
2193
|
// Name management (ario-arns)
|
|
2081
2194
|
// =========================================
|
|
@@ -47,6 +47,121 @@ const logger = new Logger({ level: 'error' });
|
|
|
47
47
|
const DEFAULT_MAINNET_RPC = 'https://api.mainnet-beta.solana.com';
|
|
48
48
|
const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com';
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
|
+
// Adaptive rate gate (token bucket + cooldown)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/** Default ceiling when `maxRequestsPerSecond` is not provided. */
|
|
53
|
+
const DEFAULT_MAX_RPS = 10;
|
|
54
|
+
/** Multiply the current rate by this on a 429 with no usable header. */
|
|
55
|
+
const AIMD_DECREASE = 0.5;
|
|
56
|
+
/** Never throttle below this many requests/second. */
|
|
57
|
+
const MIN_RATE = 1;
|
|
58
|
+
/** Consecutive successes before nudging the rate up by 1 (additive recovery). */
|
|
59
|
+
const RECOVERY_SUCCESSES = 20;
|
|
60
|
+
/** Fraction of a provider-advertised limit to actually use (safety margin). */
|
|
61
|
+
const RATE_SAFETY_FACTOR = 0.9;
|
|
62
|
+
/** Cooldown applied on a 429 that carries no `Retry-After`. */
|
|
63
|
+
const DEFAULT_COOLDOWN_MS = 1_000;
|
|
64
|
+
/**
|
|
65
|
+
* Token-bucket throttle whose rate can be retuned at runtime and which can be
|
|
66
|
+
* paused on demand. Tokens refill continuously at the current rate, capped at
|
|
67
|
+
* one second's worth (the burst allowance); waiters are released FIFO.
|
|
68
|
+
*/
|
|
69
|
+
function createRateGate(initialRate) {
|
|
70
|
+
let rate = Math.max(MIN_RATE, initialRate);
|
|
71
|
+
let capacity = Math.max(1, rate);
|
|
72
|
+
let tokens = capacity;
|
|
73
|
+
let lastRefill = Date.now();
|
|
74
|
+
let pausedUntil = 0;
|
|
75
|
+
const queue = [];
|
|
76
|
+
let timer = null;
|
|
77
|
+
const schedule = (ms) => {
|
|
78
|
+
if (timer !== null)
|
|
79
|
+
return;
|
|
80
|
+
timer = setTimeout(() => {
|
|
81
|
+
timer = null;
|
|
82
|
+
pump();
|
|
83
|
+
}, Math.max(ms, 1));
|
|
84
|
+
};
|
|
85
|
+
const refill = () => {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const elapsed = (now - lastRefill) / 1000;
|
|
88
|
+
if (elapsed > 0) {
|
|
89
|
+
tokens = Math.min(capacity, tokens + elapsed * rate);
|
|
90
|
+
lastRefill = now;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const pump = () => {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (pausedUntil > now) {
|
|
96
|
+
schedule(pausedUntil - now);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
refill();
|
|
100
|
+
while (tokens >= 1) {
|
|
101
|
+
const release = queue.shift();
|
|
102
|
+
if (!release)
|
|
103
|
+
break;
|
|
104
|
+
tokens -= 1;
|
|
105
|
+
release();
|
|
106
|
+
}
|
|
107
|
+
if (queue.length > 0) {
|
|
108
|
+
// Wake when the next whole token will have accrued.
|
|
109
|
+
schedule(Math.ceil(((1 - tokens) / rate) * 1000));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
acquire: () => new Promise((resolve) => {
|
|
114
|
+
queue.push(resolve);
|
|
115
|
+
pump();
|
|
116
|
+
}),
|
|
117
|
+
setRate: (ratePerSecond) => {
|
|
118
|
+
rate = Math.max(MIN_RATE, ratePerSecond);
|
|
119
|
+
capacity = Math.max(1, rate);
|
|
120
|
+
tokens = Math.min(tokens, capacity);
|
|
121
|
+
lastRefill = Date.now();
|
|
122
|
+
pump();
|
|
123
|
+
},
|
|
124
|
+
pauseFor: (ms) => {
|
|
125
|
+
const until = Date.now() + Math.max(0, ms);
|
|
126
|
+
if (until > pausedUntil)
|
|
127
|
+
pausedUntil = until;
|
|
128
|
+
schedule(ms);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// 429 / rate-limit header parsing
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
/**
|
|
136
|
+
* If `err` is a transport HTTP 429, return its response `Headers`; else null.
|
|
137
|
+
* Duck-typed against the `@solana/errors` HTTP-error context
|
|
138
|
+
* (`{ statusCode, headers }`) so we avoid a hard dependency on the error code.
|
|
139
|
+
*/
|
|
140
|
+
function http429Headers(err) {
|
|
141
|
+
const ctx = err
|
|
142
|
+
?.context;
|
|
143
|
+
if (ctx?.statusCode === 429 && ctx.headers instanceof Headers) {
|
|
144
|
+
return ctx.headers;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** Parse `Retry-After` (delta-seconds or HTTP-date) into ms, or null. */
|
|
149
|
+
function parseRetryAfterMs(headers) {
|
|
150
|
+
const v = headers.get('retry-after');
|
|
151
|
+
if (v === null || v === '')
|
|
152
|
+
return null;
|
|
153
|
+
const secs = Number(v);
|
|
154
|
+
if (Number.isFinite(secs))
|
|
155
|
+
return Math.max(0, secs * 1000);
|
|
156
|
+
const when = Date.parse(v);
|
|
157
|
+
return Number.isNaN(when) ? null : Math.max(0, when - Date.now());
|
|
158
|
+
}
|
|
159
|
+
/** Provider-advertised requests/second limit (`x-ratelimit-rps-limit`), or null. */
|
|
160
|
+
function parseRpsLimit(headers) {
|
|
161
|
+
const v = Number(headers.get('x-ratelimit-rps-limit'));
|
|
162
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
50
165
|
// Implementation
|
|
51
166
|
// ---------------------------------------------------------------------------
|
|
52
167
|
/**
|
|
@@ -58,11 +173,51 @@ const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com';
|
|
|
58
173
|
export function createCircuitBreakerRpc({ primaryUrl, fallbackUrl, circuitBreakerOptions: opts = {}, }) {
|
|
59
174
|
const primaryTransport = createDefaultRpcTransport({ url: primaryUrl });
|
|
60
175
|
const fallbackTransport = createDefaultRpcTransport({ url: fallbackUrl });
|
|
176
|
+
// Throttling is always on. `maxRequestsPerSecond` is the *ceiling*: every
|
|
177
|
+
// request flows through an adaptive token bucket that backs off on HTTP 429
|
|
178
|
+
// (honoring `Retry-After` and `x-ratelimit-rps-limit` when present, AIMD
|
|
179
|
+
// otherwise) and recovers back toward the ceiling on sustained success.
|
|
180
|
+
const ceilingRate = opts.maxRequestsPerSecond !== undefined && opts.maxRequestsPerSecond > 0
|
|
181
|
+
? opts.maxRequestsPerSecond
|
|
182
|
+
: DEFAULT_MAX_RPS;
|
|
183
|
+
const gate = createRateGate(ceilingRate);
|
|
184
|
+
let currentRate = ceilingRate;
|
|
185
|
+
let successStreak = 0;
|
|
186
|
+
const onError = (err) => {
|
|
187
|
+
const headers = http429Headers(err);
|
|
188
|
+
if (!headers)
|
|
189
|
+
return; // only adapt to rate-limit (429) failures
|
|
190
|
+
successStreak = 0;
|
|
191
|
+
const advertised = parseRpsLimit(headers);
|
|
192
|
+
const next = advertised !== null
|
|
193
|
+
? Math.min(ceilingRate, Math.max(MIN_RATE, advertised * RATE_SAFETY_FACTOR))
|
|
194
|
+
: Math.max(MIN_RATE, currentRate * AIMD_DECREASE);
|
|
195
|
+
if (next !== currentRate) {
|
|
196
|
+
currentRate = next;
|
|
197
|
+
gate.setRate(currentRate);
|
|
198
|
+
}
|
|
199
|
+
const retryAfter = parseRetryAfterMs(headers);
|
|
200
|
+
gate.pauseFor(retryAfter ?? DEFAULT_COOLDOWN_MS);
|
|
201
|
+
logger.warn(`[rpc-circuit-breaker] 429 — throttling to ${currentRate.toFixed(1)} req/s` +
|
|
202
|
+
`, cooling down ${retryAfter ?? DEFAULT_COOLDOWN_MS}ms`);
|
|
203
|
+
};
|
|
204
|
+
const onSuccess = () => {
|
|
205
|
+
if (currentRate >= ceilingRate)
|
|
206
|
+
return;
|
|
207
|
+
if (++successStreak >= RECOVERY_SUCCESSES) {
|
|
208
|
+
successStreak = 0;
|
|
209
|
+
currentRate = Math.min(ceilingRate, currentRate + 1);
|
|
210
|
+
gate.setRate(currentRate);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
61
213
|
const breaker = new CircuitBreaker((request) => primaryTransport(request), {
|
|
62
214
|
timeout: opts.timeout ?? 10_000,
|
|
63
215
|
errorThresholdPercentage: opts.errorThresholdPercentage ?? 25,
|
|
64
216
|
resetTimeout: opts.resetTimeout ?? 60_000,
|
|
65
217
|
volumeThreshold: opts.volumeThreshold ?? 3,
|
|
218
|
+
...(opts.maxConcurrent !== undefined && opts.maxConcurrent > 0
|
|
219
|
+
? { capacity: opts.maxConcurrent }
|
|
220
|
+
: {}),
|
|
66
221
|
});
|
|
67
222
|
breaker.fallback((request) => fallbackTransport(request));
|
|
68
223
|
breaker.on('open', () => {
|
|
@@ -74,7 +229,20 @@ export function createCircuitBreakerRpc({ primaryUrl, fallbackUrl, circuitBreake
|
|
|
74
229
|
breaker.on('close', () => {
|
|
75
230
|
logger.info('[rpc-circuit-breaker] circuit CLOSED — primary RPC recovered');
|
|
76
231
|
});
|
|
77
|
-
|
|
232
|
+
// Adapt the rate to the *primary's* health via opossum's events: `failure`
|
|
233
|
+
// fires whenever the primary call rejects (a 429 included) even when the
|
|
234
|
+
// fallback then masks it by resolving `fire()`, and `success` fires on a
|
|
235
|
+
// healthy primary call. A plain try/catch around `fire()` would miss the
|
|
236
|
+
// fallback-masked 429s entirely.
|
|
237
|
+
breaker.on('failure', (err) => onError(err));
|
|
238
|
+
breaker.on('success', () => onSuccess());
|
|
239
|
+
const transport = (async (request) => {
|
|
240
|
+
// Throttle entry to the breaker so we stay under the provider's rate
|
|
241
|
+
// limit; the queue wait sits outside `fire`, so opossum's per-request
|
|
242
|
+
// timeout only measures the actual transport call.
|
|
243
|
+
await gate.acquire();
|
|
244
|
+
return breaker.fire(request);
|
|
245
|
+
});
|
|
78
246
|
return createSolanaRpcFromTransport(transport);
|
|
79
247
|
}
|
|
80
248
|
/**
|
package/lib/esm/version.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { type Address, type Commitment } from '@solana/kit';
|
|
26
26
|
import { type ILogger } from '../common/logger.js';
|
|
27
|
-
import type { ANTHandler, ANTInfo, ANTRecord, ANTState, AntReadOptions, SortedANTRecords } from '../types/ant.js';
|
|
27
|
+
import type { ANTHandler, ANTInfo, ANTRecord, ANTState, ANTSummary, AntReadOptions, SortedANTRecords } from '../types/ant.js';
|
|
28
28
|
import type { WalletAddress } from '../types/common.js';
|
|
29
29
|
import { SolanaANTRegistryReadable } from './ant-registry-readable.js';
|
|
30
30
|
import type { SolanaRpc } from './types.js';
|
|
@@ -99,6 +99,12 @@ export declare class SolanaANTReadable {
|
|
|
99
99
|
private getAccount;
|
|
100
100
|
private fetchConfig;
|
|
101
101
|
private fetchControllers;
|
|
102
|
+
/**
|
|
103
|
+
* Fetch AntConfig + AntControllers in a single `getMultipleAccounts` round
|
|
104
|
+
* trip (instead of two single-account reads). Used by `getState` to shave one
|
|
105
|
+
* RPC per ANT — meaningful when a UI loads many ANTs.
|
|
106
|
+
*/
|
|
107
|
+
private _fetchConfigAndControllers;
|
|
102
108
|
getOwner(_opts?: AntReadOptions): Promise<WalletAddress>;
|
|
103
109
|
/** Get the on-chain schema version of this ANT's config. */
|
|
104
110
|
getConfigVersion(): Promise<number>;
|
|
@@ -111,12 +117,50 @@ export declare class SolanaANTReadable {
|
|
|
111
117
|
getRecord({ undername }: {
|
|
112
118
|
undername: string;
|
|
113
119
|
}, _opts?: AntReadOptions): Promise<ANTRecord | undefined>;
|
|
114
|
-
getRecords(
|
|
120
|
+
getRecords(opts?: AntReadOptions): Promise<SortedANTRecords>;
|
|
121
|
+
/**
|
|
122
|
+
* Bulk-load lightweight {@link ANTSummary} state for many ANTs in a handful
|
|
123
|
+
* of `getMultipleAccounts` calls instead of `N × getState`. For each mint it
|
|
124
|
+
* batches AntConfig + AntControllers + the apex (`@`) AntRecord — everything a
|
|
125
|
+
* portfolio/names table needs. Full undername records are NOT loaded here;
|
|
126
|
+
* fetch them lazily per-ANT via {@link getRecords}/{@link getState} when a
|
|
127
|
+
* name is opened.
|
|
128
|
+
*
|
|
129
|
+
* Requests: ~`ceil(3N / 100)` calls for N mints (10 → 1, 250 → 8), versus
|
|
130
|
+
* ~`4N` with per-ANT `getState`. Assumes every mint is deployed under this
|
|
131
|
+
* instance's `antProgram` (true for the standard AR.IO ANT program).
|
|
132
|
+
*
|
|
133
|
+
* Mints whose AntConfig doesn't exist are omitted from the result.
|
|
134
|
+
*/
|
|
135
|
+
getANTSummaries(mints: ReadonlyArray<string>): Promise<Record<string, ANTSummary>>;
|
|
136
|
+
/**
|
|
137
|
+
* Bulk-load FULL {@link ANTState} (including all undername records) for many
|
|
138
|
+
* ANTs in a handful of calls instead of `N × getState`:
|
|
139
|
+
* - AntConfig + AntControllers for every mint via `getMultipleAccounts`
|
|
140
|
+
* (chunked at 100), and
|
|
141
|
+
* - ALL undername records via a SINGLE program-wide `getProgramAccounts`
|
|
142
|
+
* scan grouped by mint (offset 8), instead of one mint-filtered scan per
|
|
143
|
+
* ANT.
|
|
144
|
+
*
|
|
145
|
+
* Requests: ~`ceil(2N / 100) + 1` (+1 when `includeMetadata`) regardless of
|
|
146
|
+
* N — e.g. 10 ANTs → 2 calls, 250 → ~6 — versus ~`2N` with per-ANT
|
|
147
|
+
* `getState`. The records scan reads every ANT's records program-wide (cheap
|
|
148
|
+
* per account, one round trip); prefer per-ANT {@link getState} when you only
|
|
149
|
+
* need one ANT. Mints with no AntConfig are omitted.
|
|
150
|
+
*/
|
|
151
|
+
getANTStates(mints: ReadonlyArray<string>, opts?: AntReadOptions): Promise<Record<string, ANTState>>;
|
|
152
|
+
/**
|
|
153
|
+
* Group every AntRecord (+ optional metadata) in the program by mint via a
|
|
154
|
+
* single `getProgramAccounts` scan (the mint sits at offset 8). Used by
|
|
155
|
+
* {@link getANTStates} to load all ANTs' undername records in one round trip
|
|
156
|
+
* instead of one mint-filtered scan per ANT.
|
|
157
|
+
*/
|
|
158
|
+
private _recordsByMint;
|
|
115
159
|
getBalance({ address: queryAddress }: {
|
|
116
160
|
address: WalletAddress;
|
|
117
161
|
}, _opts?: AntReadOptions): Promise<number>;
|
|
118
162
|
getBalances(_opts?: AntReadOptions): Promise<Record<WalletAddress, number>>;
|
|
119
|
-
getState(
|
|
163
|
+
getState(opts?: AntReadOptions): Promise<ANTState>;
|
|
120
164
|
getInfo(_opts?: AntReadOptions): Promise<ANTInfo>;
|
|
121
165
|
getHandlers(): Promise<ANTHandler[]>;
|
|
122
166
|
getModuleId(_opts?: {
|
|
@@ -49,6 +49,7 @@ export declare class SolanaARIOReadable {
|
|
|
49
49
|
protected readonly arnsProgram: Address;
|
|
50
50
|
protected readonly antProgram: Address;
|
|
51
51
|
private _arioMint?;
|
|
52
|
+
private _accountCache;
|
|
52
53
|
constructor(config: SolanaReadConfig & {
|
|
53
54
|
logger?: ILogger;
|
|
54
55
|
coreProgramId?: Address;
|
|
@@ -58,6 +59,13 @@ export declare class SolanaARIOReadable {
|
|
|
58
59
|
});
|
|
59
60
|
/** Helper to fetch an encoded account (kit's replacement for Connection.getAccountInfo). */
|
|
60
61
|
private getAccount;
|
|
62
|
+
/**
|
|
63
|
+
* Like {@link getAccount} but caches the result per-PDA for `ttlMs`. Use only
|
|
64
|
+
* for accounts that change slowly (DemandFactor, ArnsConfig) where a few
|
|
65
|
+
* seconds of staleness is acceptable in exchange for collapsing repeated
|
|
66
|
+
* reads. A successful fetch is cached; misses (`exists: false`) are not.
|
|
67
|
+
*/
|
|
68
|
+
private getCachedAccount;
|
|
61
69
|
/**
|
|
62
70
|
* Helper for `getProgramAccounts` with a discriminator memcmp filter.
|
|
63
71
|
*
|
|
@@ -522,6 +522,19 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
522
522
|
years?: number;
|
|
523
523
|
processId: string;
|
|
524
524
|
} & Partial<ArNSPurchaseParams>, _options?: WriteOptions): Promise<MessageResult>;
|
|
525
|
+
/**
|
|
526
|
+
* Pick a single stake-derived funding source that can cover a returned-name
|
|
527
|
+
* purchase, for the single-source `buy_returned_name_from_*` paths.
|
|
528
|
+
*
|
|
529
|
+
* Returned-name prices decay per slot, so the multi-source funding plan
|
|
530
|
+
* (which pre-commits exact amounts) can't match the execution-time cost. The
|
|
531
|
+
* single-source paths carry no amount — the program draws the live cost — so
|
|
532
|
+
* we only need to pick ONE source with enough stake. We size the pick against
|
|
533
|
+
* the premium-inclusive estimate (an upper bound, since the price only falls
|
|
534
|
+
* from now) and choose the largest matching source. Returns `null` when no
|
|
535
|
+
* single source covers the estimate.
|
|
536
|
+
*/
|
|
537
|
+
private _autoPickReturnedNameStakeSource;
|
|
525
538
|
/** Reassign an ArNS name to a different ANT. */
|
|
526
539
|
reassignName(params: {
|
|
527
540
|
name: string;
|
|
@@ -24,6 +24,35 @@ export interface CircuitBreakerRpcOptions {
|
|
|
24
24
|
* @default 3
|
|
25
25
|
*/
|
|
26
26
|
volumeThreshold?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Ceiling for requests allowed through per second. Implemented as an
|
|
29
|
+
* adaptive token bucket *in front of* the breaker: excess requests are
|
|
30
|
+
* queued (FIFO) until a token frees, smoothing bursts so you stay under a
|
|
31
|
+
* provider's rate limit (avoids HTTP 429 / Solana error #8100002).
|
|
32
|
+
*
|
|
33
|
+
* The bucket auto-tunes on 429s: it honors `Retry-After`, drops to
|
|
34
|
+
* `x-ratelimit-rps-limit` when the provider advertises one (public Solana
|
|
35
|
+
* RPC does; QuickNode generally does not), otherwise halves the rate
|
|
36
|
+
* (AIMD). It recovers back up toward this ceiling on sustained success.
|
|
37
|
+
*
|
|
38
|
+
* The queue wait happens *before* `breaker.fire`, so it does NOT count
|
|
39
|
+
* against {@link CircuitBreakerRpcOptions.timeout}.
|
|
40
|
+
*
|
|
41
|
+
* Throttling is always on; omitting this (or passing `<= 0`) uses the
|
|
42
|
+
* {@link DEFAULT_MAX_RPS} default. To effectively remove the limit, pass a
|
|
43
|
+
* very large number.
|
|
44
|
+
* @default 10
|
|
45
|
+
*/
|
|
46
|
+
maxRequestsPerSecond?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Maximum number of concurrent in-flight requests (opossum `capacity`
|
|
49
|
+
* semaphore). Unlike {@link CircuitBreakerRpcOptions.maxRequestsPerSecond},
|
|
50
|
+
* excess requests are **rejected immediately** rather than queued — this is
|
|
51
|
+
* concurrency control, not a rate limit. For avoiding 429s you usually want
|
|
52
|
+
* `maxRequestsPerSecond` instead.
|
|
53
|
+
* @default undefined (unlimited)
|
|
54
|
+
*/
|
|
55
|
+
maxConcurrent?: number;
|
|
27
56
|
}
|
|
28
57
|
export interface CircuitBreakerRpcConfig {
|
|
29
58
|
/** URL for the primary (preferred) RPC endpoint. */
|
package/lib/types/types/ant.d.ts
CHANGED
|
@@ -193,6 +193,25 @@ export declare const AntStateSchema: z.ZodObject<{
|
|
|
193
193
|
Initialized: boolean;
|
|
194
194
|
}>;
|
|
195
195
|
export type ANTState = z.infer<typeof AntStateSchema>;
|
|
196
|
+
/**
|
|
197
|
+
* Lightweight ANT view for portfolio/list rendering. Carries the config fields
|
|
198
|
+
* plus controllers and the apex (`@`) record — everything a names table needs —
|
|
199
|
+
* WITHOUT the full undername record set. Produced in bulk by
|
|
200
|
+
* `getANTSummaries(mints)` in a handful of `getMultipleAccounts` calls; fetch
|
|
201
|
+
* full undernames lazily via `getRecords`/`getState` when a name is opened.
|
|
202
|
+
*/
|
|
203
|
+
export type ANTSummary = {
|
|
204
|
+
processId: string;
|
|
205
|
+
name: string;
|
|
206
|
+
ticker: string;
|
|
207
|
+
logo: string;
|
|
208
|
+
description: string;
|
|
209
|
+
keywords: string[];
|
|
210
|
+
owner: WalletAddress;
|
|
211
|
+
controllers: WalletAddress[];
|
|
212
|
+
/** The apex (`@`) record — the name's primary target — if set. */
|
|
213
|
+
apexRecord?: ANTRecord;
|
|
214
|
+
};
|
|
196
215
|
export declare const SpawnANTStateSchema: z.ZodObject<{
|
|
197
216
|
name: z.ZodString;
|
|
198
217
|
ticker: z.ZodString;
|
|
@@ -324,6 +343,14 @@ export type ANTInfo = z.infer<typeof AntInfoSchema>;
|
|
|
324
343
|
export declare function isANTState(state: object): state is ANTState;
|
|
325
344
|
export type AntReadOptions = {
|
|
326
345
|
strict?: boolean;
|
|
346
|
+
/**
|
|
347
|
+
* Include per-record metadata (`displayName`/`logo`/`description`/`keywords`)
|
|
348
|
+
* when reading undername records. Defaults to `false` — metadata requires a
|
|
349
|
+
* second `getProgramAccounts` scan per ANT and is only needed in detail/edit
|
|
350
|
+
* views, so list reads (`getState`/`getRecords`) skip it by default. Fetch a
|
|
351
|
+
* single record's metadata on demand via `getRecord`.
|
|
352
|
+
*/
|
|
353
|
+
includeMetadata?: boolean;
|
|
327
354
|
};
|
|
328
355
|
export interface ANTRead {
|
|
329
356
|
processId: string;
|
package/lib/types/version.d.ts
CHANGED