@artblocks/abx-sdk 0.1.0-alpha.3 → 0.1.0-alpha.31

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.
Files changed (149) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +130 -0
  3. package/dist/abi/generated.d.ts +8502 -129
  4. package/dist/abi/generated.d.ts.map +1 -1
  5. package/dist/abi/generated.js +40 -22
  6. package/dist/abi/generated.js.map +1 -1
  7. package/dist/abi/index.d.ts +2645 -168
  8. package/dist/abi/index.d.ts.map +1 -1
  9. package/dist/abi/index.js +34 -7
  10. package/dist/abi/index.js.map +1 -1
  11. package/dist/anchors.d.ts +318 -0
  12. package/dist/anchors.d.ts.map +1 -0
  13. package/dist/anchors.js +701 -0
  14. package/dist/anchors.js.map +1 -0
  15. package/dist/chains.d.ts +19 -9
  16. package/dist/chains.d.ts.map +1 -1
  17. package/dist/chains.js +31 -15
  18. package/dist/chains.js.map +1 -1
  19. package/dist/chunks.d.ts +123 -27
  20. package/dist/chunks.d.ts.map +1 -1
  21. package/dist/chunks.js +124 -9
  22. package/dist/chunks.js.map +1 -1
  23. package/dist/clients.d.ts +23 -11
  24. package/dist/clients.d.ts.map +1 -1
  25. package/dist/clients.js +17 -18
  26. package/dist/clients.js.map +1 -1
  27. package/dist/create2.d.ts +84 -1
  28. package/dist/create2.d.ts.map +1 -1
  29. package/dist/create2.js +158 -2
  30. package/dist/create2.js.map +1 -1
  31. package/dist/creator-token.d.ts +132 -0
  32. package/dist/creator-token.d.ts.map +1 -0
  33. package/dist/creator-token.js +183 -0
  34. package/dist/creator-token.js.map +1 -0
  35. package/dist/deploy.d.ts +234 -14
  36. package/dist/deploy.d.ts.map +1 -1
  37. package/dist/deploy.js +289 -111
  38. package/dist/deploy.js.map +1 -1
  39. package/dist/deployments.d.ts +85 -5
  40. package/dist/deployments.d.ts.map +1 -1
  41. package/dist/deployments.js +159 -30
  42. package/dist/deployments.js.map +1 -1
  43. package/dist/deps.d.ts +123 -6
  44. package/dist/deps.d.ts.map +1 -1
  45. package/dist/deps.js +249 -7
  46. package/dist/deps.js.map +1 -1
  47. package/dist/env.d.ts +5 -4
  48. package/dist/env.d.ts.map +1 -1
  49. package/dist/env.js +20 -5
  50. package/dist/env.js.map +1 -1
  51. package/dist/errors.d.ts +109 -0
  52. package/dist/errors.d.ts.map +1 -0
  53. package/dist/errors.js +148 -0
  54. package/dist/errors.js.map +1 -0
  55. package/dist/execute.d.ts +118 -0
  56. package/dist/execute.d.ts.map +1 -0
  57. package/dist/execute.js +159 -0
  58. package/dist/execute.js.map +1 -0
  59. package/dist/gateways.d.ts +79 -0
  60. package/dist/gateways.d.ts.map +1 -0
  61. package/dist/gateways.js +156 -0
  62. package/dist/gateways.js.map +1 -0
  63. package/dist/generator-document.d.ts +57 -0
  64. package/dist/generator-document.d.ts.map +1 -0
  65. package/dist/generator-document.js +123 -0
  66. package/dist/generator-document.js.map +1 -0
  67. package/dist/generator.d.ts +44 -2
  68. package/dist/generator.d.ts.map +1 -1
  69. package/dist/generator.js +29 -0
  70. package/dist/generator.js.map +1 -1
  71. package/dist/index.d.ts +18 -3
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +18 -3
  74. package/dist/index.js.map +1 -1
  75. package/dist/inspect.d.ts +48 -0
  76. package/dist/inspect.d.ts.map +1 -0
  77. package/dist/inspect.js +295 -0
  78. package/dist/inspect.js.map +1 -0
  79. package/dist/migrate.d.ts +41 -0
  80. package/dist/migrate.d.ts.map +1 -0
  81. package/dist/migrate.js +142 -0
  82. package/dist/migrate.js.map +1 -0
  83. package/dist/mime.d.ts +11 -0
  84. package/dist/mime.d.ts.map +1 -0
  85. package/dist/mime.js +40 -0
  86. package/dist/mime.js.map +1 -0
  87. package/dist/node.d.ts +69 -0
  88. package/dist/node.d.ts.map +1 -0
  89. package/dist/node.js +92 -0
  90. package/dist/node.js.map +1 -0
  91. package/dist/onchain-uri.d.ts +110 -0
  92. package/dist/onchain-uri.d.ts.map +1 -0
  93. package/dist/onchain-uri.js +276 -0
  94. package/dist/onchain-uri.js.map +1 -0
  95. package/dist/ops.d.ts +507 -14
  96. package/dist/ops.d.ts.map +1 -1
  97. package/dist/ops.js +850 -34
  98. package/dist/ops.js.map +1 -1
  99. package/dist/policy.d.ts +57 -0
  100. package/dist/policy.d.ts.map +1 -0
  101. package/dist/policy.js +35 -0
  102. package/dist/policy.js.map +1 -0
  103. package/dist/probe.d.ts +58 -0
  104. package/dist/probe.d.ts.map +1 -1
  105. package/dist/probe.js +190 -9
  106. package/dist/probe.js.map +1 -1
  107. package/dist/reconstruct.d.ts +141 -3
  108. package/dist/reconstruct.d.ts.map +1 -1
  109. package/dist/reconstruct.js +479 -100
  110. package/dist/reconstruct.js.map +1 -1
  111. package/dist/resume.d.ts +140 -0
  112. package/dist/resume.d.ts.map +1 -0
  113. package/dist/resume.js +146 -0
  114. package/dist/resume.js.map +1 -0
  115. package/dist/script-chunks.d.ts +105 -0
  116. package/dist/script-chunks.d.ts.map +1 -0
  117. package/dist/script-chunks.js +158 -0
  118. package/dist/script-chunks.js.map +1 -0
  119. package/dist/service.d.ts +151 -9
  120. package/dist/service.d.ts.map +1 -1
  121. package/dist/service.js +103 -11
  122. package/dist/service.js.map +1 -1
  123. package/dist/spine.d.ts +47 -4
  124. package/dist/spine.d.ts.map +1 -1
  125. package/dist/spine.js +0 -0
  126. package/dist/spine.js.map +1 -1
  127. package/dist/staging.d.ts +139 -0
  128. package/dist/staging.d.ts.map +1 -0
  129. package/dist/staging.js +139 -0
  130. package/dist/staging.js.map +1 -0
  131. package/dist/token.d.ts +11 -1
  132. package/dist/token.d.ts.map +1 -1
  133. package/dist/token.js +32 -1
  134. package/dist/token.js.map +1 -1
  135. package/dist/tokendata.d.ts +63 -3
  136. package/dist/tokendata.d.ts.map +1 -1
  137. package/dist/tokendata.js +81 -19
  138. package/dist/tokendata.js.map +1 -1
  139. package/dist/tokens.d.ts +119 -0
  140. package/dist/tokens.d.ts.map +1 -0
  141. package/dist/tokens.js +317 -0
  142. package/dist/tokens.js.map +1 -0
  143. package/dist/types.d.ts +107 -5
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/util.d.ts +74 -0
  146. package/dist/util.d.ts.map +1 -0
  147. package/dist/util.js +106 -0
  148. package/dist/util.js.map +1 -0
  149. package/package.json +15 -6
@@ -1,6 +1,42 @@
1
1
  import { concat as concatHex, keccak256, parseEventLogs, } from 'viem';
2
- import { oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesCodeAbi, spineEventAbi } from './abi/index.js';
2
+ import { oneOfOneEditionAbi, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesCodeAbi, spineEventAbi } from './abi/index.js';
3
3
  import { AUTH_OPTIONS, decodeTag, DELEGATE_REGISTRY_DEFAULT, extensionName, EXTENSION_ID, PARAM_TYPES, SPINE_EVENT_DOC, } from './spine.js';
4
+ import { AbxSdkError, BlockTagUnavailableError } from './errors.js';
5
+ import { readEnv } from './util.js';
6
+ /**
7
+ * Resolve `'safe'`/`'finalized'` to a CONCRETE inclusive block number — BEFORE any `eth_getLogs`
8
+ * scan starts, and before it's persisted anywhere. A stored watermark (`ProjectState.toBlock`) must
9
+ * always be a literal number, never the tag itself: `"finalized"` is a moving target, so persisting
10
+ * the string would silently redefine an already-written checkpoint's meaning on every later read,
11
+ * and two reconstructions minutes apart would disagree about what block a stored `toBlock:
12
+ * "finalized"` even meant. Resolving once, here, up front, is what makes the persisted value durable.
13
+ *
14
+ * Throws {@link BlockTagUnavailableError} — never falls back to `latest` — when the RPC can't answer
15
+ * the tag. See that error for why silent fallback is the wrong move.
16
+ */
17
+ export async function resolveBlockTag(client, tag) {
18
+ let block;
19
+ try {
20
+ block = await client.getBlock({ blockTag: tag });
21
+ }
22
+ catch (err) {
23
+ throw new BlockTagUnavailableError(tag, err);
24
+ }
25
+ if (block?.number == null)
26
+ throw new BlockTagUnavailableError(tag);
27
+ return block.number;
28
+ }
29
+ /** Resolve a scan boundary — a literal block, `'latest'`/undefined (chain head), or a supported
30
+ * {@link ReconstructBlockTag} — to a concrete inclusive block number before any scan starts. Shared
31
+ * by {@link reconstructProject} (which also accepts a literal number) and
32
+ * {@link reconstructIncremental} (which only ever resolves relative to head). */
33
+ async function resolveScanBoundary(client, toBlock) {
34
+ if (toBlock === undefined || toBlock === 'latest')
35
+ return client.getBlockNumber();
36
+ if (toBlock === 'safe' || toBlock === 'finalized')
37
+ return resolveBlockTag(client, toBlock);
38
+ return toBlock;
39
+ }
4
40
  /**
5
41
  * Rebuild a project's full state from the chain alone — the canonical replay.
6
42
  *
@@ -12,10 +48,14 @@ import { AUTH_OPTIONS, decodeTag, DELEGATE_REGISTRY_DEFAULT, extensionName, EXTE
12
48
  */
13
49
  export async function reconstructProject(client, opts) {
14
50
  const { address, fromBlock, factory } = opts;
15
- const toBlockNum = opts.toBlock === 'latest' || opts.toBlock === undefined ? await client.getBlockNumber() : opts.toBlock;
16
- const rawLogs = await getLogsAdaptive(client, address, fromBlock, toBlockNum);
51
+ const toBlockNum = await resolveScanBoundary(client, opts.toBlock);
52
+ const rawLogs = await getLogsAdaptive(client, address, fromBlock, toBlockNum, {
53
+ maxChunks: opts.maxChunks,
54
+ range: opts.getLogsRange,
55
+ onChunk: opts.onChunk,
56
+ });
17
57
  const events = decodedToSpineEvents(parseEventLogs({ abi: spineEventAbi, logs: rawLogs })).sort(byOrder);
18
- return assembleState(client, address, events, foldSpine(events), fromBlock, toBlockNum, factory);
58
+ return assembleState(client, address, events, foldSpine(events), fromBlock, toBlockNum, factory, !!opts.readUriDocuments);
19
59
  }
20
60
  /**
21
61
  * The same state, but resumed from a prior reconstruction's checkpoint — so a
@@ -29,15 +69,34 @@ export async function reconstructProject(client, opts) {
29
69
  export async function reconstructIncremental(client, prior, opts = {}) {
30
70
  const address = prior.address;
31
71
  const factory = opts.factory ?? prior.factory ?? undefined;
32
- const head = await client.getBlockNumber();
72
+ const head = await resolveScanBoundary(client, opts.toBlock);
33
73
  const priorTo = BigInt(prior.toBlock);
34
74
  const origin = BigInt(prior.deployBlock ?? prior.fromBlock);
35
75
  const resumeFrom = priorTo + 1n > origin ? priorTo + 1n : origin;
36
- let fresh = [];
76
+ let rawLogs = [];
37
77
  if (head >= resumeFrom) {
38
- const rawLogs = await getLogsAdaptive(client, address, resumeFrom, head);
39
- fresh = decodedToSpineEvents(parseEventLogs({ abi: spineEventAbi, logs: rawLogs }));
78
+ rawLogs = await getLogsAdaptive(client, address, resumeFrom, head, {
79
+ maxChunks: opts.maxChunks,
80
+ range: opts.getLogsRange,
81
+ onChunk: opts.onChunk,
82
+ });
40
83
  }
84
+ const toBlock = head >= priorTo ? head : priorTo;
85
+ return reconstructFromLogs(client, prior, rawLogs, { factory, readUriDocuments: opts.readUriDocuments, toBlock });
86
+ }
87
+ /**
88
+ * The fold / head-read half of {@link reconstructIncremental}, given logs the caller already
89
+ * fetched. A multi-address watch loop (`getLogs({address: Address[]})`) holds each touched
90
+ * project's delta; this consumes it so that loop is the only `eth_getLogs`. Folding stays here.
91
+ *
92
+ * `toBlock` is the inclusive scan head those logs cover — required even when `logs` is empty,
93
+ * so a quiet window still advances the checkpoint to the range the caller scanned, and so a
94
+ * caller that scanned a capped window (not chain head) cannot stamp `toBlock` past the last
95
+ * log they actually held.
96
+ */
97
+ export async function reconstructFromLogs(client, prior, logs, opts) {
98
+ const factory = opts.factory ?? prior.factory ?? undefined;
99
+ const fresh = decodedToSpineEvents(parseEventLogs({ abi: spineEventAbi, logs: logs }));
41
100
  // Merge the prior log with the freshly-fetched tail, de-duping by position so an
42
101
  // overlapping/duplicate re-run can't double-count, then fold the complete spine.
43
102
  const seen = new Set();
@@ -50,8 +109,13 @@ export async function reconstructIncremental(client, prior, opts = {}) {
50
109
  all.push(ev);
51
110
  }
52
111
  all.sort(byOrder);
53
- const toBlock = head >= priorTo ? head : priorTo;
54
- return assembleState(client, address, all, foldSpine(all), BigInt(prior.fromBlock), toBlock, factory);
112
+ // Chunk content has no log (too large); `ScriptUpdated` is the ping. A Transfer currently
113
+ // used to re-download every chunk. Keep the prior digest unless that ping is in `fresh`.
114
+ const scriptTouched = fresh.some((e) => e.name === 'ScriptUpdated');
115
+ const reuseScript = !scriptTouched && prior.script?.digest != null ? prior.script : undefined;
116
+ const priorTo = BigInt(prior.toBlock);
117
+ const toBlock = opts.toBlock >= priorTo ? opts.toBlock : priorTo;
118
+ return assembleState(client, prior.address, all, foldSpine(all), BigInt(prior.fromBlock), toBlock, factory, !!opts.readUriDocuments, reuseScript);
55
119
  }
56
120
  /** Turn decoded viem logs into the canonical, serializable {@link SpineEvent} list. */
57
121
  function decodedToSpineEvents(decoded) {
@@ -73,11 +137,60 @@ function decodedToSpineEvents(decoded) {
73
137
  function tokenIn(tokens, id) {
74
138
  let t = tokens.get(id);
75
139
  if (!t) {
76
- t = { tokenId: id, minted: false, owner: null, tokenURI: null, fields: [], lockedFields: [] };
140
+ t = { tokenId: id, lifecycle: 'unminted', owner: null, tokenURI: null, fields: [], lockedFields: [] };
77
141
  tokens.set(id, t);
78
142
  }
79
143
  return t;
80
144
  }
145
+ /**
146
+ * Fold one ERC-1155 transfer leg (a `TransferSingle`, or one element of a `TransferBatch`) into
147
+ * an id's per-id supply counter + holder balances. Mint = `from` the zero address (supply +=
148
+ * amount); burn = `to` the zero address (supply -= amount); a transfer between two live holders
149
+ * touches no supply, only balances — mirrors `AbxErc1155Base._afterTokenTransfer`'s own
150
+ * if/else-if exactly.
151
+ *
152
+ * {@link TokenState.lifecycle} is recomputed from the counter on every leg, never latched: an
153
+ * edition id can return to zero supply (a full burn) and mint again. `supply > 0` ⇒ `'live'`;
154
+ * back to zero having once been issued ⇒ `'burned'`; never issued ⇒ `'unminted'`. Same three words
155
+ * the 721 lane uses, which is the point — one vocabulary, whichever standard produced it.
156
+ */
157
+ function applyEditionTransfer(tokens, id, from, to, amount) {
158
+ const t = tokenIn(tokens, id);
159
+ if (isZeroAddr(from))
160
+ t.supply = (BigInt(t.supply ?? '0') + amount).toString();
161
+ else if (isZeroAddr(to))
162
+ t.supply = (BigInt(t.supply ?? '0') - amount).toString();
163
+ // Zero live copies — and on this standard that is NOT `'burned'`, which is reserved for the
164
+ // terminal 721 case. An edition id at zero can mint again, whether it was never minted or fully
165
+ // burned, so the two have no different consequence for anything downstream and share one word.
166
+ // The alternative shipped for about an hour: `'burned'` on both standards, which put every
167
+ // consumer one forgotten `contractType` branch away from answering `410 Gone` for an id the
168
+ // contract still resolves and can reissue. See {@link TokenState.lifecycle}.
169
+ //
170
+ // Note this deliberately does NOT use the fold's extra knowledge (it could distinguish
171
+ // never-minted from fully-burned; the head-read lane cannot). Spending it here would make the two
172
+ // lanes disagree on the same id under one field name — the sibling-drift class — to express a
173
+ // distinction with no consumer. The history stays in `supply`, `holders`, and the log.
174
+ t.lifecycle = BigInt(t.supply ?? '0') > 0n ? 'live' : 'no-live-copies';
175
+ adjustHolderBalance(t, from, -amount);
176
+ adjustHolderBalance(t, to, amount);
177
+ }
178
+ /**
179
+ * Adjust one token's per-holder balance by `delta`. The zero address is never a real holder
180
+ * (a mint's `from` / a burn's `to`) and is skipped. A balance that reaches zero is deleted
181
+ * rather than kept at `"0"` — {@link TokenState.holders} lists only CURRENT holders.
182
+ */
183
+ function adjustHolderBalance(t, addr, delta) {
184
+ if (isZeroAddr(addr) || delta === 0n)
185
+ return;
186
+ if (!t.holders)
187
+ t.holders = {};
188
+ const bal = BigInt(t.holders[addr] ?? '0') + delta;
189
+ if (bal <= 0n)
190
+ delete t.holders[addr];
191
+ else
192
+ t.holders[addr] = bal.toString();
193
+ }
81
194
  const isZeroAddr = (v) => {
82
195
  try {
83
196
  return BigInt(v ?? '0') === 0n;
@@ -99,6 +212,8 @@ export function foldSpine(events) {
99
212
  deployTx: null,
100
213
  owner: null,
101
214
  royalty: null,
215
+ maxRoyaltyBps: null,
216
+ burnable: null,
102
217
  extById: new Map(),
103
218
  tokens: new Map(),
104
219
  tokenFields: new Map(),
@@ -106,6 +221,7 @@ export function foldSpine(events) {
106
221
  collectionFields: new Map(),
107
222
  collectionLocked: new Set(),
108
223
  maxInvocations: null,
224
+ defaultMaxSupply: null,
109
225
  minter: null,
110
226
  paused: false,
111
227
  primaryPayee: null,
@@ -113,6 +229,7 @@ export function foldSpine(events) {
113
229
  contractParams: new Map(),
114
230
  paramSchemas: new Map(),
115
231
  paramHooks: null,
232
+ paramHooksFrozen: false,
116
233
  delegateRegistry: undefined,
117
234
  seedSource: null,
118
235
  scriptLocked: false,
@@ -142,16 +259,74 @@ export function foldSpine(events) {
142
259
  fold.royalty = isZeroAddr(receiver) && bps === 0 ? null : { receiver, bps };
143
260
  break;
144
261
  }
262
+ // The two collection-policy events. Both are Register 2 facts a buyer prices in, and both
263
+ // fold — being in `SPINE_EVENT_DOC` only supplies the `register`/`what` strings on the event
264
+ // record; it has never made anything land in state. The guard in `spine-fold-coverage.test.ts`
265
+ // fails if a `folds: 'state'` event has no
266
+ // case here.)
267
+ case 'MaxRoyaltyBpsUpdated':
268
+ // Last-writer-wins: the deploy-time ceiling, then any `reduceMaxRoyaltyBps`.
269
+ fold.maxRoyaltyBps = Number(a.maxBps ?? 0);
270
+ break;
271
+ case 'BurnConfigured':
272
+ // Emitted once at initialize and fixed thereafter; still last-writer-wins for the same
273
+ // reason every other fold is — a re-fold of an overlapping range must be idempotent.
274
+ fold.burnable = String(a.burnable) === 'true';
275
+ break;
145
276
  case 'OwnershipTransferred':
146
277
  fold.owner = a.newOwner;
147
278
  break;
148
279
  case 'Transfer': {
149
280
  const t = tokenIn(fold.tokens, String(a.tokenId ?? a.id ?? '0'));
150
- t.owner = a.to;
151
- if (isZeroAddr(a.from))
152
- t.minted = true; // mint signal
281
+ // `to == 0x0` is a BURN and nothing else: Solady's `transferFrom` reverts on a zero
282
+ // recipient, so the only path that emits it is `_burn` (the token's own `burn(id)`, gated
283
+ // on the collection having opted in). So the leg is unambiguous, and it is the whole
284
+ // reason this case cannot just write `a.to` into `owner` — that put the zero address in a
285
+ // field every consumer reads as a holder, and left `minted` latched `true` forever.
286
+ // `'burned'` is TERMINAL on this standard, and that is a fact about ABX's mint paths rather
287
+ // than about ERC-721: ids come from a monotonic `nextTokenId`, so no entrypoint can reissue
288
+ // a destroyed id. The branch below would still return such an id to `'live'` if a log ever
289
+ // showed a re-mint — the fold owes the log's meaning, not our mint policy's — but no ABX
290
+ // deployment can produce that log, which is what makes the word safe to act on irreversibly.
291
+ if (isZeroAddr(a.to)) {
292
+ t.lifecycle = 'burned';
293
+ t.owner = null;
294
+ }
295
+ else {
296
+ t.lifecycle = 'live';
297
+ t.owner = a.to;
298
+ }
153
299
  break;
154
300
  }
301
+ case 'TransferSingle': {
302
+ applyEditionTransfer(fold.tokens, String(a.id ?? '0'), a.from, a.to, BigInt(a.amount ?? '0'));
303
+ break;
304
+ }
305
+ case 'TransferBatch': {
306
+ const ids = parseStringArray(a.ids);
307
+ const amounts = parseStringArray(a.amounts);
308
+ const from = a.from;
309
+ const to = a.to;
310
+ for (let i = 0; i < ids.length; i++) {
311
+ applyEditionTransfer(fold.tokens, ids[i], from, to, BigInt(amounts[i] ?? '0'));
312
+ }
313
+ break;
314
+ }
315
+ case 'MaxSupplyUpdated': {
316
+ const t = tokenIn(fold.tokens, String(a.id ?? '0'));
317
+ t.maxSupply = String(a.cap ?? '0');
318
+ // An override latches: it is what makes a `'0'` cap "closed forever" rather than "open",
319
+ // and what puts the monotonic never-increase rule in force for this id.
320
+ t.maxSupplyOverridden = true;
321
+ break;
322
+ }
323
+ // The collection-wide default, announced once at `initialize`. Last-writer-wins for the same
324
+ // reason as every other Register 2 setter, even though only one is emitted today. Without
325
+ // this case the event reached the ABI and stopped there: an id inheriting a non-zero default
326
+ // has no `MaxSupplyUpdated` of its own, so the fold reported it uncapped.
327
+ case 'DefaultMaxSupplySet':
328
+ fold.defaultMaxSupply = String(a.cap ?? '0');
329
+ break;
155
330
  case 'TokenFieldSet': {
156
331
  const id = String(a.tokenId ?? '0');
157
332
  const field = decodeTag((a.field ?? '0x'));
@@ -242,6 +417,7 @@ export function foldSpine(events) {
242
417
  configureHook: z(a.configureHook),
243
418
  augmentHook: z(a.augmentHook),
244
419
  transferHook: z(a.transferHook),
420
+ locked: false, // resolved from `paramHooksFrozen` at assembly — order-independent
245
421
  };
246
422
  break;
247
423
  }
@@ -254,6 +430,11 @@ export function foldSpine(events) {
254
430
  case 'ScriptLocked':
255
431
  fold.scriptLocked = true;
256
432
  break;
433
+ // A one-way lock like ScriptLocked/DependenciesLocked, and the one a BUYER cares about: after
434
+ // this the transfer hook (which can veto a transfer or a mint) can never be re-pointed.
435
+ case 'ParamHooksFrozen':
436
+ fold.paramHooksFrozen = true;
437
+ break;
257
438
  case 'DependencyUpdated': {
258
439
  const idx = Number(a.index ?? 0);
259
440
  const raw = (a.ref ?? '0x');
@@ -285,7 +466,7 @@ export function foldSpine(events) {
285
466
  return fold;
286
467
  }
287
468
  /** Build the full {@link ProjectState} from a fold + provenance, then overlay head reads. */
288
- async function assembleState(client, address, events, fold, fromBlock, toBlock, factory) {
469
+ async function assembleState(client, address, events, fold, fromBlock, toBlock, factory, readUriDocuments = false, reuseScript) {
289
470
  // ensure a TokenState exists for every token referenced by on-chain fields/locks/params
290
471
  // (a deployed-but-unminted token can carry fields before any Transfer)
291
472
  for (const id of fold.tokenFields.keys())
@@ -307,7 +488,9 @@ async function assembleState(client, address, events, fold, fromBlock, toBlock,
307
488
  t.params = [...params.values()];
308
489
  }
309
490
  // A contract is a Series if it enabled the supply-cap extension; a Series that also
310
- // speaks Params is a code project (SeriesCode's composition).
491
+ // speaks Params is a code project (SeriesCode's composition). `maxInvocations` is shared,
492
+ // unchanged, with the ERC-1155 editions family (EditionImage/EditionCode compose it too), so
493
+ // `isSeries` alone doesn't distinguish 721 from 1155 — `hasEditionSupply` does that.
311
494
  const isSeries = fold.extById.has(EXTENSION_ID.maxInvocations.toLowerCase());
312
495
  const has = (id) => fold.extById.has(id.toLowerCase());
313
496
  const hasParams = has(EXTENSION_ID.params);
@@ -315,6 +498,22 @@ async function assembleState(client, address, events, fold, fromBlock, toBlock,
315
498
  const hasScript = has(EXTENSION_ID.onChainScript);
316
499
  const hasDependencies = has(EXTENSION_ID.dependencies);
317
500
  const hasSeedSource = has(EXTENSION_ID.seedSource);
501
+ // Edition Supply is the ONLY extension id new to the 1155 family — its presence is the
502
+ // discriminator between the 721 ladder (1of1/series/code) and the 1155 one
503
+ // (1of1-edition/edition/edition-code); every other extension id is shared, unchanged.
504
+ const hasEditionSupply = has(EXTENSION_ID.editionSupply);
505
+ // The collection-wide per-id default, behind the same gate as every other extension-scoped field
506
+ // (one gate, read twice: here and on `state.defaultMaxSupply` below).
507
+ const defaultCap = hasEditionSupply ? fold.defaultMaxSupply : null;
508
+ // An id with no `MaxSupplyUpdated` of its own inherits that default, exactly as `maxSupply(id)`
509
+ // does on chain. The fold used to stop at the per-id event, so every `--copies N` edition — which
510
+ // sets the cap at `initialize` and never calls `setMaxSupply` — reported *uncapped* here while the
511
+ // head read said N. See {@link TokenState.maxSupply}.
512
+ if (defaultCap !== null) {
513
+ for (const t of fold.tokens.values())
514
+ if (t.maxSupply === undefined)
515
+ t.maxSupply = defaultCap;
516
+ }
318
517
  const state = {
319
518
  address,
320
519
  chainId: client.chain?.id ?? 0,
@@ -333,17 +532,31 @@ async function assembleState(client, address, events, fold, fromBlock, toBlock,
333
532
  contractURIRenderer: null,
334
533
  contractURILocked: null,
335
534
  royalty: fold.royalty,
535
+ // Both tri-state and both folded, never defaulted: `null` says the spine did not state it (an
536
+ // implementation from before the opt-in), which is not the same fact as `false` / a 10% ceiling.
537
+ maxRoyaltyBps: fold.maxRoyaltyBps,
538
+ burnable: fold.burnable,
336
539
  collectionFields: [...fold.collectionFields.values()],
337
540
  lockedCollectionFields: [...fold.collectionLocked],
338
- contractType: fold.deployBlock !== null ? (isSeries ? (hasParams ? 'code' : 'series') : '1of1') : undefined,
541
+ contractType: fold.deployBlock === null
542
+ ? undefined
543
+ : hasEditionSupply
544
+ ? hasParams ? 'edition-code' : isSeries ? 'edition' : '1of1-edition'
545
+ : isSeries ? (hasParams ? 'code' : 'series') : '1of1',
339
546
  maxInvocations: fold.maxInvocations,
547
+ defaultMaxSupply: defaultCap,
340
548
  minter: fold.minter,
341
549
  paused: fold.paused,
342
550
  primaryPayee: fold.primaryPayee,
343
551
  contractParams: hasParams ? [...fold.contractParams.values()] : undefined,
344
552
  paramSchemas: hasConfigurableParams ? [...fold.paramSchemas.values()] : undefined,
345
553
  paramHooks: hasConfigurableParams
346
- ? (fold.paramHooks ?? { configureHook: null, augmentHook: null, transferHook: null })
554
+ ? {
555
+ ...(fold.paramHooks ?? { configureHook: null, augmentHook: null, transferHook: null }),
556
+ // Read off the separate fold flag rather than the HooksConfigured branch, so a freeze
557
+ // survives regardless of event order (a project may freeze hooks it never set).
558
+ locked: fold.paramHooksFrozen,
559
+ }
347
560
  : null,
348
561
  // absence of the event ⇒ the canonical delegate.xyz default (the emit-non-default rule)
349
562
  delegateRegistry: hasConfigurableParams
@@ -369,13 +582,16 @@ async function assembleState(client, address, events, fold, fromBlock, toBlock,
369
582
  reconstructedAt: new Date().toISOString(),
370
583
  rpcUrl: client.transport?.url,
371
584
  };
372
- await applyHeadReads(client, state, factory);
585
+ await applyHeadReads(client, state, factory, readUriDocuments, reuseScript);
373
586
  return state;
374
587
  }
375
- /** Default safety cap on chunk count; raise via ABX_GETLOGS_MAX_CHUNKS (the CLI's --yes does). */
588
+ /** Default safety cap on chunk count; raise via the `maxChunks` opt (or ABX_GETLOGS_MAX_CHUNKS —
589
+ * the CLI's --yes sets that). */
376
590
  const DEFAULT_MAX_CHUNKS = 3000;
377
- function maxChunks() {
378
- const v = Number(process.env.ABX_GETLOGS_MAX_CHUNKS);
591
+ function resolveMaxChunks(override) {
592
+ if (override !== undefined && override > 0)
593
+ return override;
594
+ const v = Number(readEnv('ABX_GETLOGS_MAX_CHUNKS'));
379
595
  return Number.isFinite(v) && v > 0 ? v : DEFAULT_MAX_CHUNKS;
380
596
  }
381
597
  /** Does this error look like an RPC rejecting the block range (vs a real failure)? */
@@ -384,7 +600,7 @@ export function isGetLogsRangeError(err) {
384
600
  return /block range|range should work|too large|out of bounds|response size|query returned more|results|limited|\d+\s*block|exceeds|max(imum)?\b/.test(m);
385
601
  }
386
602
  /** Thrown when a reconstruction would need more getLogs requests than the cap allows. */
387
- export class GetLogsScanTooLargeError extends Error {
603
+ export class GetLogsScanTooLargeError extends AbxSdkError {
388
604
  spanBlocks;
389
605
  window;
390
606
  estimatedRequests;
@@ -392,7 +608,7 @@ export class GetLogsScanTooLargeError extends Error {
392
608
  constructor(spanBlocks, window, estimatedRequests, cap) {
393
609
  super(`Reconstructing ${spanBlocks} blocks needs ~${estimatedRequests} eth_getLogs requests at this RPC's range limit ` +
394
610
  `(~${window} blocks/call) — over the ${cap}-request safety cap. A higher-range RPC is the real fix ` +
395
- `(set ABX_RPC_URL, then re-check with \`abx doctor\`). To chunk through it anyway, raise ABX_GETLOGS_MAX_CHUNKS (the CLI's --yes does this).`);
611
+ `(set ABX_RPC_URLS, then re-check with \`abx doctor\`). To chunk through it anyway, raise ABX_GETLOGS_MAX_CHUNKS (the CLI's --yes does this).`);
396
612
  this.spanBlocks = spanBlocks;
397
613
  this.window = window;
398
614
  this.estimatedRequests = estimatedRequests;
@@ -439,121 +655,284 @@ export async function discoverDeployBlock(client, address, toBlock) {
439
655
  }
440
656
  }
441
657
  /**
442
- * Pull a contract's logs over a block range, adapting to RPC range caps. Providers
443
- * cap `eth_getLogs` very differently and the numbers change, so we don't hard-code a
444
- * window: try the whole range, and when the RPC rejects it, halve the window and
445
- * sweep — adopting the smaller window for the rest of the run. So reconstruction is a
446
- * single request on a generous RPC, still completes on a constrained one, and (for a
447
- * 1/1, whose events cluster at the deploy block) stays cheap. `ABX_GETLOGS_RANGE`
448
- * overrides the starting window.
658
+ * Ordinary RPC tiers cap how many addresses one `eth_getLogs` filter may carry.
659
+ * This is an RPC-tier opinion, not a protocol constant — callers with a dedicated
660
+ * node can raise it; `0` disables splitting (one filter for the whole list).
661
+ */
662
+ export const DEFAULT_GETLOGS_ADDRESS_BATCH = 1000;
663
+ /**
664
+ * Pull logs over a block range, adapting to RPC range caps — and, when `address` is
665
+ * a list, splitting that list so a registered-set watch scan stays one code path for
666
+ * self-host and hosted. Providers cap `eth_getLogs` very differently and the numbers
667
+ * change, so we don't hard-code a window: try the whole range, and when the RPC
668
+ * rejects it, halve the window and sweep. `ABX_GETLOGS_RANGE` overrides the starting
669
+ * window. Address-list chunking is a separate cap (`addressBatch`); 5000-block
670
+ * per-tick windows belong to the caller (the watcher), not here.
449
671
  *
450
672
  * Once the working window is known we estimate the whole job *before* grinding it: if
451
673
  * it would exceed the cap, we stop early (only the probe calls spent) and throw
452
674
  * {@link GetLogsScanTooLargeError} so the caller can offer the real fix (a higher-range
453
675
  * RPC) or an explicit opt-in to chunk through it.
454
676
  */
455
- async function getLogsAdaptive(client, address, fromBlock, toBlock) {
677
+ export async function getLogsAdaptive(client, address, fromBlock, toBlock, opts = {}) {
678
+ const addrs = (Array.isArray(address) ? address : [address]);
679
+ if (addrs.length === 0)
680
+ return [];
681
+ const batchSize = opts.addressBatch === 0 ? addrs.length : Math.max(1, opts.addressBatch ?? DEFAULT_GETLOGS_ADDRESS_BATCH);
682
+ const batches = [];
683
+ for (let i = 0; i < addrs.length; i += batchSize)
684
+ batches.push(addrs.slice(i, i + batchSize));
456
685
  const span = toBlock - fromBlock + 1n;
457
- const envRange = process.env.ABX_GETLOGS_RANGE;
458
- let window = envRange && BigInt(envRange) > 0n ? BigInt(envRange) : span;
686
+ const envRange = readEnv('ABX_GETLOGS_RANGE');
687
+ const startWindow = opts.range && opts.range > 0n ? opts.range : envRange && BigInt(envRange) > 0n ? BigInt(envRange) : span;
459
688
  const out = [];
460
- let start = fromBlock;
461
689
  let estimated = false;
462
- while (start <= toBlock) {
463
- const end = start + window - 1n < toBlock ? start + window - 1n : toBlock;
464
- let logs;
690
+ for (const [batchIndex, batch] of batches.entries()) {
691
+ const filter = batch.length === 1 ? batch[0] : batch;
692
+ let window = startWindow;
693
+ let start = fromBlock;
694
+ while (start <= toBlock) {
695
+ const end = start + window - 1n < toBlock ? start + window - 1n : toBlock;
696
+ let logs;
697
+ try {
698
+ logs = (await client.getLogs({ address: filter, fromBlock: start, toBlock: end }));
699
+ }
700
+ catch (err) {
701
+ if (isGetLogsRangeError(err) && window > 1n) {
702
+ window = window > 2n ? window / 2n : 1n; // RPC rejected this window — shrink and retry
703
+ continue;
704
+ }
705
+ throw err; // genuine failure, or already at one block per call
706
+ }
707
+ out.push(...logs);
708
+ start = end + 1n;
709
+ const scannedBlocks = start - fromBlock;
710
+ opts.onChunk?.({
711
+ scanned: BigInt(batchIndex) * span + scannedBlocks,
712
+ span: span * BigInt(batches.length),
713
+ scannedBlocks,
714
+ blockSpan: span,
715
+ addressBatch: batchIndex,
716
+ addressBatches: batches.length,
717
+ });
718
+ // Window now proven. Estimate the whole job once (range chunks × address batches)
719
+ // and bail *before* grinding if absurd.
720
+ if (!estimated) {
721
+ estimated = true;
722
+ const remaining = toBlock - start + 1n;
723
+ const perBatch = (remaining > 0n ? Number(remaining / window) + 1 : 0) + 1; // +1 for the chunk just done
724
+ const need = perBatch * batches.length;
725
+ const cap = resolveMaxChunks(opts.maxChunks);
726
+ if (need > cap)
727
+ throw new GetLogsScanTooLargeError(span, window, need, cap);
728
+ }
729
+ }
730
+ }
731
+ return out;
732
+ }
733
+ /**
734
+ * How many unbounded reads (`tokenURI`, `contractURI`, a script chunk) ride one aggregate. Small on
735
+ * purpose: on the on-chain lane each of these can return tens of KB *assembled at read*, and the
736
+ * cap that matters is the node's per-`eth_call` budget for the aggregate, not the leg count. A
737
+ * whole-chunk failure still recovers via the per-leg retry below, so this only sets how often that
738
+ * costs an extra round trip.
739
+ */
740
+ const HEAVY_READ_CHUNK = 2;
741
+ /**
742
+ * Multicall in chunks, tolerating per-leg failure — and, when a whole chunk comes back failed,
743
+ * retrying its legs individually before believing it.
744
+ *
745
+ * The retry is the point, and it exists to tell apart two failure modes that an aggregate throwing
746
+ * cannot distinguish on its own:
747
+ *
748
+ * 1. **Over budget.** `allowFailure: true` reports per-leg failures, but a multicall is ONE
749
+ * `eth_call`: if the aggregate exceeds the node's gas or response cap, the *whole* batch fails
750
+ * and every leg in it reports failure — including legs that answer fine on their own. Batching a
751
+ * cheap read beside an expensive one therefore turns the expensive one's cost into the cheap
752
+ * one's failure. Re-asking each leg alone, with no aggregate overhead, recovers this: the calls
753
+ * were always fine, only their batching was too big.
754
+ * 2. **No multicall3 on this chain.** If the aggregate fails because there's no multicall3
755
+ * deployment to call at all, *every* multicall — including a single-leg one — fails the identical
756
+ * way. Re-asking through `client.multicall` again, at any width, reproduces the same failure, so
757
+ * the retry must go through a call shape that has no multicall3 dependency: `client.readContract`.
758
+ * Skipping this means a chain lacking multicall3 reads back as "the contract answered nothing" —
759
+ * indistinguishable from a real revert — which is exactly the silent-blank-identity bug this
760
+ * retry was reported to have.
761
+ *
762
+ * So the fallback always resolves each leg with `readContract`, never with a smaller `multicall`.
763
+ * `readContract` throws on revert where multicall's `allowFailure` returns a `status: 'failure'`
764
+ * result instead, so a per-leg throw here is caught and mapped back to `null` — a genuine revert on
765
+ * one leg must still read as "that leg has no answer," matching the aggregate's own contract,
766
+ * whichever call shape actually answered it.
767
+ */
768
+ export async function multicallChunked(client, contracts, chunkSize) {
769
+ const out = new Array(contracts.length).fill(null);
770
+ const runAggregate = async (legs) => {
465
771
  try {
466
- logs = (await client.getLogs({ address, fromBlock: start, toBlock: end }));
772
+ const res = (await client.multicall({ contracts: legs, allowFailure: true }));
773
+ return res.map((r) => (r.status === 'success' ? r.result : null));
467
774
  }
468
- catch (err) {
469
- if (isGetLogsRangeError(err) && window > 1n) {
470
- window = window > 2n ? window / 2n : 1n; // RPC rejected this window — shrink and retry
471
- continue;
472
- }
473
- throw err; // genuine failure, or already at one block per call
775
+ catch {
776
+ return legs.map(() => null); // the aggregate itself failed
777
+ }
778
+ };
779
+ const readOne = async (leg) => {
780
+ try {
781
+ return await client.readContract(leg);
474
782
  }
475
- out.push(...logs);
476
- start = end + 1n;
477
- // Window now proven. Estimate the whole job once and bail *before* grinding if absurd.
478
- if (!estimated) {
479
- estimated = true;
480
- const remaining = toBlock - start + 1n;
481
- const need = (remaining > 0n ? Number(remaining / window) + 1 : 0) + 1; // +1 for the chunk just done
482
- const cap = maxChunks();
483
- if (need > cap)
484
- throw new GetLogsScanTooLargeError(span, window, need, cap);
783
+ catch {
784
+ return null; // a genuine revert — or the fallback itself failing — reads the same as unreadable
485
785
  }
786
+ };
787
+ for (let i = 0; i < contracts.length; i += chunkSize) {
788
+ const legs = contracts.slice(i, i + chunkSize);
789
+ let vals = await runAggregate(legs);
790
+ // Every leg failed: the aggregate itself is the likely cause (too costly, or no multicall3 to
791
+ // call at all), not the individual reads. Re-ask one at a time via `readContract` — never via
792
+ // `multicall` again, even for a lone leg — so the batch degrades to what genuinely can't be
793
+ // read, whether the original cause was cost or an absent multicall3. (A chunk can land at width
794
+ // 1 too, e.g. the last chunk of an odd-length list — that leg is exactly as exposed to "no
795
+ // multicall3 here" as any other, so it gets the same fallback, not a pass.)
796
+ if (vals.every((v) => v === null)) {
797
+ vals = await Promise.all(legs.map(readOne));
798
+ }
799
+ for (let k = 0; k < vals.length; k++)
800
+ out[i + k] = vals[k];
486
801
  }
487
802
  return out;
488
803
  }
489
- /** Multicall the read surface to resolve current string values + trust facts. */
490
- async function applyHeadReads(client, state, factory) {
804
+ /**
805
+ * Head-read the values the spine only pings (name/symbol/contractURI/tokenURI) plus the trust and
806
+ * URI-lane facts, and fold them into state.
807
+ *
808
+ * **Reads are split by cost class, and that split is load-bearing.** These used to be one multicall,
809
+ * which was correct for an off-chain project (every return is a short string) and quietly wrong for
810
+ * the flagship on-chain lane: there, `tokenURI(id)` *assembles the whole metadata document
811
+ * on-chain*, tens of KB per token. Four such legs in one aggregate already exceed a public node's
812
+ * `eth_call` budget, so the batch failed whole — and took `name`, `symbol`, `contractURI`,
813
+ * `isAbxClone`, and, worst, `tokenURIRenderer` down with it. A resolver then believed a
814
+ * fully-on-chain 32-token collection had no name, no canonical proof, and **was not in the
815
+ * on-chain-URI lane at all.** Reproduced on Base Sepolia at `0xB844…5E56`: 4 legs → 0/8 succeeded,
816
+ * while `name()` answered `"ABXdoku"` on its own.
817
+ *
818
+ * So: the cheap fixed reads — the ones that decide the project's identity and lane — go in one
819
+ * batch of their own and can never be collateral damage.
820
+ *
821
+ * **The unbounded pair (`contractURI` + one `tokenURI` per token) is diagnostic and opt-in
822
+ * (`readUriDocuments`, default `false`) — skipped ENTIRELY unless requested, not just best-effort.**
823
+ * Two independent reasons, not one:
824
+ *
825
+ * 1. **Size.** A real 32-token fully-on-chain project measured at 13.4 MB of serialized
826
+ * `ProjectState`, of which 99.8% was `tokens[].tokenURI` — ~313 KB/token, re-fetched on *every*
827
+ * reconstruct, full or incremental, to fold a single new mint. `contractURI` is the same shape at
828
+ * collection scope (~1 KB there, but proportionally as dominant once `tokenURI` is out of the
829
+ * picture).
830
+ * 2. **No settled value.** A renderer that composes these on demand can change them with **no log at
831
+ * all** — the refresh trigger here is `getLogs`, and there is no event for "the composed document
832
+ * changed." A value with no settled state and no change signal cannot be cached correctly at any
833
+ * refresh cadence: too eager and every tick looks different (a real incident); too lazy and the
834
+ * cached copy silently rots. The only correct move is not to cache it — read it live when someone
835
+ * actually wants to display it (`nothing served depends on it`, the one exception being a fresh
836
+ * `abx demo` read-back, which asks for it explicitly).
837
+ */
838
+ async function applyHeadReads(client, state, factory, readUriDocuments = false, reuseScript) {
491
839
  const token = { address: state.address, abi: oneOfOneImageAbi };
492
840
  const tokenIds = state.tokens.map((t) => t.tokenId);
841
+ // The edition family (ERC-1155) shares every cheap fixed-return name with the 721 superset ABI
842
+ // used below (name/symbol/owner/tokenURIRenderer/tokenURILocked/contractURIRenderer/
843
+ // contractURILocked/contractURI — same signatures on both standards), EXCEPT the per-token URI
844
+ // getter: editions expose `uri(id)`, not `tokenURI(id)` (ERC-1155 has no `tokenURI`). Encode
845
+ // that one leg against an edition ABI instead; the field name on `TokenState` stays `tokenURI`
846
+ // either way (it's the resolved metadata pointer regardless of which standard produced it).
847
+ const isEdition = state.contractType === '1of1-edition' || state.contractType === 'edition' || state.contractType === 'edition-code';
848
+ const uriAbi = isEdition ? oneOfOneEditionAbi : oneOfOneImageAbi;
849
+ const uriFn = isEdition ? 'uri' : 'tokenURI';
850
+ // ── cheap, fixed-size returns: identity, trust, the URI lane, the script count ──
493
851
  // Mixed-ABI multicall — type it loosely; results are validated per-call below.
494
- const contracts = [
852
+ const cheap = [
495
853
  { ...token, functionName: 'name' },
496
854
  { ...token, functionName: 'symbol' },
497
855
  { ...token, functionName: 'owner' },
498
- { ...token, functionName: 'contractURI' },
499
- ...tokenIds.map((id) => ({ ...token, functionName: 'tokenURI', args: [BigInt(id)] })),
856
+ { ...token, functionName: 'tokenURIRenderer' },
857
+ { ...token, functionName: 'tokenURILocked' },
858
+ { ...token, functionName: 'contractURIRenderer' },
859
+ { ...token, functionName: 'contractURILocked' },
500
860
  ];
861
+ const factoryIdx = cheap.length;
501
862
  if (factory) {
502
- contracts.push({ address: factory, abi: oneOfOneImageFactoryAbi, functionName: 'isAbxClone', args: [state.address] }, { address: factory, abi: oneOfOneImageFactoryAbi, functionName: 'implementation' });
863
+ cheap.push({ address: factory, abi: oneOfOneImageFactoryAbi, functionName: 'isAbxClone', args: [state.address] }, { address: factory, abi: oneOfOneImageFactoryAbi, functionName: 'implementation' });
503
864
  }
504
- // URI resolution config (the off-chain↔on-chain toggle + freeze)
505
- const cfgBase = contracts.length;
506
- contracts.push({ ...token, functionName: 'tokenURIRenderer' }, { ...token, functionName: 'tokenURILocked' }, { ...token, functionName: 'contractURIRenderer' }, { ...token, functionName: 'contractURILocked' });
507
865
  // On-chain script: chunk content is too large to log, so the count is a head read.
508
- const scriptIdx = contracts.length;
509
- if (state.script) {
510
- contracts.push({ address: state.address, abi: seriesCodeAbi, functionName: 'scriptChunkCount' });
866
+ // Skip when the caller already holds a digest and `fresh` had no `ScriptUpdated` —
867
+ // a Transfer must not re-download every chunk.
868
+ const keepPriorScript = !!(state.script && reuseScript?.digest != null);
869
+ const scriptIdx = cheap.length;
870
+ if (state.script && !keepPriorScript) {
871
+ cheap.push({ address: state.address, abi: seriesCodeAbi, functionName: 'scriptChunkCount' });
511
872
  }
512
- const results = (await client.multicall({
513
- contracts: contracts,
514
- allowFailure: true,
515
- }));
516
- const val = (i) => (results[i]?.status === 'success' ? results[i].result : null);
873
+ // ── unbounded returns: each one may assemble a whole document on-chain ──
874
+ // `contractURI` first so a huge per-token tokenURI can never cost us the collection's own URI.
875
+ // `contractURI` itself is standard-neutral (ContractURI is reused as-is on the edition family),
876
+ // so only the per-token leg switches ABI/function name (see `uriAbi`/`uriFn` above).
877
+ //
878
+ // Skipped ENTIRELY when `readUriDocuments` is false (the default) — not requested at reduced
879
+ // width, not requested-then-discarded: no `eth_call` for either leg. `state.contractURI` and
880
+ // every `tok.tokenURI` stay `null`, same as an off-chain project with nothing to read.
881
+ const heavy = readUriDocuments
882
+ ? [
883
+ { ...token, functionName: 'contractURI' },
884
+ ...tokenIds.map((id) => ({ address: state.address, abi: uriAbi, functionName: uriFn, args: [BigInt(id)] })),
885
+ ]
886
+ : [];
887
+ const [cheapVals, heavyVals] = await Promise.all([
888
+ multicallChunked(client, cheap, cheap.length), // one batch: all short returns
889
+ readUriDocuments ? multicallChunked(client, heavy, HEAVY_READ_CHUNK) : Promise.resolve([]),
890
+ ]);
891
+ const val = (i) => cheapVals[i] ?? null;
517
892
  state.name = val(0) ?? null;
518
893
  state.symbol = val(1) ?? null;
519
894
  state.owner = val(2) ?? state.owner;
520
- state.contractURI = val(3) ?? null;
521
- tokenIds.forEach((id, k) => {
522
- const tok = state.tokens.find((t) => t.tokenId === id);
523
- if (tok)
524
- tok.tokenURI = val(4 + k) ?? null;
525
- });
895
+ const nonZero = (a) => a && a !== '0x0000000000000000000000000000000000000000' ? a : null;
896
+ state.tokenURIRenderer = nonZero(val(3));
897
+ state.tokenURILocked = val(4) ?? null;
898
+ state.contractURIRenderer = nonZero(val(5));
899
+ state.contractURILocked = val(6) ?? null;
526
900
  if (factory) {
527
- const base = 4 + tokenIds.length;
528
- state.isCanonical = val(base) ?? null;
529
- state.implementation = val(base + 1) ?? null;
901
+ state.isCanonical = cheapVals[factoryIdx] ?? null;
902
+ state.implementation = cheapVals[factoryIdx + 1] ?? null;
530
903
  }
531
- const nonZero = (a) => a && a !== '0x0000000000000000000000000000000000000000' ? a : null;
532
- state.tokenURIRenderer = nonZero(val(cfgBase));
533
- state.tokenURILocked = val(cfgBase + 1) ?? null;
534
- state.contractURIRenderer = nonZero(val(cfgBase + 2));
535
- state.contractURILocked = val(cfgBase + 3) ?? null;
536
- if (state.script) {
537
- const count = val(scriptIdx);
904
+ // `tok.tokenURI` is already `null` from `tokenIn` — only overwrite it when we actually asked.
905
+ if (readUriDocuments) {
906
+ state.contractURI = heavyVals[0] ?? null;
907
+ tokenIds.forEach((id, k) => {
908
+ const tok = state.tokens.find((t) => t.tokenId === id);
909
+ if (tok)
910
+ tok.tokenURI = heavyVals[1 + k] ?? null;
911
+ });
912
+ }
913
+ if (state.script && keepPriorScript) {
914
+ state.script.chunkCount = reuseScript.chunkCount;
915
+ state.script.digest = reuseScript.digest;
916
+ }
917
+ else if (state.script) {
918
+ const count = cheapVals[scriptIdx] ?? null;
538
919
  state.script.chunkCount = count === null ? null : Number(count);
539
920
  // digest = keccak over the concatenated chunks — the content half of effect inputsHash
540
- // values. Chunks are few and bounded (~24 KB each), so this is a cheap second multicall.
921
+ // values. Chunks are bounded (~24 KB each) but there can be many, and the same aggregate cap
922
+ // applies, so they read through the chunked path too.
541
923
  const n = state.script.chunkCount ?? 0;
542
924
  if (n > 0) {
543
- const chunkReads = (await client.multicall({
544
- contracts: Array.from({ length: n }, (_, i) => ({
545
- address: state.address,
546
- abi: seriesCodeAbi,
547
- functionName: 'scriptChunk',
548
- args: [BigInt(i)],
549
- })),
550
- allowFailure: true,
551
- }));
925
+ const chunkReads = await multicallChunked(client, Array.from({ length: n }, (_, i) => ({
926
+ address: state.address,
927
+ abi: seriesCodeAbi,
928
+ functionName: 'scriptChunk',
929
+ args: [BigInt(i)],
930
+ })), HEAVY_READ_CHUNK);
552
931
  const parts = [];
553
932
  for (const r of chunkReads) {
554
- if (r.status !== 'success')
933
+ if (r === null)
555
934
  return; // partial read — leave digest unset rather than wrong
556
- parts.push(r.result);
935
+ parts.push(r);
557
936
  }
558
937
  state.script.digest = keccak256(concatHex(parts));
559
938
  }