@ar.io/sdk 4.0.0-solana.34 → 4.0.0-solana.36

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,
@@ -18,15 +18,18 @@
18
18
  *
19
19
  * Produces the EXACT bytes a recipient signs to release an escrowed
20
20
  * ANT. Output MUST be byte-identical to the Rust implementation in
21
- * `contracts/programs/ario-ant-escrow/src/canonical.rs::build_canonical_message`.
22
- * Cross-language equivalence is asserted by `canonical-message.test.ts`
23
- * which spawns the Rust `canonical` example binary and diffs the bytes.
21
+ * `ario-ant-escrow/src/canonical.rs::build_ant_escrow_claim_message`
22
+ * (header `ANT_ESCROW_CLAIM_HEADER = "ar.io ant-escrow claim"`). The on-chain
23
+ * program reconstructs these exact bytes and verifies the signature against
24
+ * them, so any drift (header, field set, or ordering) makes every claim fail
25
+ * `EthereumAddressMismatch` / signature verification.
24
26
  *
25
27
  * Format (UTF-8, line-feed separated, no trailing newline):
26
28
  *
27
29
  * ```text
28
- * ar.io ant-escrow claim v1
30
+ * ar.io ant-escrow claim
29
31
  * network: <network>
32
+ * recipient: <base64url(sha256(recipient_pubkey)) — 43 chars, no pad>
30
33
  * ant: <ant_mint_base58>
31
34
  * claimant: <claimant_solana_pubkey_base58>
32
35
  * nonce: <nonce_hex_lowercase>
@@ -37,8 +40,9 @@
37
40
  * - Ethereum: `wallet.signMessage(bytes)` → 65-byte ECDSA + EIP-191 sig
38
41
  * (the wallet applies the EIP-191 prefix; on-chain code re-applies it).
39
42
  */
40
- /** Header literal must match Rust `CANONICAL_HEADER`. */
41
- const CANONICAL_HEADER = 'ar.io ant-escrow claim v1';
43
+ import { sha256 } from '@noble/hashes/sha256';
44
+ /** Header literal must match Rust `ANT_ESCROW_CLAIM_HEADER`. */
45
+ const CANONICAL_HEADER = 'ar.io ant-escrow claim';
42
46
  /**
43
47
  * Build the canonical claim message bytes. UTF-8 encoded, no trailing
44
48
  * newline, exactly the format shown in the docstring.
@@ -52,21 +56,23 @@ export function canonicalMessage(input) {
52
56
  }
53
57
  const text = `${CANONICAL_HEADER}\n` +
54
58
  `network: ${input.network}\n` +
59
+ `recipient: ${deriveRecipientId(input.recipient)}\n` +
55
60
  `ant: ${input.antMint}\n` +
56
61
  `claimant: ${input.claimant}\n` +
57
62
  `nonce: ${bytesToHexLower(input.nonce)}`;
58
63
  return new TextEncoder().encode(text);
59
64
  }
60
- /** Header literal — must match Rust `CANONICAL_HEADER_V2`. */
61
- const CANONICAL_HEADER_V2 = 'ar.io escrow claim v2';
65
+ /** Header literal — must match Rust `ESCROW_CLAIM_HEADER`. */
66
+ const CANONICAL_HEADER_V2 = 'ar.io escrow claim';
62
67
  /**
63
68
  * Build the v2 canonical claim message bytes for token/vault escrows.
64
69
  * UTF-8 encoded, no trailing newline.
65
70
  *
66
71
  * Format:
67
72
  * ```text
68
- * ar.io escrow claim v2
73
+ * ar.io escrow claim
69
74
  * network: <network>
75
+ * recipient: <base64url(sha256(recipient_pubkey)) — 43 chars, no pad>
70
76
  * type: <token|vault>
71
77
  * asset: <asset_id_hex_lowercase_64chars>
72
78
  * amount: <u64_decimal>
@@ -85,6 +91,7 @@ export function canonicalMessageV2(input) {
85
91
  }
86
92
  const text = `${CANONICAL_HEADER_V2}\n` +
87
93
  `network: ${input.network}\n` +
94
+ `recipient: ${deriveRecipientId(input.recipient)}\n` +
88
95
  `type: ${input.assetType}\n` +
89
96
  `asset: ${bytesToHexLower(input.assetId)}\n` +
90
97
  `amount: ${input.amount.toString()}\n` +
@@ -95,6 +102,20 @@ export function canonicalMessageV2(input) {
95
102
  // =========================================
96
103
  // Shared utilities
97
104
  // =========================================
105
+ /**
106
+ * Recipient identity bound into the claim message — `base64url(sha256(bytes))`
107
+ * with no padding (32-byte hash → 43 chars). Byte-identical to the contract's
108
+ * `canonical.rs::derive_recipient_id_b64url`. Input is the recipient pubkey
109
+ * bytes the deposit targeted (ETH 20-byte address / Solana 32-byte pubkey /
110
+ * Arweave RSA modulus, etc.).
111
+ */
112
+ export function deriveRecipientId(recipient) {
113
+ if (recipient.length === 0) {
114
+ throw new Error('deriveRecipientId: recipient bytes must be non-empty');
115
+ }
116
+ // Node's 'base64url' encoding is unpadded — matches the Rust no-pad alphabet.
117
+ return Buffer.from(sha256(recipient)).toString('base64url');
118
+ }
98
119
  /** Lowercase-hex encoding. Matches Rust `encode_hex_lowercase`. */
99
120
  export function bytesToHexLower(bytes) {
100
121
  let s = '';
@@ -91,7 +91,7 @@ export { ANTEscrow, TokenEscrow,
91
91
  // in lock-step so users never see a raw on-chain error.
92
92
  assertVaultClaimable, isVaultClaimable, CLOCK_SKEW_TOLERANCE_SECONDS, } from './escrow.js';
93
93
  // Canonical claim-message helper (byte-equivalent to Rust impl)
94
- export { canonicalMessage, canonicalMessageV2, bytesToHexLower, } from './canonical-message.js';
94
+ export { canonicalMessage, canonicalMessageV2, deriveRecipientId, bytesToHexLower, } from './canonical-message.js';
95
95
  // ANT spawn (mint MPL Core asset + initialize ario-ant state in one tx)
96
96
  export { spawnSolanaANT, ARIO_LOGO_TX_ID, DEFAULT_ANT_TRANSACTION_ID, } from './spawn-ant.js';
97
97
  // PDA derivation
@@ -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);
@@ -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.34';
17
+ export const version = '4.0.0-solana.36';
@@ -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?: {
@@ -18,15 +18,18 @@
18
18
  *
19
19
  * Produces the EXACT bytes a recipient signs to release an escrowed
20
20
  * ANT. Output MUST be byte-identical to the Rust implementation in
21
- * `contracts/programs/ario-ant-escrow/src/canonical.rs::build_canonical_message`.
22
- * Cross-language equivalence is asserted by `canonical-message.test.ts`
23
- * which spawns the Rust `canonical` example binary and diffs the bytes.
21
+ * `ario-ant-escrow/src/canonical.rs::build_ant_escrow_claim_message`
22
+ * (header `ANT_ESCROW_CLAIM_HEADER = "ar.io ant-escrow claim"`). The on-chain
23
+ * program reconstructs these exact bytes and verifies the signature against
24
+ * them, so any drift (header, field set, or ordering) makes every claim fail
25
+ * `EthereumAddressMismatch` / signature verification.
24
26
  *
25
27
  * Format (UTF-8, line-feed separated, no trailing newline):
26
28
  *
27
29
  * ```text
28
- * ar.io ant-escrow claim v1
30
+ * ar.io ant-escrow claim
29
31
  * network: <network>
32
+ * recipient: <base64url(sha256(recipient_pubkey)) — 43 chars, no pad>
30
33
  * ant: <ant_mint_base58>
31
34
  * claimant: <claimant_solana_pubkey_base58>
32
35
  * nonce: <nonce_hex_lowercase>
@@ -50,6 +53,12 @@ export interface CanonicalMessageInput {
50
53
  /** Solana pubkey that will receive the ANT on claim. Bound into the
51
54
  * signature so front-runners can't redirect. */
52
55
  claimant: Address;
56
+ /** Recipient identity pubkey bytes the deposit targeted (ETH 20-byte
57
+ * address, Solana 32-byte pubkey, or Arweave RSA modulus). Bound into the
58
+ * message as `recipient: base64url(sha256(bytes))` to match the contract's
59
+ * `derive_recipient_id_b64url`. REQUIRED — the program reconstructs this
60
+ * line, so omitting it makes the claim mismatch. */
61
+ recipient: Uint8Array;
53
62
  /** 32-byte anti-replay nonce — read from the EscrowAnt account. */
54
63
  nonce: Uint8Array;
55
64
  }
@@ -72,6 +81,12 @@ export interface CanonicalMessageV2Input {
72
81
  amount: bigint;
73
82
  /** Solana pubkey that will receive the tokens on claim. */
74
83
  claimant: Address;
84
+ /** Recipient identity pubkey bytes the deposit targeted (ETH 20-byte
85
+ * address, Solana 32-byte pubkey, or Arweave RSA modulus). Bound into the
86
+ * message as `recipient: base64url(sha256(bytes))` to match the contract's
87
+ * `derive_recipient_id_b64url`. REQUIRED — the program reconstructs this
88
+ * line, so omitting it makes the claim mismatch. */
89
+ recipient: Uint8Array;
75
90
  /** 32-byte anti-replay nonce — read from the EscrowToken account. */
76
91
  nonce: Uint8Array;
77
92
  }
@@ -81,8 +96,9 @@ export interface CanonicalMessageV2Input {
81
96
  *
82
97
  * Format:
83
98
  * ```text
84
- * ar.io escrow claim v2
99
+ * ar.io escrow claim
85
100
  * network: <network>
101
+ * recipient: <base64url(sha256(recipient_pubkey)) — 43 chars, no pad>
86
102
  * type: <token|vault>
87
103
  * asset: <asset_id_hex_lowercase_64chars>
88
104
  * amount: <u64_decimal>
@@ -93,5 +109,13 @@ export interface CanonicalMessageV2Input {
93
109
  * @throws if `assetId` or `nonce` aren't exactly 32 bytes.
94
110
  */
95
111
  export declare function canonicalMessageV2(input: CanonicalMessageV2Input): Uint8Array;
112
+ /**
113
+ * Recipient identity bound into the claim message — `base64url(sha256(bytes))`
114
+ * with no padding (32-byte hash → 43 chars). Byte-identical to the contract's
115
+ * `canonical.rs::derive_recipient_id_b64url`. Input is the recipient pubkey
116
+ * bytes the deposit targeted (ETH 20-byte address / Solana 32-byte pubkey /
117
+ * Arweave RSA modulus, etc.).
118
+ */
119
+ export declare function deriveRecipientId(recipient: Uint8Array): string;
96
120
  /** Lowercase-hex encoding. Matches Rust `encode_hex_lowercase`. */
97
121
  export declare function bytesToHexLower(bytes: Uint8Array): string;
@@ -68,7 +68,7 @@ export type { SolanaANTRegistryWriteableConfig } from './ant-registry-writeable.
68
68
  export type { AclMaintenanceOp, AclMaintenanceRole, } from '../types/ant-registry.js';
69
69
  export { ANTEscrow, TokenEscrow, assertVaultClaimable, isVaultClaimable, CLOCK_SKEW_TOLERANCE_SECONDS, } from './escrow.js';
70
70
  export type { ANTEscrowConfig, EscrowAntState, EscrowAssetType, EscrowProtocol, EscrowTokenState, } from './escrow.js';
71
- export { canonicalMessage, canonicalMessageV2, bytesToHexLower, } from './canonical-message.js';
71
+ export { canonicalMessage, canonicalMessageV2, deriveRecipientId, bytesToHexLower, } from './canonical-message.js';
72
72
  export type { CanonicalMessageInput, CanonicalMessageV2Input, EscrowNetwork, } from './canonical-message.js';
73
73
  export { spawnSolanaANT, ARIO_LOGO_TX_ID, DEFAULT_ANT_TRANSACTION_ID, } from './spawn-ant.js';
74
74
  export type { SpawnSolanaANTParams, SpawnSolanaANTResult, SpawnSolanaANTState, } from './spawn-ant.js';
@@ -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
  *
@@ -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.33";
16
+ export declare const version = "4.0.0-solana.35";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ar.io/sdk",
3
- "version": "4.0.0-solana.34",
3
+ "version": "4.0.0-solana.36",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ar-io/ar-io-sdk.git"
@@ -115,6 +115,7 @@
115
115
  },
116
116
  "dependencies": {
117
117
  "@ar.io/solana-contracts": "0.7.0-staging.17",
118
+ "@noble/hashes": "^1.8.0",
118
119
  "@solana-program/address-lookup-table": "^0.11.0",
119
120
  "@solana-program/compute-budget": "^0.15.0",
120
121
  "@solana-program/token": "^0.13.0",