@artblocks/abx-cli 0.1.0-alpha.40 → 0.1.0-alpha.41
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 +145 -0
- package/assets/renderer-scaffold/README.md +12 -0
- package/assets/renderer-scaffold/foundry.toml +4 -0
- package/assets/renderer-scaffold/script/Preview.s.sol +99 -0
- package/dist/capabilities.d.ts +9 -4
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +13 -4
- package/dist/capabilities.js.map +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +731 -65
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/maintenance.d.ts +4 -0
- package/dist/commands/maintenance.d.ts.map +1 -0
- package/dist/commands/maintenance.js +114 -0
- package/dist/commands/maintenance.js.map +1 -0
- package/dist/commands/project.d.ts +175 -3
- package/dist/commands/project.d.ts.map +1 -1
- package/dist/commands/project.js +333 -27
- package/dist/commands/project.js.map +1 -1
- package/dist/commands/reads.d.ts +21 -0
- package/dist/commands/reads.d.ts.map +1 -1
- package/dist/commands/reads.js +171 -4
- package/dist/commands/reads.js.map +1 -1
- package/dist/commands/scaffold.d.ts.map +1 -1
- package/dist/commands/scaffold.js +5 -0
- package/dist/commands/scaffold.js.map +1 -1
- package/dist/commands/service.d.ts.map +1 -1
- package/dist/commands/service.js +7 -0
- package/dist/commands/service.js.map +1 -1
- package/dist/commands/storage.d.ts.map +1 -1
- package/dist/commands/storage.js +32 -3
- package/dist/commands/storage.js.map +1 -1
- package/dist/commands/submit-app.d.ts +43 -0
- package/dist/commands/submit-app.d.ts.map +1 -1
- package/dist/commands/submit-app.js +115 -6
- package/dist/commands/submit-app.js.map +1 -1
- package/dist/config.d.ts +16 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +73 -7
- package/dist/config.js.map +1 -1
- package/dist/deploy-plan.d.ts +150 -0
- package/dist/deploy-plan.d.ts.map +1 -0
- package/dist/deploy-plan.js +31 -0
- package/dist/deploy-plan.js.map +1 -0
- package/dist/flag-allowlists.d.ts.map +1 -1
- package/dist/flag-allowlists.js +5 -1
- package/dist/flag-allowlists.js.map +1 -1
- package/dist/flags.d.ts +6 -0
- package/dist/flags.d.ts.map +1 -1
- package/dist/flags.js +14 -0
- package/dist/flags.js.map +1 -1
- package/dist/jsonout.d.ts.map +1 -1
- package/dist/jsonout.js +11 -0
- package/dist/jsonout.js.map +1 -1
- package/dist/main.js +46 -7
- package/dist/main.js.map +1 -1
- package/dist/ownerops.d.ts +69 -0
- package/dist/ownerops.d.ts.map +1 -1
- package/dist/ownerops.js +221 -17
- package/dist/ownerops.js.map +1 -1
- package/dist/script-chunks.d.ts +1 -1
- package/dist/script-chunks.d.ts.map +1 -1
- package/dist/script-chunks.js +2 -2
- package/dist/script-chunks.js.map +1 -1
- package/package.json +6 -6
- package/skill/SKILL.md +6 -4
- package/skill/reference/code.md +17 -8
- package/skill/reference/deploy.md +9 -2
- package/skill/reference/diagnose.md +15 -2
- package/skill/reference/hosting.md +26 -0
- package/skill/reference/operate.md +43 -4
- package/skill/reference/setup.md +14 -0
package/dist/commands/project.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { resolve as resolvePath } from 'node:path';
|
|
9
|
-
import { verifyProvenance, AUTH_OPTIONS, CREATOR_TOKEN_INTERFACE_ID, PARAM_TYPES, hasOnChainUriLane, isCodeProject, isCurrentFactory, isCurrentOneOfOneEditionFactory, makePublicClient, makeWalletClient, normalizeAttributes, onChainUriReport, parseTraitPairs, predictClone, readParamHooks, readParamHooksLocked, readParamSchema, readParamSchemaKeys, readSetParamKeys, resolveChain, resolveSeriesCodeFactory, saltFor, saltGuard, sleep, readCollectionPolicy, rpcEnvVar, tryReadContract, } from '@artblocks/abx-sdk';
|
|
9
|
+
import { verifyProvenance, AUTH_OPTIONS, CREATOR_TOKEN_INTERFACE_ID, PARAM_TYPES, hasOnChainUriLane, isCodeProject, isCurrentFactory, isCurrentOneOfOneEditionFactory, makePublicClient, makeWalletClient, normalizeAttributes, onChainUriReport, parseTraitPairs, predictClone, readParamHooks, readParamHooksLocked, readParamSchema, readParamSchemaKeys, readSetParamKeys, resolveChain, resolveSeriesCodeFactory, saltFor, saltGuard, sleep, readCollectionPolicy, rpcEnvVar, tryReadContract, encodeTag, METADATA_FIELD, METADATA_REPRESENTATION, fieldOf, isCurrentRenderer, isCurrentGenerator, } from '@artblocks/abx-sdk';
|
|
10
10
|
import { resolveBackend } from '@artblocks/abx-storage';
|
|
11
11
|
import { currentRenderArtifact, verifyProject } from '@artblocks/abx-token-api';
|
|
12
12
|
import { zeroAddress } from 'viem';
|
|
13
13
|
import { CHAIN, activeBackendId, factoryAddress, localIndexer, remoteLocators, seriesFactoryAddress, oneOfOneEditionFactoryAddress, editionFactoryAddress, editionCodeFactoryAddress, storageOptions, } from '../config.js';
|
|
14
|
-
import { isDryRun, parseSaltFlag } from '../flags.js';
|
|
14
|
+
import { isDryRun, parseBlockTagFlag, parseSaltFlag } from '../flags.js';
|
|
15
15
|
import { jsonSafe, withJson } from '../jsonout.js';
|
|
16
16
|
import { allowLargeScan, bold, c, detectCanonicalFactory, dim, g, info, ok, registerAndIndexLocally, resolveScanFloor, warn } from '../output.js';
|
|
17
17
|
import { parseCopies } from './deploy.js';
|
|
@@ -239,14 +239,20 @@ export async function cmdIndex(address, flags) {
|
|
|
239
239
|
}
|
|
240
240
|
allowLargeScan(flags);
|
|
241
241
|
const full = !!flags.full; // force a full replay from the deploy block (durability proof)
|
|
242
|
+
// Stop the scan at a REORG-SAFE boundary instead of the head. `latest` (the default) can include
|
|
243
|
+
// blocks that a reorg later replaces, which would persist a watermark for history that no longer
|
|
244
|
+
// exists; `safe`/`finalized` trade freshness for a boundary the chain won't take back. Resolved to
|
|
245
|
+
// a concrete block number before scanning (see resolveBlockTag) — a stored watermark is always a
|
|
246
|
+
// number, never a tag.
|
|
247
|
+
const blockTag = parseBlockTagFlag(flags['to-block']);
|
|
242
248
|
const indexer = localIndexer();
|
|
243
249
|
const line = (state, elapsedMs, mode) => ok(`${state.name ?? state.address}: ${state.eventCount} events in ${elapsedMs}ms ${dim(`(${mode})`)}`);
|
|
244
250
|
if (address) {
|
|
245
|
-
const { state, elapsedMs, mode } = await indexer.reindex(address, { full });
|
|
251
|
+
const { state, elapsedMs, mode } = await indexer.reindex(address, { full, blockTag });
|
|
246
252
|
line(state, elapsedMs, mode);
|
|
247
253
|
return;
|
|
248
254
|
}
|
|
249
|
-
const results = await indexer.reindexAll({ full });
|
|
255
|
+
const results = await indexer.reindexAll({ full, blockTag });
|
|
250
256
|
if (results.length === 0) {
|
|
251
257
|
console.log(dim('No registered projects. Deploy one with `abx deploy`.'));
|
|
252
258
|
return;
|
|
@@ -266,13 +272,79 @@ export async function cmdVerify(address, flags) {
|
|
|
266
272
|
}
|
|
267
273
|
allowLargeScan(flags);
|
|
268
274
|
const remote = remoteFlag(flags);
|
|
269
|
-
if (remote)
|
|
270
|
-
return cmdVerifyRemote(address, remote);
|
|
271
275
|
// `--json` matters most here of all the read commands: verify already exits non-zero on a byte
|
|
272
276
|
// mismatch, so it is the one command a CI job would gate on. The payload's `ok` is the same
|
|
273
277
|
// boolean the exit code carries; callers never need to parse prose.
|
|
278
|
+
//
|
|
279
|
+
// The remote lane used to route around `withJson` entirely (`return cmdVerifyRemote(address,
|
|
280
|
+
// remote)`), so `abx verify <addr> --remote <r> --json` printed the same human prose as without
|
|
281
|
+
// the flag and emitted NOTHING on stdout — the one lane of this command a CI job is most likely
|
|
282
|
+
// to run (checking what a HOSTED resolver serves) had no machine-readable output at all. Both
|
|
283
|
+
// lanes now go through the same `withJson` wrapper and emit the same payload shape.
|
|
284
|
+
if (remote)
|
|
285
|
+
return withJson(flags, async (emit) => cmdVerifyRemote(address, remote, emit));
|
|
274
286
|
return withJson(flags, async (emit) => cmdVerifyBody(address, flags, emit));
|
|
275
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Pure by design — every caller (`cmdVerifyBody`'s local lane and `cmdVerifyRemote`'s hosted lane)
|
|
290
|
+
* feeds it facts it already gathered rather than each re-deriving its own verdict, so the two
|
|
291
|
+
* lanes' JSON payloads read the same word for the same situation.
|
|
292
|
+
*
|
|
293
|
+
* `isCode` selects which signal decides the verdict: a code project's render presence
|
|
294
|
+
* (`minted`/`present`), or — for everything else — whether a byte-check actually ran
|
|
295
|
+
* (`anyCheck`) versus a locator this run could not independently re-hash (`unrecomputablePointers`).
|
|
296
|
+
* The two signals are never combined into one number on purpose: "3 of 5 renders present" and "2
|
|
297
|
+
* locator commitments unconfirmed" are different facts about different projects, and mixing them
|
|
298
|
+
* would produce a verdict that answers neither question honestly.
|
|
299
|
+
*/
|
|
300
|
+
export function computeAvailability(opts) {
|
|
301
|
+
if (opts.isCode) {
|
|
302
|
+
if (opts.minted === 0)
|
|
303
|
+
return { status: 'unknown', note: 'no tokens minted yet — nothing to render' };
|
|
304
|
+
if (opts.present === opts.minted)
|
|
305
|
+
return { status: 'available', note: `${opts.present}/${opts.minted} minted token(s) have a real render` };
|
|
306
|
+
if (opts.present === 0)
|
|
307
|
+
return { status: 'unavailable', note: `0/${opts.minted} minted token(s) have a real render — every one is still a placeholder` };
|
|
308
|
+
return { status: 'partial', note: `${opts.present}/${opts.minted} minted token(s) have a real render` };
|
|
309
|
+
}
|
|
310
|
+
if (opts.anyCheck) {
|
|
311
|
+
return { status: 'available', note: 'served bytes were fetched and compared against the on-chain commitment (see `ok` for whether they matched)' };
|
|
312
|
+
}
|
|
313
|
+
if (opts.unrecomputablePointers > 0) {
|
|
314
|
+
return {
|
|
315
|
+
status: 'unknown',
|
|
316
|
+
note: `${opts.unrecomputablePointers} locator commitment(s) (ipfs/arweave/url) exist but were not independently re-fetched by this command`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return { status: 'available', note: 'no content commitments on this project — nothing to serve' };
|
|
320
|
+
}
|
|
321
|
+
/** Locator representations `verifyProject` never emits a check for (only `keccak256`/`sha256`
|
|
322
|
+
* fields get one) — a real commitment this command cannot re-hash from the field alone: ipfs/
|
|
323
|
+
* arweave carry a content-addressed locator this run doesn't fetch, and url/url-template carry
|
|
324
|
+
* no hash at all. Reported as a `verified: null` entry, same tri-state slot a hash check uses,
|
|
325
|
+
* so a caller sees ONE consistent shape rather than silence for these tokens. */
|
|
326
|
+
const POINTER_REPRESENTATIONS = new Set([
|
|
327
|
+
METADATA_REPRESENTATION.ipfs,
|
|
328
|
+
METADATA_REPRESENTATION.arweave,
|
|
329
|
+
METADATA_REPRESENTATION.url,
|
|
330
|
+
METADATA_REPRESENTATION.urlTemplate,
|
|
331
|
+
]);
|
|
332
|
+
/**
|
|
333
|
+
* Does this token's `image` field carry a locator commitment `verifyProject` never produced a
|
|
334
|
+
* check for? Exported and pure (a plain `fields` array in, a verdict out — no chain, no indexer)
|
|
335
|
+
* so #112's three-way distinction is unit-testable directly, rather than only reachable through a
|
|
336
|
+
* full `cmdVerifyBody` run against real chain state.
|
|
337
|
+
*
|
|
338
|
+
* Returns the representation name to report (`ipfs`/`arweave`/`url`/`url-template`) when the image
|
|
339
|
+
* field is a bare locator, else `null` — including when there's no `image` field at all, which is
|
|
340
|
+
* "no commitment", a different fact from "an unrecomputable one".
|
|
341
|
+
*/
|
|
342
|
+
export function pointerOnlyImageCheck(fields) {
|
|
343
|
+
const image = fieldOf(fields, METADATA_FIELD.image);
|
|
344
|
+
if (image && POINTER_REPRESENTATIONS.has(image.representation))
|
|
345
|
+
return image.representation;
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
276
348
|
export async function cmdVerifyBody(address, flags, emit) {
|
|
277
349
|
const indexer = localIndexer();
|
|
278
350
|
let state = indexer.getProject(address);
|
|
@@ -296,6 +368,9 @@ export async function cmdVerifyBody(address, flags, emit) {
|
|
|
296
368
|
renders: null,
|
|
297
369
|
onChainUri: null,
|
|
298
370
|
ok: true,
|
|
371
|
+
// A SIBLING of `ok`, not a rename — see `computeAvailability`'s doc comment for why the two
|
|
372
|
+
// must never collapse into one bit. Filled in below, once the facts it's computed from exist.
|
|
373
|
+
availability: null,
|
|
299
374
|
};
|
|
300
375
|
emit(verifyReport);
|
|
301
376
|
console.log(bold(`\n verify ${state.name ?? address}`));
|
|
@@ -309,6 +384,12 @@ export async function cmdVerifyBody(address, flags, emit) {
|
|
|
309
384
|
const tokens = result.tokens ?? [];
|
|
310
385
|
let anyCheck = false;
|
|
311
386
|
let allGood = true;
|
|
387
|
+
// Locator commitments (ipfs/arweave/url/url-template `image` fields) `verifyProject` never
|
|
388
|
+
// produces a check for — see `POINTER_REPRESENTATIONS`'s doc comment. Counted SEPARATELY from
|
|
389
|
+
// `anyCheck`/`allGood` on purpose: a pointer this command didn't re-fetch is not an integrity
|
|
390
|
+
// verdict either way, so it must never move `contentIntegrity`/`ok` — it feeds `availability`
|
|
391
|
+
// instead, below.
|
|
392
|
+
let unrecomputablePointers = 0;
|
|
312
393
|
for (const t of tokens) {
|
|
313
394
|
const tok = state.tokens.find((s) => s.tokenId === t.tokenId);
|
|
314
395
|
const life = tok?.lifecycle === 'live'
|
|
@@ -339,8 +420,27 @@ export async function cmdVerifyBody(address, flags, emit) {
|
|
|
339
420
|
else
|
|
340
421
|
info(`${ch.kind} — pointer-only, not locally recomputable`);
|
|
341
422
|
}
|
|
423
|
+
// `verifyProject` only ever emits a check for a keccak256/sha256-committed `image` field, so a
|
|
424
|
+
// token whose image is a bare LOCATOR (ipfs/arweave/url/url-template) gets `t.checks === []` —
|
|
425
|
+
// indistinguishable, before this, from "no commitment at all". That was the real gap #112
|
|
426
|
+
// named: a real, independently-lockable commitment silently read as nothing. Surface it as the
|
|
427
|
+
// same tri-state slot a hash check uses (`verified: null`), so a caller sees one consistent
|
|
428
|
+
// shape instead of two different kinds of silence.
|
|
429
|
+
if (t.checks.length === 0 && tok) {
|
|
430
|
+
const pointerKind = pointerOnlyImageCheck(tok.fields);
|
|
431
|
+
if (pointerKind) {
|
|
432
|
+
unrecomputablePointers++;
|
|
433
|
+
verifyReport.contentChecks.push({
|
|
434
|
+
tokenId: t.tokenId,
|
|
435
|
+
lifecycle: tok.lifecycle,
|
|
436
|
+
kind: pointerKind,
|
|
437
|
+
verified: null,
|
|
438
|
+
});
|
|
439
|
+
info(`${pointerKind} — pointer-only, not locally recomputable (no outbound fetch from this command)`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
342
442
|
}
|
|
343
|
-
if (!anyCheck && !isCodeProject(state))
|
|
443
|
+
if (!anyCheck && unrecomputablePointers === 0 && !isCodeProject(state))
|
|
344
444
|
info('no content commitments on this project');
|
|
345
445
|
verifyReport.contentIntegrity = !anyCheck ? 'no-commitments' : allGood ? 'ok' : 'mismatch';
|
|
346
446
|
verifyReport.ok = !anyCheck || allGood;
|
|
@@ -351,6 +451,9 @@ export async function cmdVerifyBody(address, flags, emit) {
|
|
|
351
451
|
// placeholder? Same seam the /…/image route serves from (currentRenderArtifact @ current
|
|
352
452
|
// inputsHash) — so this answers "did the render land?" with one command, right after token 0.
|
|
353
453
|
let renderGap = false;
|
|
454
|
+
// Hoisted above the `isCodeProject` branch: `availability` (computed right after it, whether or
|
|
455
|
+
// not this project turns out to be a code project) needs the render counts that branch produces.
|
|
456
|
+
let codeRenders = null;
|
|
354
457
|
if (isCodeProject(state)) {
|
|
355
458
|
const client = makePublicClient({ chainKey: CHAIN });
|
|
356
459
|
const storageForRender = resolveBackend(storageOptions());
|
|
@@ -439,6 +542,7 @@ export async function cmdVerifyBody(address, flags, emit) {
|
|
|
439
542
|
// there, not here, so `missing` is a false negative for a hosted drop — hence the scope.
|
|
440
543
|
scope: "this node's store",
|
|
441
544
|
};
|
|
545
|
+
codeRenders = { minted: minted.length, present };
|
|
442
546
|
emit(verifyReport);
|
|
443
547
|
if (present)
|
|
444
548
|
ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
|
|
@@ -450,6 +554,16 @@ export async function cmdVerifyBody(address, flags, emit) {
|
|
|
450
554
|
console.log(` ${dim('else render them:')} ${bold(`abx render ${address}`)} ${dim('(once) ·')} ${bold('abx effects')} ${dim('(continuous)')}`);
|
|
451
555
|
}
|
|
452
556
|
}
|
|
557
|
+
// The availability verdict — computed here, after both the byte-integrity pass and the render
|
|
558
|
+
// pass above have run, from facts they already gathered (no new reads). See `computeAvailability`.
|
|
559
|
+
verifyReport.availability = jsonSafe(computeAvailability({
|
|
560
|
+
isCode: isCodeProject(state),
|
|
561
|
+
minted: codeRenders?.minted ?? 0,
|
|
562
|
+
present: codeRenders?.present ?? 0,
|
|
563
|
+
anyCheck,
|
|
564
|
+
unrecomputablePointers,
|
|
565
|
+
}));
|
|
566
|
+
emit(verifyReport);
|
|
453
567
|
// The on-chain URI lane (a non-zero tokenURIRenderer, or an animation field pointing at the
|
|
454
568
|
// generator): read the generator's honesty surface — onChainStatus (branch · chain-complete ·
|
|
455
569
|
// unresolved refs · URL budget) — then decode tokenURI straight from the contract and report
|
|
@@ -465,9 +579,23 @@ export async function cmdVerifyBody(address, flags, emit) {
|
|
|
465
579
|
try {
|
|
466
580
|
const report = await onChainUriReport(client, state, flags.generator);
|
|
467
581
|
const { status, probe } = report;
|
|
582
|
+
// #128: the factory-generation half of "is this pinned to a superseded deployment" already
|
|
583
|
+
// ships (ANCHOR_GENERATIONS + verifyCanonical, in the `canonical`/`abxGeneration` line above);
|
|
584
|
+
// this is the SINGLETON half — these two pointers were already read and printed here, just
|
|
585
|
+
// never compared. Binary by design (see `isCurrentGenerator`'s doc): current / not-current,
|
|
586
|
+
// never "superseded-but-known", because neither manifest keeps a prior address to recognize a
|
|
587
|
+
// mismatch against. A `false` here is NOT a defect report — an older-but-working renderer or
|
|
588
|
+
// generator still serves; it just isn't what a fresh deploy would get today.
|
|
589
|
+
const generatorCurrent = isCurrentGenerator(state.chainId, report.generator);
|
|
590
|
+
const rendererCurrent = state.tokenURIRenderer ? await isCurrentRenderer(client, state.tokenURIRenderer) : null;
|
|
468
591
|
verifyReport.onChainUri = jsonSafe({
|
|
469
592
|
generator: report.generator,
|
|
593
|
+
generatorCurrent, // true | false | null (no canonical generator recorded for this chain)
|
|
470
594
|
tokenURIRenderer: state.tokenURIRenderer ?? null,
|
|
595
|
+
// null ⇒ no renderer set at all (nothing to compare) — distinct from `false` (set, but not
|
|
596
|
+
// spec v11). See `isCurrentRenderer`: a read failure ALSO reports `false`, same as a stale
|
|
597
|
+
// renderer — the underlying probe cannot tell those apart, so neither can this field.
|
|
598
|
+
tokenURIRendererCurrent: rendererCurrent,
|
|
471
599
|
branch: status.branchName,
|
|
472
600
|
chainComplete: !!status.chainComplete,
|
|
473
601
|
unresolvedRefs: [...status.unresolvedRefs],
|
|
@@ -478,6 +606,16 @@ export async function cmdVerifyBody(address, flags, emit) {
|
|
|
478
606
|
});
|
|
479
607
|
emit(verifyReport);
|
|
480
608
|
info(`on-chain URI lane: generator ${report.generator}${state.tokenURIRenderer ? ` · tokenURI renderer ${state.tokenURIRenderer}` : ' · tokenURIRenderer NOT set (animation field only — tokenURI still resolves off-chain)'}`);
|
|
609
|
+
if (generatorCurrent === true)
|
|
610
|
+
ok(`generator is the CURRENT canonical one for ${CHAIN}`);
|
|
611
|
+
else if (generatorCurrent === false) {
|
|
612
|
+
info(`generator is NOT the current canonical one for ${CHAIN} ${dim('(a prior deployment this project is pinned to, or a fully custom field renderer — it still works; repoint with')} ${bold(`abx set-field ${address} --field animation_url --representation renderer --value <generator> --collection`)}${dim(')')}`);
|
|
613
|
+
} // null: no canonical generator recorded for this chain at all — nothing to compare against, so say nothing rather than a false "NOT current".
|
|
614
|
+
if (rendererCurrent === true)
|
|
615
|
+
ok(`tokenURI renderer is CURRENT (spec v11)`);
|
|
616
|
+
else if (rendererCurrent === false) {
|
|
617
|
+
info(`tokenURI renderer is NOT current (spec v11) ${dim(`— an older renderer still serves fine; it just doesn't have v11's capabilities (see the renderer's changelog in deployments.ts). Repoint with`)} ${bold(`abx set-renderer ${address} --collection`)}${dim(' (and without --collection for the per-token pointer)')}`);
|
|
618
|
+
}
|
|
481
619
|
if (status.branchName === 'template') {
|
|
482
620
|
ok(`generator branch: template — the document assembles from the on-chain script chunks`);
|
|
483
621
|
if (status.chainComplete) {
|
|
@@ -569,9 +707,25 @@ export async function cmdVerifyBody(address, flags, emit) {
|
|
|
569
707
|
// `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
|
|
570
708
|
// `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
|
|
571
709
|
// sees it — the truthful "did the thumbnail land?" check after a remote render.
|
|
572
|
-
export async function cmdVerifyRemote(address, remote) {
|
|
710
|
+
export async function cmdVerifyRemote(address, remote, emit) {
|
|
573
711
|
const base = remote.url.replace(/\/$/, '');
|
|
574
712
|
const chainId = resolveChain(CHAIN).id;
|
|
713
|
+
// Same shape, same field names as the local lane's `verifyReport` (cmdVerifyBody) wherever the
|
|
714
|
+
// two lanes answer the same question, so a caller doesn't need a second parser for `--remote`.
|
|
715
|
+
// `--remote --json` used to emit NOTHING at all — this command routed around `withJson` entirely
|
|
716
|
+
// (see `cmdVerify`) — so every field here is new, not a rename of something that already worked.
|
|
717
|
+
const verifyReport = {
|
|
718
|
+
address,
|
|
719
|
+
remote: base,
|
|
720
|
+
name: null,
|
|
721
|
+
watching: null,
|
|
722
|
+
contentChecks: [],
|
|
723
|
+
contentIntegrity: 'no-commitments',
|
|
724
|
+
renders: null,
|
|
725
|
+
availability: null,
|
|
726
|
+
ok: true,
|
|
727
|
+
};
|
|
728
|
+
emit(verifyReport);
|
|
575
729
|
console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
|
|
576
730
|
// A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
|
|
577
731
|
// bare `fetch failed` says neither. Match `abx status --remote` so both commands describe the same
|
|
@@ -587,6 +741,7 @@ export async function cmdVerifyRemote(address, remote) {
|
|
|
587
741
|
throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
|
|
588
742
|
}
|
|
589
743
|
const state = (await stateRes.json());
|
|
744
|
+
verifyReport.name = state.name ?? null;
|
|
590
745
|
info(`serving as "${state.name ?? address}"`);
|
|
591
746
|
// Is the resolver actively WATCHING the chain? Prove it from /api/watch (the meta the watcher
|
|
592
747
|
// stamps each tick) so a hosted operator who can't tail the log still sees liveness — and catches
|
|
@@ -594,6 +749,8 @@ export async function cmdVerifyRemote(address, remote) {
|
|
|
594
749
|
const watch = (await fetch(`${base}/api/watch`)
|
|
595
750
|
.then((r) => (r.ok ? r.json() : null))
|
|
596
751
|
.catch(() => null));
|
|
752
|
+
verifyReport.watching = watch?.watching ?? null;
|
|
753
|
+
emit(verifyReport);
|
|
597
754
|
if (watch?.watching) {
|
|
598
755
|
const heads = Object.entries(watch.chains).map(([ck, s]) => `${ck} @ block ${s.head}`).join(', ');
|
|
599
756
|
const ageS = watch.pollAt ? Math.round((Date.now() - Date.parse(watch.pollAt)) / 1000) : null;
|
|
@@ -610,6 +767,8 @@ export async function cmdVerifyRemote(address, remote) {
|
|
|
610
767
|
const minted = (state.tokens ?? []).filter((t) => t.lifecycle === 'live');
|
|
611
768
|
if (minted.length === 0) {
|
|
612
769
|
info('no tokens minted yet — mint token #0, then re-run.');
|
|
770
|
+
verifyReport.availability = jsonSafe({ status: 'unknown', note: 'no tokens minted yet' });
|
|
771
|
+
emit(verifyReport);
|
|
613
772
|
console.log('');
|
|
614
773
|
return;
|
|
615
774
|
}
|
|
@@ -647,27 +806,48 @@ export async function cmdVerifyRemote(address, remote) {
|
|
|
647
806
|
else
|
|
648
807
|
ok(`renders: ${summary}`);
|
|
649
808
|
}
|
|
650
|
-
|
|
809
|
+
verifyReport.renders = jsonSafe({ minted: minted.length, upToDate, stale, rendering, failed, tokens: report.tokens });
|
|
810
|
+
verifyReport.availability = jsonSafe(computeAvailability({ isCode: report.tokens.length > 0, minted: minted.length, present: upToDate, anyCheck: false, unrecomputablePointers: 0 }));
|
|
811
|
+
emit(verifyReport);
|
|
812
|
+
const integrity = await reportRemoteByteIntegrity(address, remote, base);
|
|
813
|
+
verifyReport.contentIntegrity = integrity.status;
|
|
814
|
+
verifyReport.contentChecks = integrity.checks;
|
|
815
|
+
verifyReport.ok = integrity.status !== 'mismatch';
|
|
816
|
+
emit(verifyReport);
|
|
651
817
|
return;
|
|
652
818
|
}
|
|
653
819
|
let gap = false;
|
|
820
|
+
let present = 0;
|
|
821
|
+
const missing = [];
|
|
654
822
|
for (const t of minted) {
|
|
655
823
|
const img = await fetch(`${base}/t/${chainId}/${address}/${t.tokenId}/image`, { redirect: 'manual' });
|
|
656
824
|
const loc = img.headers.get('location');
|
|
657
825
|
const ct = img.headers.get('content-type') ?? '';
|
|
658
|
-
if (img.status >= 300 && img.status < 400 && loc)
|
|
826
|
+
if (img.status >= 300 && img.status < 400 && loc) {
|
|
827
|
+
present++;
|
|
659
828
|
ok(`token #${t.tokenId} image: real render — resolver 302s to ${loc}`);
|
|
660
|
-
|
|
829
|
+
}
|
|
830
|
+
else if (img.status === 200 && !/svg/i.test(ct)) {
|
|
831
|
+
present++;
|
|
661
832
|
ok(`token #${t.tokenId} image: real render present (${ct})`);
|
|
833
|
+
}
|
|
662
834
|
else {
|
|
663
835
|
gap = true;
|
|
836
|
+
missing.push(t.tokenId);
|
|
664
837
|
console.log(` ${c.orange}⚠${c.reset} token #${t.tokenId} image: PLACEHOLDER (${ct || 'svg'}) — run \`abx render ${address} --remote ${base}\`, or stand up the effects runner`);
|
|
665
838
|
}
|
|
666
839
|
}
|
|
667
840
|
console.log(gap
|
|
668
841
|
? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
|
|
669
842
|
: ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
|
|
670
|
-
|
|
843
|
+
verifyReport.renders = jsonSafe({ minted: minted.length, present, missing, scope: 'the resolver (raw image probe — no /effects route)' });
|
|
844
|
+
verifyReport.availability = jsonSafe(computeAvailability({ isCode: true, minted: minted.length, present, anyCheck: false, unrecomputablePointers: 0 }));
|
|
845
|
+
emit(verifyReport);
|
|
846
|
+
const integrity = await reportRemoteByteIntegrity(address, remote, base);
|
|
847
|
+
verifyReport.contentIntegrity = integrity.status;
|
|
848
|
+
verifyReport.contentChecks = integrity.checks;
|
|
849
|
+
verifyReport.ok = integrity.status !== 'mismatch';
|
|
850
|
+
emit(verifyReport);
|
|
671
851
|
}
|
|
672
852
|
/**
|
|
673
853
|
* The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
|
|
@@ -683,14 +863,14 @@ export async function cmdVerifyRemote(address, remote) {
|
|
|
683
863
|
export async function reportRemoteByteIntegrity(address, remote, base) {
|
|
684
864
|
if (!remote.token) {
|
|
685
865
|
warn(`byte integrity NOT checked — that check is credentialed on the service. Set ${remote.tokenVar} (or pass --remote-token) and re-run, or run ${bold(`abx verify ${address}`)} against a node that holds the bytes.`);
|
|
686
|
-
return;
|
|
866
|
+
return { status: 'not-checked', checks: [] };
|
|
687
867
|
}
|
|
688
868
|
let report;
|
|
689
869
|
try {
|
|
690
870
|
const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
|
|
691
871
|
if (res.status === 401 || res.status === 403) {
|
|
692
872
|
warn(`byte integrity NOT checked — ${base} rejected ${tokenSourceLabel(remote)} for its verify route (the read plane served fine, so this is a credential/scoping issue, not a broken project).`);
|
|
693
|
-
return;
|
|
873
|
+
return { status: 'not-checked', checks: [] };
|
|
694
874
|
}
|
|
695
875
|
if (!res.ok) {
|
|
696
876
|
// Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
|
|
@@ -698,23 +878,24 @@ export async function reportRemoteByteIntegrity(address, remote, base) {
|
|
|
698
878
|
warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
|
|
699
879
|
`Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
|
|
700
880
|
`Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
|
|
701
|
-
return;
|
|
881
|
+
return { status: 'not-checked', checks: [] };
|
|
702
882
|
}
|
|
703
883
|
report = (await res.json());
|
|
704
884
|
}
|
|
705
885
|
catch {
|
|
706
886
|
warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
|
|
707
|
-
return;
|
|
887
|
+
return { status: 'not-checked', checks: [] };
|
|
708
888
|
}
|
|
709
889
|
const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
|
|
710
890
|
if (checked.length === 0) {
|
|
711
891
|
info(`bytes ${dim('no on-chain byte commitment to check (this project commits no image hash)')}`);
|
|
712
|
-
return;
|
|
892
|
+
return { status: 'no-commitments', checks: [] };
|
|
713
893
|
}
|
|
894
|
+
const flatChecks = checked.flatMap((t) => t.checks.map((k) => ({ tokenId: t.tokenId, kind: k.kind, verified: k.verified })));
|
|
714
895
|
const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
|
|
715
896
|
if (bad.length === 0) {
|
|
716
897
|
ok(`bytes: ${checked.length} token(s) hash-match their on-chain commitment ${dim('(what the service serves IS what the chain vouches for)')}`);
|
|
717
|
-
return;
|
|
898
|
+
return { status: 'ok', checks: flatChecks };
|
|
718
899
|
}
|
|
719
900
|
const spec = remote.name?.toLowerCase() ?? base;
|
|
720
901
|
console.log(` ${c.red}✗${c.reset} ${bold('BYTE MISMATCH')} on token(s) ${bad.map((t) => `#${t.tokenId}`).join(', ')} — ${base} does NOT serve bytes that hash to the on-chain commitment ` +
|
|
@@ -722,6 +903,7 @@ export async function reportRemoteByteIntegrity(address, remote, base) {
|
|
|
722
903
|
info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
|
|
723
904
|
info(`the bytes only exist on THIS machine (local fs custody) → a hosted resolver can never serve them: ${bold('abx storage upload')} to a durable backend + re-point the field, or serve the project from a node that holds them.`);
|
|
724
905
|
process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
|
|
906
|
+
return { status: 'mismatch', checks: flatChecks };
|
|
725
907
|
}
|
|
726
908
|
// ── state: a one-glance operational snapshot of a deployed contract, read straight from chain ──
|
|
727
909
|
// Read-only (no tx, no local index). Series-only getters revert on a 1/1, so each read is defensive
|
|
@@ -761,6 +943,16 @@ export const STATE_ABI = [
|
|
|
761
943
|
// Shown because it is the one setting an owner can re-point mid-sale that changes what a LATER
|
|
762
944
|
// buyer receives; `SeedSourceSet` puts it on the spine, and this makes it readable in one command.
|
|
763
945
|
{ type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
946
|
+
// The irreversible locks #122 asked `state` to report in full (previously only `paramHooksLocked`
|
|
947
|
+
// showed here). `tokenURILocked`/`contractURILocked`/`contractFieldLocked` exist on every ABX
|
|
948
|
+
// token (baseline URI + On-Chain Metadata surface); `scriptLocked`/`dependenciesLocked` exist only
|
|
949
|
+
// on the two code twins — see `readCollectionLocks`, which gates those two on `isCode` rather than
|
|
950
|
+
// trusting a revert to mean "unknown" for a contract that never had the surface at all.
|
|
951
|
+
{ type: 'function', name: 'tokenURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
952
|
+
{ type: 'function', name: 'contractURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
953
|
+
{ type: 'function', name: 'scriptLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
954
|
+
{ type: 'function', name: 'dependenciesLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
955
|
+
{ type: 'function', name: 'contractFieldLocked', stateMutability: 'view', inputs: [{ type: 'bytes32' }], outputs: [{ type: 'bool' }] },
|
|
764
956
|
];
|
|
765
957
|
/** The edition twin of {@link STATE_ABI} — `owner`/`maxInvocations`/`paused`/`minter`/
|
|
766
958
|
* `primaryPayee`/`tokenURIRenderer`/`royaltyInfo` are shared, unchanged, function names (see
|
|
@@ -785,6 +977,13 @@ export const EDITION_STATE_ABI = [
|
|
|
785
977
|
// those two getters, shared with every other integrator) — deliberately not re-declared here.
|
|
786
978
|
// Seed Source extension — EditionCode composes it; the image editions don't. See STATE_ABI's note.
|
|
787
979
|
{ type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
980
|
+
// The same lock surface as STATE_ABI's edit — see the note there. Identical getter names on both
|
|
981
|
+
// standards (verified against the generated ABI: every edition twin exposes the same five).
|
|
982
|
+
{ type: 'function', name: 'tokenURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
983
|
+
{ type: 'function', name: 'contractURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
984
|
+
{ type: 'function', name: 'scriptLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
985
|
+
{ type: 'function', name: 'dependenciesLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
986
|
+
{ type: 'function', name: 'contractFieldLocked', stateMutability: 'view', inputs: [{ type: 'bytes32' }], outputs: [{ type: 'bool' }] },
|
|
788
987
|
];
|
|
789
988
|
/**
|
|
790
989
|
* The royalty ceiling line — and the headroom, which is the part a creator has not been told.
|
|
@@ -876,6 +1075,101 @@ function warnNotCanonical(address) {
|
|
|
876
1075
|
`(a bespoke contract, a superseded factory), nothing here is broken — just make sure whoever owns the ` +
|
|
877
1076
|
`project knows they chose it.`);
|
|
878
1077
|
}
|
|
1078
|
+
/**
|
|
1079
|
+
* The bounded field-lock scan #122's triage settled on: every field this node can enumerate is one
|
|
1080
|
+
* of the protocol registry's `METADATA_FIELD` keys (site/content/docs/protocol/metadata.mdx), read
|
|
1081
|
+
* with `contractFieldLocked`. A creator's own custom field (`stems`, `palette`, …) is a real,
|
|
1082
|
+
* independently-lockable key — but this node can only learn its NAME from the spine's
|
|
1083
|
+
* `ContractFieldLocked` event, which needs an indexer, not a bare head read. So this reports "none
|
|
1084
|
+
* of the STANDARD fields are locked", never "no fields are locked" — the caveat has to travel with
|
|
1085
|
+
* the data, not live only in a comment a caller never sees.
|
|
1086
|
+
*/
|
|
1087
|
+
async function readFieldLocks(publicClient, address, abi) {
|
|
1088
|
+
return Promise.all(Object.values(METADATA_FIELD).map(async (field) => ({
|
|
1089
|
+
field,
|
|
1090
|
+
locked: (await tryReadContract(publicClient, { address, abi, functionName: 'contractFieldLocked', args: [encodeTag(field)] })) ?? null,
|
|
1091
|
+
})));
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Read every applicable irreversible lock for one collection: token/contract URI config, the
|
|
1095
|
+
* bounded standard field set, and — for a code project — the script and dependency freezes.
|
|
1096
|
+
* `paramHooks.locked` is deliberately NOT read here: `cmdStateBody`/`cmdStateEditionBody` already
|
|
1097
|
+
* read it (`readParamHooksLocked`) as part of the existing param-hooks readout, and re-reading it
|
|
1098
|
+
* would cost a second `eth_call` for a fact already on hand — see the callers, which pass it into
|
|
1099
|
+
* {@link printLocks} instead.
|
|
1100
|
+
*
|
|
1101
|
+
* `isCode` gates `scriptLocked`/`dependenciesLocked`: those getters exist ONLY on the two code
|
|
1102
|
+
* twins (SeriesCode/EditionCode), so asking a plain image/Series contract for them isn't a failed
|
|
1103
|
+
* read to report as "unknown" — it's a surface that was never there, reported as `null` (not
|
|
1104
|
+
* applicable) via the OUTER object rather than the boolean inside it.
|
|
1105
|
+
*/
|
|
1106
|
+
export async function readCollectionLocks(publicClient, address, abi, isCode) {
|
|
1107
|
+
const read = (fn) => tryReadContract(publicClient, { address, abi, functionName: fn });
|
|
1108
|
+
const [tokenURI, contractURI, fields, script, dependencies] = await Promise.all([
|
|
1109
|
+
read('tokenURILocked'),
|
|
1110
|
+
read('contractURILocked'),
|
|
1111
|
+
readFieldLocks(publicClient, address, abi),
|
|
1112
|
+
isCode ? read('scriptLocked') : Promise.resolve(undefined),
|
|
1113
|
+
isCode ? read('dependenciesLocked') : Promise.resolve(undefined),
|
|
1114
|
+
]);
|
|
1115
|
+
return {
|
|
1116
|
+
tokenURI: tokenURI ?? null,
|
|
1117
|
+
contractURI: contractURI ?? null,
|
|
1118
|
+
fields,
|
|
1119
|
+
script: isCode ? { locked: script ?? null } : null,
|
|
1120
|
+
dependencies: isCode ? { locked: dependencies ?? null } : null,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
/** One lock's human verdict — the same three words everywhere this prints, so a reader learns the
|
|
1124
|
+
* vocabulary once. */
|
|
1125
|
+
function lockVerdict(locked) {
|
|
1126
|
+
return locked === true
|
|
1127
|
+
? `${c.green}frozen${c.reset}`
|
|
1128
|
+
: locked === false
|
|
1129
|
+
? `${c.orange}unlocked${c.reset}`
|
|
1130
|
+
: dim('unknown (node refused the check)');
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Print + emit the "Locks" section shared by both `state` bodies (721 and edition) — the one
|
|
1134
|
+
* coherent summary #122 asked for, in place of a lone `paramHooks.locked` line. Every lock is its
|
|
1135
|
+
* own independent switch (freezing one does nothing to any other), which is easy to miss when each
|
|
1136
|
+
* only ever appeared alone — so the header says so explicitly, and every line names the exact
|
|
1137
|
+
* command that freezes THAT lock and nothing else.
|
|
1138
|
+
*/
|
|
1139
|
+
function printLocks(address, locks, hasParamHooks, paramHooksLock, payload, emit) {
|
|
1140
|
+
const lockedFieldNames = locks.fields.filter((f) => f.locked === true).map((f) => f.field);
|
|
1141
|
+
const unknownFieldCount = locks.fields.filter((f) => f.locked === null).length;
|
|
1142
|
+
payload.locks = jsonSafe({
|
|
1143
|
+
tokenURI: locks.tokenURI,
|
|
1144
|
+
contractURI: locks.contractURI,
|
|
1145
|
+
script: locks.script,
|
|
1146
|
+
dependencies: locks.dependencies,
|
|
1147
|
+
// `null` ⇒ no ConfigurableParams surface on this contract (not a code project) — matching the
|
|
1148
|
+
// `script`/`dependencies` null-means-N/A convention rather than reusing LockRead's "unknown".
|
|
1149
|
+
paramHooks: hasParamHooks ? (paramHooksLock ?? null) : null,
|
|
1150
|
+
fields: {
|
|
1151
|
+
// The bounded scan, keyed by name — automation gets the same three-way distinction the human
|
|
1152
|
+
// line does (true/false/null), never a collapsed "none locked".
|
|
1153
|
+
checked: locks.fields,
|
|
1154
|
+
note: "checks only the standard METADATA_FIELD set (site/content/docs/protocol/metadata.mdx); a project's own custom field keys are real and independently lockable but are NOT enumerated here — that needs an indexer over ContractFieldLocked events.",
|
|
1155
|
+
},
|
|
1156
|
+
});
|
|
1157
|
+
emit(payload);
|
|
1158
|
+
console.log(`\n ${bold('Locks')} ${dim('each is INDEPENDENT — freezing one does nothing to the others')}`);
|
|
1159
|
+
info(`tokenURI ${lockVerdict(locks.tokenURI)} ${dim(`\`abx lock-uri ${address}\` freezes the pointer + renderer forever`)}`);
|
|
1160
|
+
info(`contractURI ${lockVerdict(locks.contractURI)} ${dim(`\`abx lock-uri ${address} --collection\` freezes the pointer + renderer forever`)}`);
|
|
1161
|
+
if (locks.script)
|
|
1162
|
+
info(`script ${lockVerdict(locks.script.locked)} ${dim(`\`abx lock-script ${address}\` freezes the program bytes forever`)}`);
|
|
1163
|
+
if (locks.dependencies)
|
|
1164
|
+
info(`dependencies ${lockVerdict(locks.dependencies.locked)} ${dim(`\`abx lock-dependencies ${address}\` freezes the library set forever`)}`);
|
|
1165
|
+
if (hasParamHooks)
|
|
1166
|
+
info(`param hooks ${lockVerdict(paramHooksLock ?? null)} ${dim(`\`abx lock-param-hooks ${address}\` freezes all three hook addresses forever (detail above)`)}`);
|
|
1167
|
+
info(lockedFieldNames.length
|
|
1168
|
+
? `fields ${c.green}locked${c.reset}: ${lockedFieldNames.join(', ')}`
|
|
1169
|
+
: `fields ${dim('none of the standard fields are locked')}`);
|
|
1170
|
+
info(dim(` checked the standard METADATA_FIELD set only (${locks.fields.length} keys${unknownFieldCount ? `, ${unknownFieldCount} unreadable` : ''}) — a custom field key is real and lockable but not enumerable without an indexer. ` +
|
|
1171
|
+
`\`abx lock-field ${address} --field <name> [--collection | --token 0]\` freezes one.`));
|
|
1172
|
+
}
|
|
879
1173
|
export async function cmdState(address, flags) {
|
|
880
1174
|
if (!address || address.startsWith('--')) {
|
|
881
1175
|
console.error('usage: abx state <address> — a read-only, on-chain operational snapshot\n');
|
|
@@ -1001,6 +1295,10 @@ export async function cmdStateBody(address, flags, emit) {
|
|
|
1001
1295
|
// are maintained by the contract's own write paths, so there is no list to drift. Read-only,
|
|
1002
1296
|
// best-effort, and skipped entirely for a project that has none (a 1/1, a legacy pre-enumeration
|
|
1003
1297
|
// project, or a code project with no params).
|
|
1298
|
+
// Hoisted above the try block: the Locks section (printed after it, see `printLocks`) reuses
|
|
1299
|
+
// whatever this reads rather than paying for a second `eth_call` on the same fact.
|
|
1300
|
+
let hooks = null;
|
|
1301
|
+
let paramHooksLock;
|
|
1004
1302
|
try {
|
|
1005
1303
|
const rawKeys = await readParamSchemaKeys(publicClient, address);
|
|
1006
1304
|
const enumerable = rawKeys !== null;
|
|
@@ -1054,9 +1352,9 @@ export async function cmdStateBody(address, flags, emit) {
|
|
|
1054
1352
|
// transfers and mints, so "which hooks, and are they frozen" is exactly the pair someone needs
|
|
1055
1353
|
// before buying. Printed whenever the surface exists — including with all three unset, because
|
|
1056
1354
|
// "none, and frozen" is the strongest thing this line can say and it must be visible.
|
|
1057
|
-
|
|
1355
|
+
hooks = await readParamHooks(publicClient, address);
|
|
1058
1356
|
if (hooks) {
|
|
1059
|
-
|
|
1357
|
+
paramHooksLock = await readParamHooksLocked(publicClient, address);
|
|
1060
1358
|
const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
|
|
1061
1359
|
.filter(([, a]) => a !== zeroAddress)
|
|
1062
1360
|
.map(([role, a]) => `${role} ${a}`);
|
|
@@ -1066,12 +1364,12 @@ export async function cmdStateBody(address, flags, emit) {
|
|
|
1066
1364
|
transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
|
|
1067
1365
|
// `null` = could not be established (see readParamHooksLocked). Never collapse it to false:
|
|
1068
1366
|
// "unknown" and "the owner can still arm a transfer veto" are different answers.
|
|
1069
|
-
locked:
|
|
1367
|
+
locked: paramHooksLock ?? null,
|
|
1070
1368
|
});
|
|
1071
1369
|
emit(payload);
|
|
1072
|
-
const lockNote =
|
|
1370
|
+
const lockNote = paramHooksLock === true
|
|
1073
1371
|
? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
|
|
1074
|
-
:
|
|
1372
|
+
: paramHooksLock === false
|
|
1075
1373
|
? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
|
|
1076
1374
|
: dim('freeze state unknown (the node refused the check)');
|
|
1077
1375
|
info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
|
|
@@ -1083,6 +1381,10 @@ export async function cmdStateBody(address, flags, emit) {
|
|
|
1083
1381
|
catch {
|
|
1084
1382
|
/* best-effort — a non-code project has no params surface to read */
|
|
1085
1383
|
}
|
|
1384
|
+
// The coherent lock summary #122 asked for — every irreversible switch this node can read, side
|
|
1385
|
+
// by side, independent of whether this project happens to compose the params surface above.
|
|
1386
|
+
const locks = await readCollectionLocks(publicClient, address, STATE_ABI, kind.kind === 'code');
|
|
1387
|
+
printLocks(address, locks, !!hooks, paramHooksLock, payload, emit);
|
|
1086
1388
|
console.log('');
|
|
1087
1389
|
}
|
|
1088
1390
|
/**
|
|
@@ -1223,8 +1525,9 @@ async function cmdStateEditionBody(address, publicClient, kind, emit) {
|
|
|
1223
1525
|
// the same pair as the 721 body (which hooks, and whether they can still change); silent for the
|
|
1224
1526
|
// image/1-of-1 edition twins, which have no params surface at all.
|
|
1225
1527
|
const hooks = await readParamHooks(publicClient, address);
|
|
1528
|
+
let paramHooksLock;
|
|
1226
1529
|
if (hooks) {
|
|
1227
|
-
|
|
1530
|
+
paramHooksLock = await readParamHooksLocked(publicClient, address);
|
|
1228
1531
|
const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
|
|
1229
1532
|
.filter(([, a]) => a !== zeroAddress)
|
|
1230
1533
|
.map(([role, a]) => `${role} ${a}`);
|
|
@@ -1232,12 +1535,12 @@ async function cmdStateEditionBody(address, publicClient, kind, emit) {
|
|
|
1232
1535
|
configureHook: hooks.configureHook === zeroAddress ? null : hooks.configureHook,
|
|
1233
1536
|
augmentHook: hooks.augmentHook === zeroAddress ? null : hooks.augmentHook,
|
|
1234
1537
|
transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
|
|
1235
|
-
locked:
|
|
1538
|
+
locked: paramHooksLock ?? null, // null = unknown; never collapse it to false
|
|
1236
1539
|
});
|
|
1237
1540
|
emit(payload);
|
|
1238
|
-
const lockNote =
|
|
1541
|
+
const lockNote = paramHooksLock === true
|
|
1239
1542
|
? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
|
|
1240
|
-
:
|
|
1543
|
+
: paramHooksLock === false
|
|
1241
1544
|
? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
|
|
1242
1545
|
: dim('freeze state unknown (the node refused the check)');
|
|
1243
1546
|
info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
|
|
@@ -1245,6 +1548,9 @@ async function cmdStateEditionBody(address, publicClient, kind, emit) {
|
|
|
1245
1548
|
info(dim(` the transfer hook is a VETO: if it reverts, the transfer fails — and mints too (mint = transfer from 0x0).`));
|
|
1246
1549
|
}
|
|
1247
1550
|
}
|
|
1551
|
+
// The coherent lock summary #122 asked for — see cmdStateBody's identical call for why.
|
|
1552
|
+
const locks = await readCollectionLocks(publicClient, address, EDITION_STATE_ABI, kind.kind === 'edition-code');
|
|
1553
|
+
printLocks(address, locks, !!hooks, paramHooksLock, payload, emit);
|
|
1248
1554
|
console.log('');
|
|
1249
1555
|
}
|
|
1250
1556
|
/**
|