@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.
@@ -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 Promise.all([
190
- this.getAccount(recordPda),
191
- this.getAccount(metaPda),
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(_opts) {
226
- // Fetch all AntRecord + AntRecordMetadata accounts for this mint in parallel.
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
- withRetry(() => this.rpc
252
- .getProgramAccounts(this.antProgram, {
253
- commitment: this.commitment,
254
- encoding: 'base64',
255
- filters: gpaFilter(bs58.encode(ANT_RECORD_METADATA_DISCRIMINATOR)),
256
- })
257
- .send()),
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(_opts) {
330
- const [config, controllersData, records] = await Promise.all([
331
- this.fetchConfig(),
332
- this.fetchControllers(),
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: controllersData.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
- const account = await this.getAccount(pda);
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.getAccount(dfPda);
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
- const gw = await this.getGateway({ address: params.fromAddress });
1331
- if (gw.status === 'joined') {
1332
- const discountAmount = Math.floor((tokenCost * 200_000) / RATE_SCALE);
1333
- discounts.push({
1334
- name: 'Gateway Operator',
1335
- discountTotal: discountAmount,
1336
- multiplier: 0.8,
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.getAccount(dfPda);
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.getAccount(pda);
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.getAccount(pda);
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.getAccount(arnsConfigPda);
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.getAccount(arnsConfigPda);
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.getAccount(pda);
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' || params.fundFrom === 'any') {
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
- // 'balance' or undefined falls through to the original direct-buy path.
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' || args.params.fundFrom === 'any') {
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
- if (!params.fundFrom ||
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 (params.fundFrom === 'stakes' && params.gatewayAddress) {
2023
- const gatewayAddr = address(params.gatewayAddress);
2087
+ if (resolvedGateway !== undefined) {
2088
+ const gatewayAddr = address(resolvedGateway);
2024
2089
  const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
2025
- if (params.fundAsOperator) {
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 (params.fundFrom === 'withdrawal' &&
2038
- params.withdrawalId !== undefined) {
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' || params.fundFrom === 'any') {
2043
- // Returned-name pricing is dynamic (Dutch auction premium); we trust
2044
- // explicit caller-supplied sources here and skip auto-discovery if
2045
- // sources is provided. For 'any' without sources, we fall back to a
2046
- // best-effort estimate using the plain registration fee caller can
2047
- // always retry with explicit sources on FundingPlanAmountMismatch.
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(syncIx ? [ix, syncIx] : [ix]);
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
- const transport = ((request) => breaker.fire(request));
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
  /**
@@ -14,4 +14,4 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  // AUTOMATICALLY GENERATED FILE - DO NOT TOUCH
17
- export const version = '4.0.0-solana.33';
17
+ export const version = '4.0.0-solana.35';
@@ -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(_opts?: AntReadOptions): Promise<SortedANTRecords>;
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(_opts?: AntReadOptions): Promise<ANTState>;
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. */
@@ -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;
@@ -13,4 +13,4 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- export declare const version = "4.0.0-solana.32";
16
+ export declare const version = "4.0.0-solana.34";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ar.io/sdk",
3
- "version": "4.0.0-solana.33",
3
+ "version": "4.0.0-solana.35",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ar-io/ar-io-sdk.git"