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