@artblocks/abx-cli 0.1.0-alpha.4 → 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 +161 -0
- package/assets/renderer-scaffold/README.md +41 -11
- package/assets/renderer-scaffold/foundry.toml +5 -0
- package/assets/renderer-scaffold/remappings.txt +1 -1
- package/assets/renderer-scaffold/script/DeployHooks.s.sol +24 -0
- package/assets/renderer-scaffold/script/Preview.s.sol +99 -0
- package/assets/renderer-scaffold/src/MyHooks.sol +20 -0
- package/assets/renderer-scaffold/src/MyRenderer.sol +4 -4
- package/assets/renderer-scaffold/src/MyTraits.sol +2 -2
- package/assets/renderer-scaffold/test/MyRenderer.t.sol +60 -3
- package/dist/bin.d.ts +26 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +63 -0
- package/dist/bin.js.map +1 -0
- package/dist/capabilities.d.ts +99 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +144 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/commands/auth.d.ts +54 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +447 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/deploy.d.ts +242 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +5429 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/feedback.d.ts +7 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/feedback.js +147 -0
- package/dist/commands/feedback.js.map +1 -0
- 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 +429 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +1720 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/reads.d.ts +85 -0
- package/dist/commands/reads.d.ts.map +1 -0
- package/dist/commands/reads.js +868 -0
- package/dist/commands/reads.js.map +1 -0
- package/dist/commands/scaffold.d.ts +89 -0
- package/dist/commands/scaffold.d.ts.map +1 -0
- package/dist/commands/scaffold.js +738 -0
- package/dist/commands/scaffold.js.map +1 -0
- package/dist/commands/service.d.ts +67 -0
- package/dist/commands/service.d.ts.map +1 -0
- package/dist/commands/service.js +748 -0
- package/dist/commands/service.js.map +1 -0
- package/dist/commands/storage.d.ts +51 -0
- package/dist/commands/storage.d.ts.map +1 -0
- package/dist/commands/storage.js +399 -0
- package/dist/commands/storage.js.map +1 -0
- package/dist/commands/submit-app.d.ts +102 -0
- package/dist/commands/submit-app.d.ts.map +1 -0
- package/dist/commands/submit-app.js +622 -0
- package/dist/commands/submit-app.js.map +1 -0
- package/dist/config.d.ts +105 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +351 -12
- package/dist/config.js.map +1 -1
- package/dist/conformance.d.ts +31 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +390 -0
- package/dist/conformance.js.map +1 -0
- package/dist/contract-read-error.d.ts +5 -0
- package/dist/contract-read-error.d.ts.map +1 -0
- package/dist/contract-read-error.js +37 -0
- package/dist/contract-read-error.js.map +1 -0
- 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/deps.d.ts +6 -39
- package/dist/deps.d.ts.map +1 -1
- package/dist/deps.js +4 -68
- package/dist/deps.js.map +1 -1
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/flag-allowlists.d.ts +53 -0
- package/dist/flag-allowlists.d.ts.map +1 -0
- package/dist/flag-allowlists.js +184 -0
- package/dist/flag-allowlists.js.map +1 -0
- package/dist/flags.d.ts +47 -0
- package/dist/flags.d.ts.map +1 -1
- package/dist/flags.js +125 -1
- package/dist/flags.js.map +1 -1
- package/dist/jsonout.d.ts +37 -0
- package/dist/jsonout.d.ts.map +1 -0
- package/dist/jsonout.js +79 -0
- package/dist/jsonout.js.map +1 -0
- package/dist/kind.d.ts +57 -0
- package/dist/kind.d.ts.map +1 -0
- package/dist/kind.js +122 -0
- package/dist/kind.js.map +1 -0
- package/dist/main.js +786 -4838
- package/dist/main.js.map +1 -1
- package/dist/mintpage.d.ts +17 -2
- package/dist/mintpage.d.ts.map +1 -1
- package/dist/mintpage.js +241 -54
- package/dist/mintpage.js.map +1 -1
- package/dist/output.d.ts +179 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +780 -0
- package/dist/output.js.map +1 -0
- package/dist/ownerops.d.ts +381 -57
- package/dist/ownerops.d.ts.map +1 -1
- package/dist/ownerops.js +2014 -359
- package/dist/ownerops.js.map +1 -1
- package/dist/preview.d.ts +23 -5
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +95 -43
- package/dist/preview.js.map +1 -1
- package/dist/prompt.d.ts +17 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +19 -0
- package/dist/prompt.js.map +1 -0
- package/dist/provision.d.ts +3 -13
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +19 -21
- package/dist/provision.js.map +1 -1
- package/dist/remote.d.ts +157 -52
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +435 -46
- package/dist/remote.js.map +1 -1
- package/dist/riskgate.d.ts +62 -0
- package/dist/riskgate.d.ts.map +1 -0
- package/dist/riskgate.js +234 -0
- package/dist/riskgate.js.map +1 -0
- package/dist/scaffold.d.ts +12 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +56 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/schema.d.ts +36 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +121 -26
- package/dist/schema.js.map +1 -1
- package/dist/script-chunks.d.ts +8 -0
- package/dist/script-chunks.d.ts.map +1 -0
- package/dist/script-chunks.js +35 -0
- package/dist/script-chunks.js.map +1 -0
- package/dist/served.d.ts +30 -0
- package/dist/served.d.ts.map +1 -0
- package/dist/served.js +112 -0
- package/dist/served.js.map +1 -0
- package/dist/signer.d.ts +13 -0
- package/dist/signer.d.ts.map +1 -1
- package/dist/signer.js +84 -15
- package/dist/signer.js.map +1 -1
- package/dist/update-check.d.ts +86 -5
- package/dist/update-check.d.ts.map +1 -1
- package/dist/update-check.js +161 -20
- package/dist/update-check.js.map +1 -1
- package/package.json +13 -12
- package/skill/SKILL.md +181 -347
- package/skill/agents/openai.yaml +4 -0
- package/skill/reference/capabilities.md +188 -0
- package/skill/reference/code.md +220 -0
- package/skill/reference/creator-token.md +94 -0
- package/skill/reference/deploy.md +174 -0
- package/skill/reference/diagnose.md +178 -0
- package/skill/reference/hosting.md +186 -93
- package/skill/reference/operate.md +220 -0
- package/skill/reference/services.md +121 -0
- package/skill/reference/setup.md +154 -36
- package/assets/renderer-scaffold/src/interfaces/IAbxFieldRenderer.sol +0 -32
- package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +0 -26
- package/dist/inspect.d.ts +0 -48
- package/dist/inspect.d.ts.map +0 -1
- package/dist/inspect.js +0 -184
- package/dist/inspect.js.map +0 -1
- package/dist/migrate.d.ts +0 -65
- package/dist/migrate.d.ts.map +0 -1
- package/dist/migrate.js +0 -180
- package/dist/migrate.js.map +0 -1
- package/dist/onchain-uri.d.ts +0 -97
- package/dist/onchain-uri.d.ts.map +0 -1
- package/dist/onchain-uri.js +0 -243
- package/dist/onchain-uri.js.map +0 -1
- package/dist/upload.d.ts +0 -28
- package/dist/upload.d.ts.map +0 -1
- package/dist/upload.js +0 -41
- package/dist/upload.js.map +0 -1
- package/skill/reference/code-projects.md +0 -246
- package/skill/reference/operating.md +0 -116
- package/skill/reference/troubleshooting.md +0 -28
|
@@ -0,0 +1,1720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project lifecycle commands: `predict` (pre-compute a deploy address), `add` (register + index a
|
|
3
|
+
* project this node didn't deploy), `index` (re-index from chain), `verify` (re-hash served bytes
|
|
4
|
+
* vs. the on-chain commitment), `state`/`status` (on-chain snapshot / indexed-project rollup), and
|
|
5
|
+
* `forget` (drop the local registration; on-chain data is untouched).
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
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, encodeTag, METADATA_FIELD, METADATA_REPRESENTATION, fieldOf, isCurrentRenderer, isCurrentGenerator, } from '@artblocks/abx-sdk';
|
|
10
|
+
import { resolveBackend } from '@artblocks/abx-storage';
|
|
11
|
+
import { currentRenderArtifact, verifyProject } from '@artblocks/abx-token-api';
|
|
12
|
+
import { zeroAddress } from 'viem';
|
|
13
|
+
import { CHAIN, activeBackendId, factoryAddress, localIndexer, remoteLocators, seriesFactoryAddress, oneOfOneEditionFactoryAddress, editionFactoryAddress, editionCodeFactoryAddress, storageOptions, } from '../config.js';
|
|
14
|
+
import { isDryRun, parseBlockTagFlag, parseSaltFlag } from '../flags.js';
|
|
15
|
+
import { jsonSafe, withJson } from '../jsonout.js';
|
|
16
|
+
import { allowLargeScan, bold, c, detectCanonicalFactory, dim, g, info, ok, registerAndIndexLocally, resolveScanFloor, warn } from '../output.js';
|
|
17
|
+
import { parseCopies } from './deploy.js';
|
|
18
|
+
import { canonicalSeedSource } from '../ownerops.js';
|
|
19
|
+
import { detectTokenKind, describeKind } from '../kind.js';
|
|
20
|
+
import { isRpcReadFailure } from '../contract-read-error.js';
|
|
21
|
+
import { canonicalLabel, describeRemoteError, indexErrorAction, remoteFlag, reportRemoteIndexing, requireRemoteToken, rollUp, serviceClient, statusLabel, statusLine, statusRow, tokenSourceLabel, } from '../remote.js';
|
|
22
|
+
import { describeSchema } from '../schema.js';
|
|
23
|
+
import { looksPerTokenAttributes, parseSeriesTraitsById } from '../series-traits.js';
|
|
24
|
+
// ── predict ──────────────────────────────────────────────────────────────────
|
|
25
|
+
// Pre-compute a deploy address from a salt — so you can stand up the resolver and
|
|
26
|
+
// reserve a vanity/known address before signing. The address is a pure function of
|
|
27
|
+
// (factory, salt); the salt's leading 20 bytes are the front-run guard.
|
|
28
|
+
export async function cmdPredict(flags) {
|
|
29
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
30
|
+
// Lane-aware: each deploy command uses a DIFFERENT factory, so the deterministic address differs.
|
|
31
|
+
// Infer the lane from the content flags so `predict` matches what will actually be deployed —
|
|
32
|
+
// otherwise a code/Series creator gets the 1/1 address (a real mismatch, not just a wrong echo).
|
|
33
|
+
const chainId = resolveChain(CHAIN).id;
|
|
34
|
+
const content = flags.script ? `--script ${flags.script}` :
|
|
35
|
+
flags['code-dir'] ? `--code-dir ${flags['code-dir']}` :
|
|
36
|
+
flags['image-renderer'] ? `--image-renderer ${flags['image-renderer']}` :
|
|
37
|
+
flags.dir ? `--dir ${flags.dir}` :
|
|
38
|
+
flags.image ? `--image ${flags.image}` : '';
|
|
39
|
+
const baseCmd = (flags.script || flags['code-dir'] || flags['image-renderer'])
|
|
40
|
+
? 'deploy-code'
|
|
41
|
+
: flags.dir
|
|
42
|
+
? 'deploy-series'
|
|
43
|
+
: 'deploy';
|
|
44
|
+
// `--copies <n|open>` switches which factory's address is predicted — the SAME flag that
|
|
45
|
+
// switches which factory a real deploy uses (see commands/deploy.ts's `parseCopies`). Validated
|
|
46
|
+
// here too (not just deferred to the real deploy) so a malformed value fails before printing an
|
|
47
|
+
// address tied to it.
|
|
48
|
+
const editionSize = flags.copies !== undefined ? parseCopies(flags.copies) : undefined;
|
|
49
|
+
const isEdition = editionSize !== undefined;
|
|
50
|
+
const lane = isEdition
|
|
51
|
+
? {
|
|
52
|
+
cmd: baseCmd,
|
|
53
|
+
factory: baseCmd === 'deploy-code'
|
|
54
|
+
? editionCodeFactoryAddress(flags.factory)
|
|
55
|
+
: baseCmd === 'deploy-series'
|
|
56
|
+
? editionFactoryAddress(flags.factory)
|
|
57
|
+
: oneOfOneEditionFactoryAddress(flags.factory),
|
|
58
|
+
}
|
|
59
|
+
: baseCmd === 'deploy-code'
|
|
60
|
+
? { cmd: 'deploy-code', factory: resolveSeriesCodeFactory(chainId, flags.factory) }
|
|
61
|
+
: baseCmd === 'deploy-series'
|
|
62
|
+
? { cmd: 'deploy-series', factory: seriesFactoryAddress(flags.factory) }
|
|
63
|
+
: { cmd: 'deploy', factory: factoryAddress(flags.factory) };
|
|
64
|
+
const factory = lane.factory;
|
|
65
|
+
if (!factory) {
|
|
66
|
+
throw new Error(`no canonical ${isEdition ? 'edition ' : ''}${lane.cmd} factory for '${CHAIN}' — run \`abx ${lane.cmd}${isEdition ? ` --copies ${flags.copies}` : ''}\` ` +
|
|
67
|
+
`once to deploy the trust anchor, or pass --factory 0x.. (or the ABX_* env).`);
|
|
68
|
+
}
|
|
69
|
+
// The current-factory guard is the 1/1 impl-version check (721 or its edition twin); the Series/
|
|
70
|
+
// Series-edition/code/code-edition lanes verify their own factory at deploy time instead —
|
|
71
|
+
// EditionCodeFactory mirrors SeriesCodeFactory's stance here (no version probe: a configured code
|
|
72
|
+
// factory with code is always accepted, and one found at its CREATE2 address is bytecode-bound to
|
|
73
|
+
// this build anyway, so there's nothing this check would catch that the real deploy doesn't
|
|
74
|
+
// already). predictDeterministicAddress works on any factory ABI.
|
|
75
|
+
if (lane.cmd === 'deploy') {
|
|
76
|
+
const current = isEdition ? await isCurrentOneOfOneEditionFactory(publicClient, factory) : await isCurrentFactory(publicClient, factory);
|
|
77
|
+
if (!current) {
|
|
78
|
+
throw new Error(`factory ${factory} is an older/incompatible version. Run \`abx deploy${isEdition ? ` --copies ${flags.copies}` : ''}\` once to deploy the current ` +
|
|
79
|
+
`trust anchor (ownerless + cheap), then predict again — or pass a current --factory.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let salt = parseSaltFlag(flags.salt);
|
|
83
|
+
if (!salt) {
|
|
84
|
+
// No explicit salt: reserve one to a deployer (front-run-proof). Default to the env key.
|
|
85
|
+
let deployer = flags.for;
|
|
86
|
+
if (!deployer)
|
|
87
|
+
deployer = makeWalletClient({ chainKey: CHAIN }).account.address;
|
|
88
|
+
salt = saltFor(deployer);
|
|
89
|
+
info(`reserved a fresh salt to ${deployer} (front-run-proof) — pass --salt to fix/vanity it`);
|
|
90
|
+
}
|
|
91
|
+
const guard = saltGuard(salt);
|
|
92
|
+
const clone = await predictClone(publicClient, { factory, salt });
|
|
93
|
+
console.log(`\n ${bold('predicted address')} ${g(clone)} ${dim(`(${lane.cmd} lane${isEdition ? ', edition' : ''})`)}`);
|
|
94
|
+
info(`salt ${salt}`);
|
|
95
|
+
info(guard === zeroAddress ? 'guard permissionless — anyone may deploy this salt' : `guard reserved to ${guard} (only this signer can deploy it)`);
|
|
96
|
+
const copiesFlag = isEdition ? ` --copies ${editionSize === 0n ? 'open' : editionSize.toString()}` : '';
|
|
97
|
+
info(`deploy: ${bold(`abx ${lane.cmd}${content ? ` ${content}` : ''}${copiesFlag} --salt ${salt}`)}`);
|
|
98
|
+
if (lane.cmd === 'deploy' && !isEdition)
|
|
99
|
+
info(dim('(this is the 1/1 lane; a Series/code drop uses a different factory → a different address — pass --dir / --script to predict those, or use that command\'s --dry-run)'));
|
|
100
|
+
console.log('');
|
|
101
|
+
}
|
|
102
|
+
// ── add ──────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Register + index a project this node didn't deploy. LOCAL by default (this
|
|
104
|
+
// machine's store); `--remote [url]` instead tells a HOSTED resolver to index it —
|
|
105
|
+
// the bridge a local deploy can't make on its own (separate projection stores).
|
|
106
|
+
export async function cmdAdd(address, flags) {
|
|
107
|
+
if (!address || address.startsWith('--')) {
|
|
108
|
+
console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [name|url]]\n');
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// REFUSE `--dry-run` rather than ignoring it. `add` is a write (a local registration + index, and
|
|
113
|
+
// with `--remote` a registration on someone else's service), and it has no preview mode — so
|
|
114
|
+
// silently proceeding to DO the thing when the caller explicitly asked to preview is the one
|
|
115
|
+
// outcome we must never produce. Name what's read-only instead.
|
|
116
|
+
if (isDryRun(flags)) {
|
|
117
|
+
throw new Error('`abx add` has no --dry-run: it registers + indexes for real (and with --remote it registers on that service). ' +
|
|
118
|
+
'Nothing here touches the chain, but it does write. To look before acting: `abx state <address>` (on-chain snapshot) ' +
|
|
119
|
+
'or `abx status <address> [--remote <name>]` (what a node already has). Re-run without --dry-run when you mean it.');
|
|
120
|
+
}
|
|
121
|
+
// `--attributes` is lane-aware here exactly as at deploy: a PER-TOKEN payload edits a Series'
|
|
122
|
+
// per-token off-chain traits; a flat payload (+ `--traits`) edits the collection/1-of-1 `attributes`.
|
|
123
|
+
// Ambiguity defaults to flat (see looksPerTokenAttributes), so a 1/1 add is never mis-read.
|
|
124
|
+
const attrRaw = flags.attributes ? readFileSync(resolvePath(process.cwd(), String(flags.attributes)), 'utf8') : undefined;
|
|
125
|
+
const perTokenEdit = attrRaw != null && looksPerTokenAttributes(attrRaw);
|
|
126
|
+
const flagTraits = [];
|
|
127
|
+
if (attrRaw != null && !perTokenEdit)
|
|
128
|
+
flagTraits.push(...normalizeAttributes(JSON.parse(attrRaw)));
|
|
129
|
+
if (flags.traits)
|
|
130
|
+
flagTraits.push(...parseTraitPairs(flags.traits));
|
|
131
|
+
const editedTokenAttributes = perTokenEdit ? parseSeriesTraitsById(attrRaw) : undefined;
|
|
132
|
+
const remote = remoteFlag(flags);
|
|
133
|
+
if (remote) {
|
|
134
|
+
requireRemoteToken(remote);
|
|
135
|
+
// Bridge what a remote resolver can't derive itself: the off-chain traits and the durable
|
|
136
|
+
// content locators (ipfs://…). Prefer flags; otherwise forward what the LOCAL deploy stored
|
|
137
|
+
// (the local registration), and compute locators from this machine's content index if needed.
|
|
138
|
+
const localReg = localIndexer().store.getRegistration(address);
|
|
139
|
+
const attributes = flagTraits.length
|
|
140
|
+
? flagTraits
|
|
141
|
+
: localReg?.attributes
|
|
142
|
+
? normalizeAttributes(JSON.parse(localReg.attributes))
|
|
143
|
+
: undefined;
|
|
144
|
+
// Bridge a Series' per-token off-chain traits to the remote resolver (the resolver has no other
|
|
145
|
+
// way to derive them — they're operator metadata, not chain state). A fresh per-token `--attributes`
|
|
146
|
+
// EDITS them; otherwise forward what the LOCAL deploy stored. Best-effort parse.
|
|
147
|
+
let tokenAttributes;
|
|
148
|
+
if (editedTokenAttributes && Object.keys(editedTokenAttributes).length) {
|
|
149
|
+
tokenAttributes = editedTokenAttributes;
|
|
150
|
+
}
|
|
151
|
+
else if (localReg?.tokenAttributes) {
|
|
152
|
+
try {
|
|
153
|
+
const obj = JSON.parse(localReg.tokenAttributes);
|
|
154
|
+
const norm = {};
|
|
155
|
+
for (const [id, v] of Object.entries(obj)) {
|
|
156
|
+
const a = normalizeAttributes(v);
|
|
157
|
+
if (a.length)
|
|
158
|
+
norm[id] = a;
|
|
159
|
+
}
|
|
160
|
+
if (Object.keys(norm).length)
|
|
161
|
+
tokenAttributes = norm;
|
|
162
|
+
}
|
|
163
|
+
catch { /* skip a malformed local column */ }
|
|
164
|
+
}
|
|
165
|
+
const contentLocators = await remoteLocators(address, localReg?.contentLocators, flags);
|
|
166
|
+
// Forward the deploy block, like every other field falls back to the local registration.
|
|
167
|
+
// (Its ABSENCE here was the bug: a hosted resolver defaulted to genesis and scanned the whole
|
|
168
|
+
// chain.) Re-sending the same floor stays incremental server-side, so a nudge ≠ a re-scan.
|
|
169
|
+
const body = {
|
|
170
|
+
chainId: resolveChain(CHAIN).id,
|
|
171
|
+
address,
|
|
172
|
+
fromBlock: await resolveScanFloor(address, localReg?.fromBlock, flags),
|
|
173
|
+
factory: await detectCanonicalFactory(address, flags.factory, localReg?.factory),
|
|
174
|
+
label: flags.label,
|
|
175
|
+
description: flags.description ?? localReg?.description,
|
|
176
|
+
externalUrl: flags['external-url'] ?? localReg?.externalUrl,
|
|
177
|
+
attributes,
|
|
178
|
+
tokenAttributes,
|
|
179
|
+
contentLocators: Object.keys(contentLocators).length ? contentLocators : undefined,
|
|
180
|
+
full: flags.full ? true : undefined,
|
|
181
|
+
};
|
|
182
|
+
info(`${bold('REMOTE')} → ${remote.url} ${dim('(registering with the remote resolver — NOT this machine)')}`);
|
|
183
|
+
if (body.contentLocators)
|
|
184
|
+
info(`bridging image locator → ${Object.values(body.contentLocators)[0]} ${dim('(so the resolver points at IPFS, not its own localhost)')}`);
|
|
185
|
+
let r;
|
|
186
|
+
try {
|
|
187
|
+
r = await serviceClient(remote).registerProject(body);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
throw describeRemoteError(err, remote, 'remote add');
|
|
191
|
+
}
|
|
192
|
+
await reportRemoteIndexing(remote, body.chainId, address, r, flags, 'indexed');
|
|
193
|
+
info(`it now serves ${remote.url}/t/${body.chainId}/${address.toLowerCase()}/0`);
|
|
194
|
+
// "Indexed" is not "correct". This line proves the service replayed the chain and will answer at
|
|
195
|
+
// that URL; it says nothing about whether the bytes it serves match the on-chain commitment.
|
|
196
|
+
// Name the step that checks.
|
|
197
|
+
info(`confirm what it actually serves (bytes vs. the on-chain hash): ${bold(`abx verify ${address} --remote ${remote.name?.toLowerCase() ?? remote.url}`)}`);
|
|
198
|
+
// Say what this registration did NOT buy. A project whose `tokenURIRenderer` is set answers
|
|
199
|
+
// `tokenURI` from the chain, so marketplaces and wallets read THAT document and never touch this
|
|
200
|
+
// service — the register is still useful (indexing, the live view, managed rendering for a code
|
|
201
|
+
// drop) but it changes nothing a collector sees. Without this line the readout is a list of
|
|
202
|
+
// successes that reads like a win, and a creator who was told "put it on a hosted service so it
|
|
203
|
+
// shows up properly" concludes their problem is solved when nothing about it moved.
|
|
204
|
+
const onChainRenderer = await tryReadContract(makePublicClient({ chainKey: CHAIN }), {
|
|
205
|
+
address,
|
|
206
|
+
abi: STATE_ABI,
|
|
207
|
+
functionName: 'tokenURIRenderer',
|
|
208
|
+
});
|
|
209
|
+
if (onChainRenderer && onChainRenderer !== zeroAddress) {
|
|
210
|
+
info(`${dim('note:')} this project resolves ${bold('tokenURI from the chain')} (renderer ${onChainRenderer}) — marketplaces read that, not this service. ` +
|
|
211
|
+
`Registering still gives you indexing, the live view and managed rendering, but it does not change the metadata a collector sees.`);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// The local (non-`--remote`) path is shared with the deploy family's post-setup registration —
|
|
216
|
+
// see registerAndIndexLocally's doc comment (output.ts).
|
|
217
|
+
return registerAndIndexLocally(address, flags);
|
|
218
|
+
}
|
|
219
|
+
export async function cmdIndex(address, flags) {
|
|
220
|
+
const remote = remoteFlag(flags);
|
|
221
|
+
if (remote) {
|
|
222
|
+
if (!address || address.startsWith('--')) {
|
|
223
|
+
console.error('usage: abx index <address> --remote [name|url] (re-index one project on a remote resolver)\n');
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
requireRemoteToken(remote);
|
|
228
|
+
info(`${bold('REMOTE')} → ${remote.url} ${dim('(re-indexing on the remote resolver — the post-deploy nudge)')}`);
|
|
229
|
+
const chainId = resolveChain(CHAIN).id;
|
|
230
|
+
let r;
|
|
231
|
+
try {
|
|
232
|
+
r = await serviceClient(remote).registerProject({ chainId, address, full: flags.full ? true : undefined });
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
throw describeRemoteError(err, remote, 'remote index');
|
|
236
|
+
}
|
|
237
|
+
await reportRemoteIndexing(remote, chainId, address, r, flags, 're-indexed');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
allowLargeScan(flags);
|
|
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']);
|
|
248
|
+
const indexer = localIndexer();
|
|
249
|
+
const line = (state, elapsedMs, mode) => ok(`${state.name ?? state.address}: ${state.eventCount} events in ${elapsedMs}ms ${dim(`(${mode})`)}`);
|
|
250
|
+
if (address) {
|
|
251
|
+
const { state, elapsedMs, mode } = await indexer.reindex(address, { full, blockTag });
|
|
252
|
+
line(state, elapsedMs, mode);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const results = await indexer.reindexAll({ full, blockTag });
|
|
256
|
+
if (results.length === 0) {
|
|
257
|
+
console.log(dim('No registered projects. Deploy one with `abx deploy`.'));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
for (const { state, elapsedMs, mode } of results)
|
|
261
|
+
line(state, elapsedMs, mode);
|
|
262
|
+
}
|
|
263
|
+
// ── verify ─────────────────────────────────────────────────────────────────--
|
|
264
|
+
// Re-hash a project's served bytes against its on-chain content commitment, from
|
|
265
|
+
// chain + custody alone — no running server. The CLI form of the `/verify` route,
|
|
266
|
+
// so an agent can confirm integrity right after deploy/mint without curling.
|
|
267
|
+
export async function cmdVerify(address, flags) {
|
|
268
|
+
if (!address || address.startsWith('--')) {
|
|
269
|
+
console.error('usage: abx verify <address> [--json] [--remote <name|url>]\n');
|
|
270
|
+
process.exitCode = 1;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
allowLargeScan(flags);
|
|
274
|
+
const remote = remoteFlag(flags);
|
|
275
|
+
// `--json` matters most here of all the read commands: verify already exits non-zero on a byte
|
|
276
|
+
// mismatch, so it is the one command a CI job would gate on. The payload's `ok` is the same
|
|
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));
|
|
286
|
+
return withJson(flags, async (emit) => cmdVerifyBody(address, flags, emit));
|
|
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
|
+
}
|
|
348
|
+
export async function cmdVerifyBody(address, flags, emit) {
|
|
349
|
+
const indexer = localIndexer();
|
|
350
|
+
let state = indexer.getProject(address);
|
|
351
|
+
if (indexer.store.getRegistration(address)) {
|
|
352
|
+
({ state } = await indexer.reindex(address)); // freshest state from chain
|
|
353
|
+
}
|
|
354
|
+
else if (!state) {
|
|
355
|
+
throw new Error(`${address} isn't indexed by this node. Run \`abx add ${address}\` first.`);
|
|
356
|
+
}
|
|
357
|
+
if (!state)
|
|
358
|
+
throw new Error(`could not load state for ${address}`);
|
|
359
|
+
// Accumulated as the checks run, re-emitted at each stage so a payload survives an early return.
|
|
360
|
+
const verifyReport = {
|
|
361
|
+
address,
|
|
362
|
+
chain: CHAIN,
|
|
363
|
+
name: state.name ?? null,
|
|
364
|
+
canonical: state.isCanonical, // TRUE tri-state: true | false | null (couldn't check)
|
|
365
|
+
owner: state.owner ?? null,
|
|
366
|
+
contentChecks: [],
|
|
367
|
+
contentIntegrity: 'no-commitments',
|
|
368
|
+
renders: null,
|
|
369
|
+
onChainUri: null,
|
|
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,
|
|
374
|
+
};
|
|
375
|
+
emit(verifyReport);
|
|
376
|
+
console.log(bold(`\n verify ${state.name ?? address}`));
|
|
377
|
+
// isCanonical is a TRUE TRI-STATE (true | false | null) and collapsing it lost the only
|
|
378
|
+
// distinction that matters: "the chain says this is NOT a clone of the configured factory" is a
|
|
379
|
+
// trust finding; "we couldn't run the check" (no factory configured, or none deployed on this
|
|
380
|
+
// chain) is an environment note. Keep those states visibly distinct.
|
|
381
|
+
info(`canonical: ${canonicalLabel(state.isCanonical)} · owner ${state.owner ?? '—'}`);
|
|
382
|
+
const storage = resolveBackend(storageOptions());
|
|
383
|
+
const result = await verifyProject(state, storage);
|
|
384
|
+
const tokens = result.tokens ?? [];
|
|
385
|
+
let anyCheck = false;
|
|
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;
|
|
393
|
+
for (const t of tokens) {
|
|
394
|
+
const tok = state.tokens.find((s) => s.tokenId === t.tokenId);
|
|
395
|
+
const life = tok?.lifecycle === 'live'
|
|
396
|
+
? 'minted'
|
|
397
|
+
: tok?.lifecycle === 'burned'
|
|
398
|
+
? 'BURNED'
|
|
399
|
+
: tok?.lifecycle === 'no-live-copies'
|
|
400
|
+
? 'no live copies'
|
|
401
|
+
: 'not yet minted';
|
|
402
|
+
info(`token #${t.tokenId}: ${life}`);
|
|
403
|
+
for (const ch of t.checks) {
|
|
404
|
+
verifyReport.contentChecks.push({
|
|
405
|
+
tokenId: t.tokenId,
|
|
406
|
+
lifecycle: tok?.lifecycle ?? 'unminted',
|
|
407
|
+
kind: ch.kind,
|
|
408
|
+
// tri-state again: true (re-hashed, matched) · false (MISMATCH) · null (pointer-only,
|
|
409
|
+
// nothing local to recompute). Collapsing null into false would report a normal state as a
|
|
410
|
+
// failure, which is the bug this command exists to not have.
|
|
411
|
+
verified: ch.verified,
|
|
412
|
+
});
|
|
413
|
+
anyCheck = true;
|
|
414
|
+
if (ch.verified === true)
|
|
415
|
+
ok(`${ch.kind} matches on-chain commitment — content is authentic`);
|
|
416
|
+
else if (ch.verified === false) {
|
|
417
|
+
allGood = false;
|
|
418
|
+
console.log(` ${c.red}✗${c.reset} ${ch.kind} MISMATCH — served bytes don't match the commitment`);
|
|
419
|
+
}
|
|
420
|
+
else
|
|
421
|
+
info(`${ch.kind} — pointer-only, not locally recomputable`);
|
|
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
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (!anyCheck && unrecomputablePointers === 0 && !isCodeProject(state))
|
|
444
|
+
info('no content commitments on this project');
|
|
445
|
+
verifyReport.contentIntegrity = !anyCheck ? 'no-commitments' : allGood ? 'ok' : 'mismatch';
|
|
446
|
+
verifyReport.ok = !anyCheck || allGood;
|
|
447
|
+
emit(verifyReport);
|
|
448
|
+
// Code projects have no on-chain `image` commitment (the still is rendered off-chain by the
|
|
449
|
+
// effect runner), so the hash checks above are silent for them. Instead report what actually
|
|
450
|
+
// matters after mint: does a REAL thumbnail exist yet, or is the marketplace image still the
|
|
451
|
+
// placeholder? Same seam the /…/image route serves from (currentRenderArtifact @ current
|
|
452
|
+
// inputsHash) — so this answers "did the render land?" with one command, right after token 0.
|
|
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;
|
|
457
|
+
if (isCodeProject(state)) {
|
|
458
|
+
const client = makePublicClient({ chainKey: CHAIN });
|
|
459
|
+
const storageForRender = resolveBackend(storageOptions());
|
|
460
|
+
const minted = state.tokens.filter((t) => t.lifecycle === 'live');
|
|
461
|
+
if (minted.length === 0)
|
|
462
|
+
info('no tokens minted yet — mint token #0, then re-run to check its thumbnail.');
|
|
463
|
+
// Live-data posture — the augment hook IS the opt-in: no hook ⇒ zero live reads (pure indexed
|
|
464
|
+
// params); hook set ⇒ the live view reads chain per view, and the STILL snapshots settled state
|
|
465
|
+
// (live data never re-addresses the render — that's the settled/full split).
|
|
466
|
+
info(state.paramHooks?.augmentHook
|
|
467
|
+
? `live data: augment hook ${state.paramHooks.augmentHook} — live view reads chain per view; stills snapshot settled state`
|
|
468
|
+
: 'live data: none (settled params only — the live view makes zero extra chain reads)');
|
|
469
|
+
// Immutability of the WORK itself — the on-chain program (script chunks) and its library set.
|
|
470
|
+
// This is the gap `lock-field`/`lock-uri` don't cover: those freeze metadata, but the owner can
|
|
471
|
+
// still call `setScriptChunk`/`removeLastScriptChunk` until `lock-script` is sent. Report it so a
|
|
472
|
+
// "locked" drop isn't quietly still-mutable in the one place that carries the work.
|
|
473
|
+
verifyReport.script = state.script ? { locked: !!state.script.locked } : null;
|
|
474
|
+
verifyReport.dependencies = state.dependencies
|
|
475
|
+
? { count: state.dependencies.list.length, locked: !!state.dependencies.locked }
|
|
476
|
+
: null;
|
|
477
|
+
emit(verifyReport);
|
|
478
|
+
if (state.script) {
|
|
479
|
+
if (state.script.locked)
|
|
480
|
+
ok('script: locked — the program bytes are frozen permanently (setScriptChunk reverts)');
|
|
481
|
+
else
|
|
482
|
+
console.log(` ${c.orange}⚠${c.reset} script: UNLOCKED — the owner can still change the program. Freeze the work with ${bold(`abx lock-script ${address}`)} ${dim("(lock-field/lock-uri don't cover the script)")}`);
|
|
483
|
+
}
|
|
484
|
+
if (state.dependencies && state.dependencies.list.length > 0) {
|
|
485
|
+
if (state.dependencies.locked)
|
|
486
|
+
ok('dependencies: locked — the library set is frozen');
|
|
487
|
+
else
|
|
488
|
+
info(`dependencies: unlocked — freeze the list + registry pointer with \`abx lock-dependencies ${address}\` (that pins WHICH library each ref means; a Registry ref's bytes still live in the registry)`);
|
|
489
|
+
}
|
|
490
|
+
// The other two param hooks (if wired) — surfaced here since this is where hooks show. Quiet when
|
|
491
|
+
// unset (the common case). Manage all three with `abx set-param-hooks`.
|
|
492
|
+
if (state.paramHooks?.configureHook || state.paramHooks?.transferHook) {
|
|
493
|
+
const parts = [];
|
|
494
|
+
if (state.paramHooks.configureHook)
|
|
495
|
+
parts.push(`configure ${state.paramHooks.configureHook}`);
|
|
496
|
+
if (state.paramHooks.transferHook)
|
|
497
|
+
parts.push(`transfer ${state.paramHooks.transferHook}`);
|
|
498
|
+
info(`param hooks: ${parts.join(' · ')} ${dim('(configure = write-time veto · transfer = a VETO over transfers AND mints; manage with `abx set-param-hooks`)')}`);
|
|
499
|
+
}
|
|
500
|
+
// The hook lock is reported for every project with the surface, set hooks or not: a frozen empty
|
|
501
|
+
// trio is a real guarantee ("this project can never add a transfer veto"), and an unfrozen one is
|
|
502
|
+
// a live power even when no hook is set today. Folded from `ParamHooksFrozen`.
|
|
503
|
+
if (state.paramHooks) {
|
|
504
|
+
verifyReport.paramHooks = {
|
|
505
|
+
configureHook: state.paramHooks.configureHook,
|
|
506
|
+
augmentHook: state.paramHooks.augmentHook,
|
|
507
|
+
transferHook: state.paramHooks.transferHook,
|
|
508
|
+
locked: state.paramHooks.locked,
|
|
509
|
+
};
|
|
510
|
+
if (state.paramHooks.locked)
|
|
511
|
+
ok('param hooks: frozen — no hook address can change again (no transfer veto can ever be armed)');
|
|
512
|
+
else if (state.paramHooks.transferHook) {
|
|
513
|
+
console.log(` ${c.orange}⚠${c.reset} param hooks: UNLOCKED, and a transfer hook is armed — it can block transfers and mints, and the owner can re-point it. Freeze the set with ${bold(`abx lock-param-hooks ${address}`)}`);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
info(`param hooks: unlocked — no hook is set, but the owner can still add one (a transfer hook can block transfers/mints). Freeze with \`abx lock-param-hooks ${address}\``);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// This checks THIS machine's store. A render PUBLISHED to a hosted resolver (locator bridge) lives
|
|
520
|
+
// on that resolver, NOT here — so a "not found" below can be a false negative for a hosted drop.
|
|
521
|
+
// `abx verify <addr> --remote <resolver>` probes what the resolver actually serves (the truthful check).
|
|
522
|
+
if (minted.length)
|
|
523
|
+
info(dim(`render check is against THIS node's store; for a HOSTED drop use \`abx verify ${address} --remote <resolver>\``));
|
|
524
|
+
// ONE line per outcome, not per token. This printed the same full-sentence advisory 32 times on a
|
|
525
|
+
// 32-token project (~4KB of identical text) and pushed the four lines that answer "did my deploy
|
|
526
|
+
// work" off the top of the screen; at a 1000-token supply it is unreadable. The per-token detail
|
|
527
|
+
// that survives is the token LIST, which is the only part that differs.
|
|
528
|
+
const missing = [];
|
|
529
|
+
let present = 0;
|
|
530
|
+
for (const token of minted) {
|
|
531
|
+
const { found } = await currentRenderArtifact(client, state, token, storageForRender, 'image');
|
|
532
|
+
if (found)
|
|
533
|
+
present++;
|
|
534
|
+
else
|
|
535
|
+
missing.push(String(token.tokenId));
|
|
536
|
+
}
|
|
537
|
+
verifyReport.renders = {
|
|
538
|
+
minted: minted.length,
|
|
539
|
+
present,
|
|
540
|
+
missing, // token ids, so a caller can re-render exactly these
|
|
541
|
+
// Named for what it IS: this node's store. A render PUBLISHED to a hosted resolver lives
|
|
542
|
+
// there, not here, so `missing` is a false negative for a hosted drop — hence the scope.
|
|
543
|
+
scope: "this node's store",
|
|
544
|
+
};
|
|
545
|
+
codeRenders = { minted: minted.length, present };
|
|
546
|
+
emit(verifyReport);
|
|
547
|
+
if (present)
|
|
548
|
+
ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
|
|
549
|
+
if (missing.length) {
|
|
550
|
+
renderGap = true;
|
|
551
|
+
const ids = missing.length > 12 ? `${missing.slice(0, 12).join(', ')}, …+${missing.length - 12} more` : missing.join(', ');
|
|
552
|
+
console.log(` ${c.orange}⚠${c.reset} ${missing.length}/${minted.length} token(s) have no render in THIS node's store ${dim(`(#${ids})`)}`);
|
|
553
|
+
console.log(` ${dim('published to a hosted resolver? check there:')} ${bold(`abx verify ${address} --remote <resolver>`)}`);
|
|
554
|
+
console.log(` ${dim('else render them:')} ${bold(`abx render ${address}`)} ${dim('(once) ·')} ${bold('abx effects')} ${dim('(continuous)')}`);
|
|
555
|
+
}
|
|
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);
|
|
567
|
+
// The on-chain URI lane (a non-zero tokenURIRenderer, or an animation field pointing at the
|
|
568
|
+
// generator): read the generator's honesty surface — onChainStatus (branch · chain-complete ·
|
|
569
|
+
// unresolved refs · URL budget) — then decode tokenURI straight from the contract and report
|
|
570
|
+
// what the animation_url actually is. All eth_calls; no server, no local store involved.
|
|
571
|
+
if (hasOnChainUriLane(state)) {
|
|
572
|
+
const client = makePublicClient({ chainKey: CHAIN });
|
|
573
|
+
// Is this actually a PROGRAM, or a static image that merely resolves on-chain? `hasOnChainUriLane`
|
|
574
|
+
// is true whenever a tokenURI renderer is set — which it is for EVERY fully-on-chain project,
|
|
575
|
+
// image or code. So an image drop ran the code-project checks and ended in two ⚠ ("generator
|
|
576
|
+
// reports NO code", "no animation_url") that are the DESIGN for a static image, not a defect.
|
|
577
|
+
// Static on-chain images should not receive code-project warnings for their intentional shape.
|
|
578
|
+
const isCodeProject = state.contractType === 'code' || state.contractType === 'edition-code';
|
|
579
|
+
try {
|
|
580
|
+
const report = await onChainUriReport(client, state, flags.generator);
|
|
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;
|
|
591
|
+
verifyReport.onChainUri = jsonSafe({
|
|
592
|
+
generator: report.generator,
|
|
593
|
+
generatorCurrent, // true | false | null (no canonical generator recorded for this chain)
|
|
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,
|
|
599
|
+
branch: status.branchName,
|
|
600
|
+
chainComplete: !!status.chainComplete,
|
|
601
|
+
unresolvedRefs: [...status.unresolvedRefs],
|
|
602
|
+
urlOverBudget: !!status.urlOverBudget,
|
|
603
|
+
probe: probe
|
|
604
|
+
? { tokenId: probe.tokenId, onChainJson: !!probe.onChainJson, animation: probe.animation }
|
|
605
|
+
: null,
|
|
606
|
+
});
|
|
607
|
+
emit(verifyReport);
|
|
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
|
+
}
|
|
619
|
+
if (status.branchName === 'template') {
|
|
620
|
+
ok(`generator branch: template — the document assembles from the on-chain script chunks`);
|
|
621
|
+
if (status.chainComplete) {
|
|
622
|
+
ok('chain-complete — every dependency resolves to on-chain bytes; no server, gateway, or CDN in the graph');
|
|
623
|
+
// chainComplete is a SERVING claim, not an immutability one. A `Registry` dependency is
|
|
624
|
+
// fetched live from the registry contract on every read, so its bytes can change after
|
|
625
|
+
// `lock-dependencies` froze the pointer — and this flag reported `true` on both sides of
|
|
626
|
+
// a registry update. Say what it means rather than letting "complete" read as "finished".
|
|
627
|
+
info(dim(' chain-complete describes WHERE the bytes come from, not that they are frozen: a Registry dependency is re-fetched from the registry on every read, so its bytes can change even with lock-dependencies set. Locks freeze this contract; they cannot freeze another one.'));
|
|
628
|
+
}
|
|
629
|
+
else
|
|
630
|
+
info('not chain-complete — CDN-served or unresolved dependencies (CDN entries serve fine; on-chain bytes are the durability floor)');
|
|
631
|
+
for (const ref of status.unresolvedRefs) {
|
|
632
|
+
console.log(` ${c.red}✗${c.reset} unresolved dependency ${bold(ref)} — the document carries an <!-- abx:unresolved --> marker; fix the ref or the registry pointer`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
else if (status.branchName === 'directory') {
|
|
636
|
+
ok('generator branch: directory — the code field emits a parameterized gateway URL');
|
|
637
|
+
if (status.urlOverBudget)
|
|
638
|
+
console.log(` ${c.orange}⚠${c.reset} the emitted URL exceeds the 8KB budget — gateway front-ends may drop it; prefer template mode, trim the param surface, or carry big values as locator params`);
|
|
639
|
+
else
|
|
640
|
+
info('URL within the 8KB budget');
|
|
641
|
+
}
|
|
642
|
+
else if (isCodeProject) {
|
|
643
|
+
console.log(` ${c.orange}⚠${c.reset} generator reports NO code (neither script chunks nor a code field) — animation_url degrades to an <!-- abx:no-code --> marker`);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
// A static image drop has no program by design — stating that is fine, warning about it is not.
|
|
647
|
+
info('no program on this project — a static image drop, so the metadata carries no animation_url (expected, not a gap)');
|
|
648
|
+
}
|
|
649
|
+
if (!probe) {
|
|
650
|
+
info('tokenURI probe skipped — no token minted yet (mint token #0, then re-run)');
|
|
651
|
+
}
|
|
652
|
+
else if (!probe.onChainJson) {
|
|
653
|
+
console.log(` ${c.red}✗${c.reset} ${probe.accessor}(${probe.tokenId}) is NOT an on-chain data:application/json;base64 URI — got "${probe.uriPrefix}…"`);
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
ok(`${probe.accessor}(${probe.tokenId}) resolves ON-CHAIN: data:application/json;base64 — decoded from the contract, no server`);
|
|
657
|
+
const a = probe.animation;
|
|
658
|
+
if (a.form === 'data-html') {
|
|
659
|
+
if (a.marker)
|
|
660
|
+
console.log(` ${c.orange}⚠${c.reset} animation_url: data:text/html;base64 (${a.bytes} bytes decoded) but the document carries ${bold(a.marker)} — it degraded honestly instead of reverting`);
|
|
661
|
+
else
|
|
662
|
+
ok(`animation_url: data:text/html;base64 — ${a.bytes} bytes of HTML, decoded (the full document, inline)`);
|
|
663
|
+
}
|
|
664
|
+
else if (a.form === 'url') {
|
|
665
|
+
ok(`animation_url: ${a.url} ${dim('(directory branch — the parameterized gateway URL, landed verbatim)')}`);
|
|
666
|
+
}
|
|
667
|
+
else if (a.form === 'data-other') {
|
|
668
|
+
console.log(` ${c.orange}⚠${c.reset} animation_url is a data: URI but not text/html — got "${a.prefix}…"`);
|
|
669
|
+
}
|
|
670
|
+
else if (isCodeProject) {
|
|
671
|
+
console.log(` ${c.orange}⚠${c.reset} no animation_url in the on-chain JSON — the generator field may be missing or unrenderable`);
|
|
672
|
+
}
|
|
673
|
+
// else: a static image has no animation_url and is not supposed to — say nothing.
|
|
674
|
+
}
|
|
675
|
+
// The READ-side envelope. Params enumerate on-chain, so the write side is unbounded — but
|
|
676
|
+
// tokenURI and tokenData assemble EVERY enumerated param per call, and that is what grows.
|
|
677
|
+
// ~64 keys is the documented design envelope; hard failure only nears at several hundred.
|
|
678
|
+
// Advisory (never a ✗) — a big surface is a legitimate choice, it just isn't free.
|
|
679
|
+
const set = await readSetParamKeys(client, state.address, probe ? BigInt(probe.tokenId) : undefined);
|
|
680
|
+
if (set) {
|
|
681
|
+
const total = new Set([...set.contract, ...set.token]).size;
|
|
682
|
+
if (total > 64) {
|
|
683
|
+
console.log(` ${c.orange}⚠${c.reset} ${total} params enumerate on-chain${probe ? ` for token #${probe.tokenId}` : ''} — tokenURI and tokenData assemble every one of them per call, so both grow with this count. ` +
|
|
684
|
+
dim('The documented design envelope is ~64; past a few hundred a public eth_call can hit its gas cap.'));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
catch (e) {
|
|
689
|
+
// First line only. A viem read error carries a multi-line dump (Contract Call / args / Docs /
|
|
690
|
+
// Version) that is meaningless to a creator and buried the actual sentence when this fired.
|
|
691
|
+
const first = String(e.message ?? e).split('\n')[0].trim();
|
|
692
|
+
console.log(` ${c.orange}⚠${c.reset} on-chain URI check unavailable: ${first}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (renderGap)
|
|
696
|
+
console.log(`\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`);
|
|
697
|
+
else
|
|
698
|
+
console.log(anyCheck && allGood ? `\n ${g('✓ verified')} — the node serves exactly what the chain commits to.\n` : '\n');
|
|
699
|
+
// A byte-vs-chain mismatch is an integrity FAILURE, so fail the command. Verify's whole job is to
|
|
700
|
+
// answer "is what's served what the chain vouches for" — exiting 0 while printing ✗ meant nothing
|
|
701
|
+
// could gate on it (a script or CI would sail past a corrupted image). Deliberately narrow: a
|
|
702
|
+
// missing render / placeholder is a normal, expected state and still exits 0.
|
|
703
|
+
if (anyCheck && !allGood)
|
|
704
|
+
process.exitCode = 1;
|
|
705
|
+
}
|
|
706
|
+
// `abx verify <addr> --remote <url>` — verify what a HOSTED resolver actually serves (the local
|
|
707
|
+
// `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
|
|
708
|
+
// `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
|
|
709
|
+
// sees it — the truthful "did the thumbnail land?" check after a remote render.
|
|
710
|
+
export async function cmdVerifyRemote(address, remote, emit) {
|
|
711
|
+
const base = remote.url.replace(/\/$/, '');
|
|
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);
|
|
729
|
+
console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
|
|
730
|
+
// A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
|
|
731
|
+
// bare `fetch failed` says neither. Match `abx status --remote` so both commands describe the same
|
|
732
|
+
// condition consistently.
|
|
733
|
+
let stateRes;
|
|
734
|
+
try {
|
|
735
|
+
stateRes = await fetch(`${base}/api/project/${address}`);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
throw new Error(`verify: nothing responded at ${base}${remote.source === 'named' ? ` (from ABX_REMOTE_${remote.name}_URL)` : ''}. Is it running, and is that the right address?`);
|
|
739
|
+
}
|
|
740
|
+
if (!stateRes.ok) {
|
|
741
|
+
throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
|
|
742
|
+
}
|
|
743
|
+
const state = (await stateRes.json());
|
|
744
|
+
verifyReport.name = state.name ?? null;
|
|
745
|
+
info(`serving as "${state.name ?? address}"`);
|
|
746
|
+
// Is the resolver actively WATCHING the chain? Prove it from /api/watch (the meta the watcher
|
|
747
|
+
// stamps each tick) so a hosted operator who can't tail the log still sees liveness — and catches
|
|
748
|
+
// a silently-stalled watcher (pollAt gone stale) instead of assuming auto-updates still work.
|
|
749
|
+
const watch = (await fetch(`${base}/api/watch`)
|
|
750
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
751
|
+
.catch(() => null));
|
|
752
|
+
verifyReport.watching = watch?.watching ?? null;
|
|
753
|
+
emit(verifyReport);
|
|
754
|
+
if (watch?.watching) {
|
|
755
|
+
const heads = Object.entries(watch.chains).map(([ck, s]) => `${ck} @ block ${s.head}`).join(', ');
|
|
756
|
+
const ageS = watch.pollAt ? Math.round((Date.now() - Date.parse(watch.pollAt)) / 1000) : null;
|
|
757
|
+
const stalled = ageS !== null && ageS > Math.max(60, Math.round((Number(watch.intervalMs) / 1000) * 4));
|
|
758
|
+
const detail = `watching ${heads || 'chain'}${ageS !== null ? ` · last poll ${ageS}s ago` : ''}${watch.lastDeltaAt ? ` · last change ${watch.lastDeltaAt}` : ''}`;
|
|
759
|
+
if (stalled)
|
|
760
|
+
console.log(` ${c.orange}⚠${c.reset} ${detail} — watcher looks STALLED; restart \`abx serve\``);
|
|
761
|
+
else
|
|
762
|
+
ok(detail);
|
|
763
|
+
}
|
|
764
|
+
else if (watch) {
|
|
765
|
+
console.log(` ${dim('watcher OFF — changes land only via explicit add/index or a manual render (ABX_WATCH_INTERVAL_MS=0)')}`);
|
|
766
|
+
}
|
|
767
|
+
const minted = (state.tokens ?? []).filter((t) => t.lifecycle === 'live');
|
|
768
|
+
if (minted.length === 0) {
|
|
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);
|
|
772
|
+
console.log('');
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
// Prefer the resolver's effect-status API (the derived 4-state readout: up-to-date · rendering ·
|
|
776
|
+
// failed(error) · stale). Falls back to the raw /image probe for a resolver without the route.
|
|
777
|
+
const statusRes = await fetch(`${base}/api/project/${address}/effects`).catch(() => null);
|
|
778
|
+
if (statusRes?.ok) {
|
|
779
|
+
const report = (await statusRes.json());
|
|
780
|
+
for (const t of report.tokens) {
|
|
781
|
+
const label = `token #${t.tokenId} ${t.effectKey}`;
|
|
782
|
+
if (t.status === 'up-to-date')
|
|
783
|
+
ok(`${label}: up to date (real render at the current state)`);
|
|
784
|
+
else if (t.status === 'rendering')
|
|
785
|
+
info(`${label}: rendering — the effects runner is on it`);
|
|
786
|
+
else if (t.status === 'failed')
|
|
787
|
+
console.log(` ${c.red}✗${c.reset} ${label}: FAILED${t.attempts ? ` after ${t.attempts} attempt(s)` : ''} — ${t.error ?? 'see runner logs'} ${dim(`(fix, then \`abx render ${address} ${t.tokenId} --force --remote ${base}\`)`)}`);
|
|
788
|
+
else
|
|
789
|
+
console.log(` ${c.orange}⚠${c.reset} ${label}: stale — no render at the current state yet (the runner's next notify/sweep picks it up, or \`abx render ${address} --remote ${base}\`)`);
|
|
790
|
+
}
|
|
791
|
+
const { upToDate, stale, rendering, failed } = report.counts;
|
|
792
|
+
// RENDERS ONLY — say so. This report answers "is there a current render for each token", never
|
|
793
|
+
// "do the served bytes match the on-chain commitment"; those are different questions and this
|
|
794
|
+
// command promises the second one too. A project with no renders at all (a 1/1, an image Series)
|
|
795
|
+
// has nothing to be "up to date" ABOUT, so don't print a 0/N fraction — but don't let a green ✓
|
|
796
|
+
// here read as "the image is verified" either. Byte integrity comes from the check below.
|
|
797
|
+
if (report.tokens.length === 0) {
|
|
798
|
+
info(`renders ${dim('none for this project (a static image needs no off-chain render)')}`);
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
// Lead with the count that carries the polarity: "N of M current" never inverts on a skim the
|
|
802
|
+
// way "0/M up to date" does.
|
|
803
|
+
const summary = `${upToDate} of ${minted.length} token(s) current${rendering ? ` · ${rendering} rendering` : ''}${stale ? ` · ${stale} stale` : ''}${failed ? ` · ${failed} FAILED` : ''}`;
|
|
804
|
+
if (failed || stale)
|
|
805
|
+
console.log(` ${c.orange}⚠${c.reset} renders: ${summary} ${dim('— live view animates regardless; only the static thumbnail is affected.')}`);
|
|
806
|
+
else
|
|
807
|
+
ok(`renders: ${summary}`);
|
|
808
|
+
}
|
|
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);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
let gap = false;
|
|
820
|
+
let present = 0;
|
|
821
|
+
const missing = [];
|
|
822
|
+
for (const t of minted) {
|
|
823
|
+
const img = await fetch(`${base}/t/${chainId}/${address}/${t.tokenId}/image`, { redirect: 'manual' });
|
|
824
|
+
const loc = img.headers.get('location');
|
|
825
|
+
const ct = img.headers.get('content-type') ?? '';
|
|
826
|
+
if (img.status >= 300 && img.status < 400 && loc) {
|
|
827
|
+
present++;
|
|
828
|
+
ok(`token #${t.tokenId} image: real render — resolver 302s to ${loc}`);
|
|
829
|
+
}
|
|
830
|
+
else if (img.status === 200 && !/svg/i.test(ct)) {
|
|
831
|
+
present++;
|
|
832
|
+
ok(`token #${t.tokenId} image: real render present (${ct})`);
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
gap = true;
|
|
836
|
+
missing.push(t.tokenId);
|
|
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`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
console.log(gap
|
|
841
|
+
? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
|
|
842
|
+
: ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
|
|
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);
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
|
|
854
|
+
* commitment? Everything above it checks renders (is a thumbnail current, is it a placeholder) — a
|
|
855
|
+
* different question, and a green ✓ there was reading as "the image is correct" when the bytes could
|
|
856
|
+
* genuinely mismatch.
|
|
857
|
+
*
|
|
858
|
+
* The service is the right place to answer it: it holds both the bytes and the chain, and it already
|
|
859
|
+
* exposes exactly this check (`GET /api/project/:addr/verify`, bearer-gated because it triggers
|
|
860
|
+
* outbound fetches). When we can't reach that — no credential, or an older node — say plainly that
|
|
861
|
+
* byte integrity was NOT checked rather than leaving the ✓ above to imply it was.
|
|
862
|
+
*/
|
|
863
|
+
export async function reportRemoteByteIntegrity(address, remote, base) {
|
|
864
|
+
if (!remote.token) {
|
|
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.`);
|
|
866
|
+
return { status: 'not-checked', checks: [] };
|
|
867
|
+
}
|
|
868
|
+
let report;
|
|
869
|
+
try {
|
|
870
|
+
const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
|
|
871
|
+
if (res.status === 401 || res.status === 403) {
|
|
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).`);
|
|
873
|
+
return { status: 'not-checked', checks: [] };
|
|
874
|
+
}
|
|
875
|
+
if (!res.ok) {
|
|
876
|
+
// Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
|
|
877
|
+
// this is equally "an older self-hosted node" and "a provider that didn't implement it".
|
|
878
|
+
warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
|
|
879
|
+
`Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
|
|
880
|
+
`Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
|
|
881
|
+
return { status: 'not-checked', checks: [] };
|
|
882
|
+
}
|
|
883
|
+
report = (await res.json());
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
|
|
887
|
+
return { status: 'not-checked', checks: [] };
|
|
888
|
+
}
|
|
889
|
+
const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
|
|
890
|
+
if (checked.length === 0) {
|
|
891
|
+
info(`bytes ${dim('no on-chain byte commitment to check (this project commits no image hash)')}`);
|
|
892
|
+
return { status: 'no-commitments', checks: [] };
|
|
893
|
+
}
|
|
894
|
+
const flatChecks = checked.flatMap((t) => t.checks.map((k) => ({ tokenId: t.tokenId, kind: k.kind, verified: k.verified })));
|
|
895
|
+
const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
|
|
896
|
+
if (bad.length === 0) {
|
|
897
|
+
ok(`bytes: ${checked.length} token(s) hash-match their on-chain commitment ${dim('(what the service serves IS what the chain vouches for)')}`);
|
|
898
|
+
return { status: 'ok', checks: flatChecks };
|
|
899
|
+
}
|
|
900
|
+
const spec = remote.name?.toLowerCase() ?? base;
|
|
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 ` +
|
|
902
|
+
`${dim('(this is what a marketplace shows as a blank or placeholder image)')}. The chain is the truth, so the served copy is the wrong one. Two causes, two fixes:`);
|
|
903
|
+
info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
|
|
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.`);
|
|
905
|
+
process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
|
|
906
|
+
return { status: 'mismatch', checks: flatChecks };
|
|
907
|
+
}
|
|
908
|
+
// ── state: a one-glance operational snapshot of a deployed contract, read straight from chain ──
|
|
909
|
+
// Read-only (no tx, no local index). Series-only getters revert on a 1/1, so each read is defensive
|
|
910
|
+
// → the same command works for both. The agent-friendly "what's the state before/after an op" call.
|
|
911
|
+
/**
|
|
912
|
+
* The `seed source` readout — three states, and the distinction between them is the whole reason the
|
|
913
|
+
* line exists. `0x0` is a code project that deliberately draws no mint seed; the canonical address is
|
|
914
|
+
* the shared pseudorandom `AbxSeedSource` (whose properties are documented and NOT lottery-grade);
|
|
915
|
+
* anything else is the creator's own contract, about which ABX knows and claims nothing. A buyer or an
|
|
916
|
+
* agent reading this needs to be able to tell "the standard one" from "someone's custom randomness"
|
|
917
|
+
* at a glance — flattening them to a bare address hides exactly the fact worth surfacing.
|
|
918
|
+
*/
|
|
919
|
+
function describeSeedSource(source) {
|
|
920
|
+
if (source === zeroAddress)
|
|
921
|
+
return dim('none (0x0 — no mint-time seed drawn at mint)');
|
|
922
|
+
if (source.toLowerCase() === canonicalSeedSource(resolveChain(CHAIN).id).toLowerCase()) {
|
|
923
|
+
return `${source} ${dim('(canonical AbxSeedSource — pseudorandom, not lottery-grade)')}`;
|
|
924
|
+
}
|
|
925
|
+
return `${source} ${dim('— CUSTOM IAbxSeedSource (not the canonical one; its randomness properties are the project’s to state)')}`;
|
|
926
|
+
}
|
|
927
|
+
export const STATE_ABI = [
|
|
928
|
+
{ type: 'function', name: 'owner', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
929
|
+
{ type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
|
|
930
|
+
{ type: 'function', name: 'nextTokenId', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
|
|
931
|
+
{ type: 'function', name: 'maxInvocations', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
|
|
932
|
+
{ type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
933
|
+
{ type: 'function', name: 'minter', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
934
|
+
{ type: 'function', name: 'primaryPayee', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
935
|
+
{ type: 'function', name: 'tokenURIRenderer', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
936
|
+
{ type: 'function', name: 'royaltyInfo', stateMutability: 'view', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [{ type: 'address' }, { type: 'uint256' }] },
|
|
937
|
+
// ERC-721C (creator token) — ERC-165 advertises ICreatorToken ONLY when enrolled at deploy.
|
|
938
|
+
{ type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ type: 'bytes4' }], outputs: [{ type: 'bool' }] },
|
|
939
|
+
{ type: 'function', name: 'getTransferValidator', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
940
|
+
// The royalty ceiling + burn opt-in are read via the SDK's `readCollectionPolicy` (one owner of
|
|
941
|
+
// those two getters, shared with every other integrator) — deliberately not re-declared here.
|
|
942
|
+
// Seed Source extension — composed by CODE projects only, so absent (undefined) on an image token.
|
|
943
|
+
// Shown because it is the one setting an owner can re-point mid-sale that changes what a LATER
|
|
944
|
+
// buyer receives; `SeedSourceSet` puts it on the spine, and this makes it readable in one command.
|
|
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' }] },
|
|
956
|
+
];
|
|
957
|
+
/** The edition twin of {@link STATE_ABI} — `owner`/`maxInvocations`/`paused`/`minter`/
|
|
958
|
+
* `primaryPayee`/`tokenURIRenderer`/`royaltyInfo` are shared, unchanged, function names (see
|
|
959
|
+
* `kind.ts`'s own note on why); `totalSupply`/`maxSupply` take an id. The creator-token probe is
|
|
960
|
+
* ALSO shared, unchanged: Limit Break uses the identical `ICreatorToken` ERC-165 id (0xad0d7f6c)
|
|
961
|
+
* and `getTransferValidator()` surface for 721C and 1155C, and `CreatorToken1155` advertises them
|
|
962
|
+
* the same way when enrolled — so an enrolled edition reports its validator exactly like a 721. */
|
|
963
|
+
export const EDITION_STATE_ABI = [
|
|
964
|
+
{ type: 'function', name: 'owner', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
965
|
+
{ type: 'function', name: 'maxInvocations', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
|
|
966
|
+
{ type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [{ type: 'uint256' }], outputs: [{ type: 'uint256' }] },
|
|
967
|
+
{ type: 'function', name: 'maxSupply', stateMutability: 'view', inputs: [{ type: 'uint256' }], outputs: [{ type: 'uint256' }] },
|
|
968
|
+
{ type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
|
|
969
|
+
{ type: 'function', name: 'minter', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
970
|
+
{ type: 'function', name: 'primaryPayee', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
971
|
+
{ type: 'function', name: 'tokenURIRenderer', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
972
|
+
{ type: 'function', name: 'royaltyInfo', stateMutability: 'view', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [{ type: 'address' }, { type: 'uint256' }] },
|
|
973
|
+
// ERC-1155C (creator token) — identical ERC-165 id + getter as 721C; advertised only when enrolled.
|
|
974
|
+
{ type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ type: 'bytes4' }], outputs: [{ type: 'bool' }] },
|
|
975
|
+
{ type: 'function', name: 'getTransferValidator', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
|
|
976
|
+
// The royalty ceiling + burn opt-in are read via the SDK's `readCollectionPolicy` (one owner of
|
|
977
|
+
// those two getters, shared with every other integrator) — deliberately not re-declared here.
|
|
978
|
+
// Seed Source extension — EditionCode composes it; the image editions don't. See STATE_ABI's note.
|
|
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' }] },
|
|
987
|
+
];
|
|
988
|
+
/**
|
|
989
|
+
* The royalty ceiling line — and the headroom, which is the part a creator has not been told.
|
|
990
|
+
*
|
|
991
|
+
* A ceiling above the live rate means the owner can raise the rate to it unilaterally, and nobody
|
|
992
|
+
* looking at a listing can see that. So the useful sentence is not "your cap is 10%": it is that
|
|
993
|
+
* reducing the cap TO the current rate is what turns "5% today" into "5%, provably, forever".
|
|
994
|
+
*/
|
|
995
|
+
function royaltyCapLine(address, capBps, rateBps) {
|
|
996
|
+
const cap = `${capBps / 100}%`;
|
|
997
|
+
const tail = '(ceiling — owner-set at deploy, reduce-only)';
|
|
998
|
+
if (rateBps === null || capBps <= rateBps) {
|
|
999
|
+
return `${cap} ${dim(`${tail} — locked to the current rate: this royalty can never rise`)}`;
|
|
1000
|
+
}
|
|
1001
|
+
return (`${cap} ${dim(tail)}\n` +
|
|
1002
|
+
` ${dim(`↳ headroom: the rate is ${rateBps / 100}%, and the owner may raise it to ${cap} without asking anyone.`)}\n` +
|
|
1003
|
+
` ${dim(` \`abx set-royalty-cap ${address} --cap ${rateBps}\` makes today's rate permanent.`)}`);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Refuse an obsolete RPC variable before silently falling back to the public default. In the exact
|
|
1007
|
+
* failure this guards, ABX_RPC_URL held the reporter's dead endpoint, `state` ignored it, reached a
|
|
1008
|
+
* different node, and then described the contract instead of naming the misspelling. A valid
|
|
1009
|
+
* chain-specific or shared plural variable wins and suppresses this refusal.
|
|
1010
|
+
*/
|
|
1011
|
+
function assertNoIgnoredRpcEnv(address, env = process.env) {
|
|
1012
|
+
const chainVariable = rpcEnvVar(CHAIN);
|
|
1013
|
+
if (!env.ABX_RPC_URL || env.ABX_RPC_URLS || env[chainVariable])
|
|
1014
|
+
return;
|
|
1015
|
+
throw new Error(`ABX_RPC_URL (singular) is set but is not read. Rename it to ${chainVariable} or ABX_RPC_URLS; ` +
|
|
1016
|
+
`refusing to ignore an RPC setting while reading ${address}.`);
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Distinguish "the RPC is down" from "this address isn't an ABX token" before contract reads.
|
|
1020
|
+
* This catches endpoints that cannot answer even a head query; the required-getter diagnostics
|
|
1021
|
+
* below separately catch nodes that answer block/code probes but refuse `eth_call`.
|
|
1022
|
+
*/
|
|
1023
|
+
async function assertRpcReachable(publicClient, address) {
|
|
1024
|
+
try {
|
|
1025
|
+
await publicClient.getBlockNumber();
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
throw new Error(`could not reach the ${CHAIN} RPC (check ${rpcEnvVar(CHAIN)} or ABX_RPC_URLS).` +
|
|
1029
|
+
` This is not a fact about ${address}.`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
async function diagnosticStateRead(publicClient, address, abi, functionName, args = []) {
|
|
1033
|
+
try {
|
|
1034
|
+
const value = (await publicClient.readContract({ address, abi, functionName, args }));
|
|
1035
|
+
return { value };
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
return { error };
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
async function describeMissingAbxState(publicClient, address, requiredReadErrors) {
|
|
1042
|
+
let code;
|
|
1043
|
+
try {
|
|
1044
|
+
code = await publicClient.getCode({ address });
|
|
1045
|
+
}
|
|
1046
|
+
catch {
|
|
1047
|
+
return (`could not read ${address} on ${CHAIN} — the RPC failed mid-call ` +
|
|
1048
|
+
`(check ${rpcEnvVar(CHAIN)} or ABX_RPC_URLS).`);
|
|
1049
|
+
}
|
|
1050
|
+
if (!code || code === '0x') {
|
|
1051
|
+
return `no code at ${address} on ${CHAIN} — not an ABX token, or the wrong chain.`;
|
|
1052
|
+
}
|
|
1053
|
+
if (requiredReadErrors.some(isRpcReadFailure)) {
|
|
1054
|
+
return (`could not read ABX state for ${address} on ${CHAIN} — the RPC answered block/code requests ` +
|
|
1055
|
+
`but failed eth_call (rate-limited or backend unhealthy; check ${rpcEnvVar(CHAIN)} or ABX_RPC_URLS). ` +
|
|
1056
|
+
`This is not a contract verdict.`);
|
|
1057
|
+
}
|
|
1058
|
+
return `${address} has code on ${CHAIN} but is not an ABX token (no usable owner/supply getters).`;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* What a `canonical: NO` actually costs, said once and shared by both state readouts.
|
|
1062
|
+
*
|
|
1063
|
+
* A contract deployed outside a trust anchor is a real, working NFT — this is not a failure — but the
|
|
1064
|
+
* one property it can never gain is the one platforms allowlist against, and it cannot be granted
|
|
1065
|
+
* later at any price: canonicity is fixed at deploy by WHICH contract created it. So the readout says
|
|
1066
|
+
* what was lost and what the only remedy is (redeploy through the factory, migrate holders), rather
|
|
1067
|
+
* than printing a bare "NO" the reader has to interpret.
|
|
1068
|
+
*/
|
|
1069
|
+
function warnNotCanonical(address) {
|
|
1070
|
+
warn(`${address} was not created by an ABX trust anchor, so ${bold('isAbxClone is false')} for it. It still works as ` +
|
|
1071
|
+
`an NFT, but nothing that allowlists ABX collections — marketplaces, the App Store, this toolkit's own ` +
|
|
1072
|
+
`provenance checks — can recognize it as one, and that cannot be added afterwards: canonicity is decided ` +
|
|
1073
|
+
`by which contract deployed it. The only remedy is a fresh deploy through ${bold('abx deploy')} / ` +
|
|
1074
|
+
`${bold('deploy-series')} / ${bold('deploy-code')} and moving holders to it. If this was deliberate ` +
|
|
1075
|
+
`(a bespoke contract, a superseded factory), nothing here is broken — just make sure whoever owns the ` +
|
|
1076
|
+
`project knows they chose it.`);
|
|
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
|
+
}
|
|
1173
|
+
export async function cmdState(address, flags) {
|
|
1174
|
+
if (!address || address.startsWith('--')) {
|
|
1175
|
+
console.error('usage: abx state <address> — a read-only, on-chain operational snapshot\n');
|
|
1176
|
+
process.exitCode = 1;
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
// `--json` emits the same facts as data, so programs never need to parse prose. The human
|
|
1180
|
+
// narration below is unchanged and rides stderr under --json.
|
|
1181
|
+
return withJson(flags, async (emit) => cmdStateBody(address, flags, emit));
|
|
1182
|
+
}
|
|
1183
|
+
export async function cmdStateBody(address, flags, emit) {
|
|
1184
|
+
assertNoIgnoredRpcEnv(address);
|
|
1185
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1186
|
+
await assertRpcReachable(publicClient, address);
|
|
1187
|
+
// Edition family branches to its own (leaner) readout — supply/maxSupply are per-id, so "supply
|
|
1188
|
+
// X/Y minted" (a whole-contract fact on a 721) doesn't generalize; see cmdStateEditionBody.
|
|
1189
|
+
const kind = await detectTokenKind(publicClient, address);
|
|
1190
|
+
if (kind.isEdition)
|
|
1191
|
+
return cmdStateEditionBody(address, publicClient, kind, emit);
|
|
1192
|
+
// getter absent (extension not composed) or contract has no code → undefined, never a throw.
|
|
1193
|
+
const read = (functionName, args = []) => tryReadContract(publicClient, { address, abi: STATE_ABI, functionName, args });
|
|
1194
|
+
const [ownerRead, totalSupplyRead, nextTokenId, maxInvocations, paused, minter, primaryPayee, renderer, royalty, creatorToken, transferValidator, seedSource] = await Promise.all([
|
|
1195
|
+
diagnosticStateRead(publicClient, address, STATE_ABI, 'owner'),
|
|
1196
|
+
diagnosticStateRead(publicClient, address, STATE_ABI, 'totalSupply'),
|
|
1197
|
+
read('nextTokenId'),
|
|
1198
|
+
read('maxInvocations'),
|
|
1199
|
+
read('paused'),
|
|
1200
|
+
read('minter'),
|
|
1201
|
+
read('primaryPayee'),
|
|
1202
|
+
read('tokenURIRenderer'),
|
|
1203
|
+
read('royaltyInfo', [0n, 10000n]),
|
|
1204
|
+
read('supportsInterface', [CREATOR_TOKEN_INTERFACE_ID]),
|
|
1205
|
+
read('getTransferValidator'),
|
|
1206
|
+
read('seedSource'),
|
|
1207
|
+
]);
|
|
1208
|
+
const owner = ownerRead.value;
|
|
1209
|
+
const totalSupply = totalSupplyRead.value;
|
|
1210
|
+
if (owner === undefined && totalSupply === undefined) {
|
|
1211
|
+
throw new Error(await describeMissingAbxState(publicClient, address, [ownerRead.error, totalSupplyRead.error]));
|
|
1212
|
+
}
|
|
1213
|
+
// Trust, not just shape. Everything above is readable off ANY ERC-721-ish address, so without this
|
|
1214
|
+
// `state` described a hand-rolled contract exactly as confidently as a canonical clone. Canonicity
|
|
1215
|
+
// is what platforms allowlist and it can never be added after deploy, so it belongs in the snapshot.
|
|
1216
|
+
const provenance = await verifyProvenance(publicClient, address, resolveChain(CHAIN).id);
|
|
1217
|
+
const isCanonical = provenance.canonical;
|
|
1218
|
+
const isSeries = maxInvocations !== undefined; // series-only getter
|
|
1219
|
+
const addrOrNone = (a) => (a && a !== zeroAddress ? a : dim('none'));
|
|
1220
|
+
// The payload mirrors what is printed, with `undefined` (getter absent) kept distinct from a zero
|
|
1221
|
+
// address (present and deliberately unset) — collapsing those is what makes a readout lie.
|
|
1222
|
+
const zeroToNull = (a) => (a === undefined ? null : a === zeroAddress ? null : a);
|
|
1223
|
+
const { maxRoyaltyBps: royaltyCap, burnable } = await readCollectionPolicy(publicClient, address);
|
|
1224
|
+
const payload = jsonSafe({
|
|
1225
|
+
address,
|
|
1226
|
+
chain: CHAIN,
|
|
1227
|
+
chainId: resolveChain(CHAIN).id,
|
|
1228
|
+
type: isSeries ? 'series' : '1of1',
|
|
1229
|
+
canonical: isCanonical, // tri-state: true | false | null (couldn't check) — never collapse it
|
|
1230
|
+
// Provenance, not just trust: WHICH generation of the anchors stamped this clone, and the core
|
|
1231
|
+
// version it therefore reports. `prior` is "canonically ABX, older" — a different fact from
|
|
1232
|
+
// `canonical: false`, which the two used to share.
|
|
1233
|
+
abxGeneration: provenance.generation,
|
|
1234
|
+
abxCoreVersion: provenance.coreVersion,
|
|
1235
|
+
owner: owner ?? null,
|
|
1236
|
+
totalSupply: totalSupply ?? null,
|
|
1237
|
+
nextTokenId: nextTokenId ?? null,
|
|
1238
|
+
maxInvocations: maxInvocations ?? null,
|
|
1239
|
+
paused: paused ?? null,
|
|
1240
|
+
minter: zeroToNull(minter),
|
|
1241
|
+
primaryPayee: zeroToNull(primaryPayee),
|
|
1242
|
+
tokenURIRenderer: zeroToNull(renderer),
|
|
1243
|
+
onChainTokenUri: !!(renderer && renderer !== zeroAddress),
|
|
1244
|
+
royalty: royalty ? { receiver: royalty[0], bps: Number(royalty[1]) } : null,
|
|
1245
|
+
royaltyCap,
|
|
1246
|
+
burnable,
|
|
1247
|
+
creatorToken: !!creatorToken,
|
|
1248
|
+
transferValidator: creatorToken ? zeroToNull(transferValidator) : null,
|
|
1249
|
+
// `undefined` (no extension — an image token) and `0x0` (a code project that opted out) are
|
|
1250
|
+
// different facts; the JSON keeps them apart the same way every other getter here does.
|
|
1251
|
+
seedSource: zeroToNull(seedSource),
|
|
1252
|
+
hasSeedSource: seedSource !== undefined,
|
|
1253
|
+
// `enumerable: false` means "no governed keys were FOUND", never "none exist" — a legacy /
|
|
1254
|
+
// pre-enumeration / non-canonical contract can hold real PostParams this node cannot list at
|
|
1255
|
+
// all. Kept apart from `governed` so a reader cannot collapse "confirmed empty" and "unprovable"
|
|
1256
|
+
// into the same silence.
|
|
1257
|
+
params: { governed: [], contractScopeSet: [], enumerable: true },
|
|
1258
|
+
});
|
|
1259
|
+
emit(payload);
|
|
1260
|
+
console.log(bold(`\n ${isSeries ? 'Series' : '1/1'} state`) + dim(` ${address} · ${CHAIN}`));
|
|
1261
|
+
info(`canonical ${canonicalLabel(isCanonical, provenance)}`);
|
|
1262
|
+
if (isCanonical === false)
|
|
1263
|
+
warnNotCanonical(address);
|
|
1264
|
+
info(`owner ${owner ?? dim('?')}`);
|
|
1265
|
+
if (isSeries) {
|
|
1266
|
+
// Fully minted (totalSupply == maxInvocations) ⇒ fixed supply exhausted: the collection is
|
|
1267
|
+
// COMPLETE and `unpause` is moot (nothing left for anyone to mint). Say so, so a fully-minted
|
|
1268
|
+
// paused series doesn't read as "unfinished, go unpause".
|
|
1269
|
+
const soldOut = totalSupply !== undefined && maxInvocations !== undefined && totalSupply >= maxInvocations;
|
|
1270
|
+
info(`supply ${totalSupply ?? 0n} / ${maxInvocations} minted ${dim(soldOut ? '(complete — fixed supply fully minted)' : `(nextTokenId ${nextTokenId ?? 0n})`)}`);
|
|
1271
|
+
info(`paused ${paused ? `${c.orange}true${c.reset} ${dim(soldOut ? '— moot: all tokens minted, nothing left to mint' : '— owner-only minting until `abx unpause`')}` : `false ${dim('— open to minter/public')}`}`);
|
|
1272
|
+
info(`minter ${addrOrNone(minter)}${minter && minter !== zeroAddress ? '' : dim(' (owner-only)')}`);
|
|
1273
|
+
info(`primary payee ${addrOrNone(primaryPayee)}`);
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
info(`supply ${totalSupply ?? 0n} / 1 minted`);
|
|
1277
|
+
}
|
|
1278
|
+
if (royalty)
|
|
1279
|
+
info(`royalty ${Number(royalty[1]) / 100}% → ${royalty[0]}`);
|
|
1280
|
+
if (royaltyCap !== null)
|
|
1281
|
+
info(`royalty cap ${royaltyCapLine(address, royaltyCap, royalty ? Number(royalty[1]) : null)}`);
|
|
1282
|
+
if (burnable !== null)
|
|
1283
|
+
info(`burnable ${burnable ? `${c.orange}true${c.reset} ${dim('— holders may burn their own token')}` : dim('false — no token can be destroyed')}`);
|
|
1284
|
+
info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
|
|
1285
|
+
// ERC-721C: shown ONLY for an enrolled collection — an unenrolled token is a plain ERC-721
|
|
1286
|
+
// (indistinguishable from a pre-721C token) and shows nothing new here.
|
|
1287
|
+
if (creatorToken) {
|
|
1288
|
+
info(`721C validator ${transferValidator && transferValidator !== zeroAddress ? transferValidator : `${c.orange}suspended${c.reset} ${dim('(zero — enforcement off; re-enable with `abx set-transfer-validator`)')}`}`);
|
|
1289
|
+
}
|
|
1290
|
+
if (seedSource !== undefined)
|
|
1291
|
+
info(`seed source ${describeSeedSource(seedSource)}`);
|
|
1292
|
+
// The PostParam surface, read straight from chain: `paramSchemaKeys()` is the DECLARED (governed)
|
|
1293
|
+
// set — append-only, and it includes keys declared but never yet written, which nothing off-chain
|
|
1294
|
+
// could otherwise discover — and `contractParamKeys()` is the collection-scope set values. Both
|
|
1295
|
+
// are maintained by the contract's own write paths, so there is no list to drift. Read-only,
|
|
1296
|
+
// best-effort, and skipped entirely for a project that has none (a 1/1, a legacy pre-enumeration
|
|
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;
|
|
1302
|
+
try {
|
|
1303
|
+
const rawKeys = await readParamSchemaKeys(publicClient, address);
|
|
1304
|
+
const enumerable = rawKeys !== null;
|
|
1305
|
+
const keys = rawKeys ?? [];
|
|
1306
|
+
const rows = keys.length
|
|
1307
|
+
? await Promise.all(keys.map(async (key) => ({ key, s: await readParamSchema(publicClient, address, key) })))
|
|
1308
|
+
: [];
|
|
1309
|
+
const governed = rows.filter((r) => r.s.exists);
|
|
1310
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1311
|
+
payload.params.enumerable = enumerable;
|
|
1312
|
+
payload.params.governed = jsonSafe(governed.map(({ key, s }) => ({
|
|
1313
|
+
key,
|
|
1314
|
+
// Canonical NAMES, not the raw enum indices — a caller must not have to know the Solidity
|
|
1315
|
+
// enum's ordering to read this (the same reason `retired` is a boolean below).
|
|
1316
|
+
paramType: PARAM_TYPES[s.paramType] ?? String(s.paramType),
|
|
1317
|
+
auth: AUTH_OPTIONS[s.auth] ?? String(s.auth),
|
|
1318
|
+
authAddress: s.authAddress === zeroAddress ? null : s.authAddress,
|
|
1319
|
+
lockAfter: s.lockAfter,
|
|
1320
|
+
// A past lockAfter IS how a param is retired — surfaced as a boolean so a caller doesn't
|
|
1321
|
+
// have to re-derive the convention from a timestamp.
|
|
1322
|
+
retired: s.lockAfter !== 0 && s.lockAfter < now,
|
|
1323
|
+
selectOptions: s.selectOptions,
|
|
1324
|
+
})));
|
|
1325
|
+
emit(payload);
|
|
1326
|
+
if (governed.length) {
|
|
1327
|
+
console.log(`\n ${bold('PostParams')} ${dim(`${governed.length} governed · collectors/creator set these; the generator injects them`)}`);
|
|
1328
|
+
for (const { key, s } of governed) {
|
|
1329
|
+
const retired = s.lockAfter !== 0 && s.lockAfter < now;
|
|
1330
|
+
const locks = s.lockAfter !== 0 && !retired ? dim(` locks ${new Date(s.lockAfter * 1000).toISOString().slice(0, 10)}`) : '';
|
|
1331
|
+
const tag = retired ? ` ${c.orange}retired${c.reset}${dim(' — writes revert')}` : locks;
|
|
1332
|
+
console.log(` ${describeSchema({ key, paramType: s.paramType, auth: s.auth, authAddress: s.authAddress, lockAfter: 0, min: s.min, max: s.max, selectOptions: s.selectOptions })}${tag}`);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
else if (!enumerable) {
|
|
1336
|
+
// NOT "no PostParams" — this contract predates (or never implemented) the enumeration
|
|
1337
|
+
// extension, so `paramSchemaKeys()` itself is unreadable. Zero governed keys found here is
|
|
1338
|
+
// not proof zero exist; say so plainly rather than printing nothing, which reads as "confirmed
|
|
1339
|
+
// none" to a creator about to lock.
|
|
1340
|
+
console.log(`\n ${bold('PostParams')} ${c.orange}cannot be enumerated on this contract${c.reset} ${dim('(pre-enumeration or non-canonical) — this does NOT prove none exist, only that this node could not list them')}`);
|
|
1341
|
+
}
|
|
1342
|
+
// Collection-scope values (every token sees these). `seed` is per-token and never enumerated —
|
|
1343
|
+
// it is a tokenData coordinate, read directly.
|
|
1344
|
+
const set = await readSetParamKeys(publicClient, address);
|
|
1345
|
+
if (set?.contract.length) {
|
|
1346
|
+
payload.params.contractScopeSet = [...set.contract].sort();
|
|
1347
|
+
emit(payload);
|
|
1348
|
+
info(`${dim('contract-scope params set:')} ${[...set.contract].sort().join(', ')} ${dim('(apply to every token)')}`);
|
|
1349
|
+
}
|
|
1350
|
+
// The hooks, and whether they can still change. This is a BUYER's read, which is why it lives in
|
|
1351
|
+
// `state` next to the validator rather than only in `verify`: the transfer hook is a veto over
|
|
1352
|
+
// transfers and mints, so "which hooks, and are they frozen" is exactly the pair someone needs
|
|
1353
|
+
// before buying. Printed whenever the surface exists — including with all three unset, because
|
|
1354
|
+
// "none, and frozen" is the strongest thing this line can say and it must be visible.
|
|
1355
|
+
hooks = await readParamHooks(publicClient, address);
|
|
1356
|
+
if (hooks) {
|
|
1357
|
+
paramHooksLock = await readParamHooksLocked(publicClient, address);
|
|
1358
|
+
const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
|
|
1359
|
+
.filter(([, a]) => a !== zeroAddress)
|
|
1360
|
+
.map(([role, a]) => `${role} ${a}`);
|
|
1361
|
+
payload.params.hooks = jsonSafe({
|
|
1362
|
+
configureHook: hooks.configureHook === zeroAddress ? null : hooks.configureHook,
|
|
1363
|
+
augmentHook: hooks.augmentHook === zeroAddress ? null : hooks.augmentHook,
|
|
1364
|
+
transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
|
|
1365
|
+
// `null` = could not be established (see readParamHooksLocked). Never collapse it to false:
|
|
1366
|
+
// "unknown" and "the owner can still arm a transfer veto" are different answers.
|
|
1367
|
+
locked: paramHooksLock ?? null,
|
|
1368
|
+
});
|
|
1369
|
+
emit(payload);
|
|
1370
|
+
const lockNote = paramHooksLock === true
|
|
1371
|
+
? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
|
|
1372
|
+
: paramHooksLock === false
|
|
1373
|
+
? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
|
|
1374
|
+
: dim('freeze state unknown (the node refused the check)');
|
|
1375
|
+
info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
|
|
1376
|
+
if (hooks.transferHook !== zeroAddress) {
|
|
1377
|
+
info(dim(` the transfer hook is a VETO: if it reverts, the transfer fails — and mints too (mint = transfer from 0x0).`));
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
/* best-effort — a non-code project has no params surface to read */
|
|
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);
|
|
1388
|
+
console.log('');
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* The edition (ERC-1155) half of {@link cmdStateBody}: `owner`/`paused`/`minter`/`primaryPayee`/
|
|
1392
|
+
* `royalty`/`renderer` are the SAME facts, read the SAME way (shared, unchanged function names);
|
|
1393
|
+
* what differs is supply — there's no whole-contract `totalSupply()`, only a per-id one, so the
|
|
1394
|
+
* headline is id #0's copies (the flagship OneOfOneEdition case, and a representative sample for
|
|
1395
|
+
* EditionImage/EditionCode) with a pointer at `abx tokens` for the full per-id breakdown.
|
|
1396
|
+
*/
|
|
1397
|
+
async function cmdStateEditionBody(address, publicClient, kind, emit) {
|
|
1398
|
+
const read = (functionName, args = []) => tryReadContract(publicClient, { address, abi: EDITION_STATE_ABI, functionName, args });
|
|
1399
|
+
const [ownerRead, maxInvocations, paused, minter, primaryPayee, renderer, royalty, creatorToken, transferValidator, seedSource] = await Promise.all([
|
|
1400
|
+
diagnosticStateRead(publicClient, address, EDITION_STATE_ABI, 'owner'),
|
|
1401
|
+
read('maxInvocations'), // absent on a 1/1-edition — its id space is fixed to {0}
|
|
1402
|
+
read('paused'),
|
|
1403
|
+
read('minter'),
|
|
1404
|
+
read('primaryPayee'),
|
|
1405
|
+
read('tokenURIRenderer'),
|
|
1406
|
+
read('royaltyInfo', [0n, 10000n]),
|
|
1407
|
+
read('supportsInterface', [CREATOR_TOKEN_INTERFACE_ID]),
|
|
1408
|
+
read('getTransferValidator'),
|
|
1409
|
+
read('seedSource'),
|
|
1410
|
+
]);
|
|
1411
|
+
const owner = ownerRead.value;
|
|
1412
|
+
if (owner === undefined) {
|
|
1413
|
+
throw new Error(await describeMissingAbxState(publicClient, address, [ownerRead.error]));
|
|
1414
|
+
}
|
|
1415
|
+
const [supply0, maxSupply0, provenance] = await Promise.all([
|
|
1416
|
+
read('totalSupply', [0n]),
|
|
1417
|
+
read('maxSupply', [0n]),
|
|
1418
|
+
verifyProvenance(publicClient, address, resolveChain(CHAIN).id), // the edition twin owes the same answer
|
|
1419
|
+
]);
|
|
1420
|
+
const isCanonical = provenance.canonical;
|
|
1421
|
+
const addrOrNone = (a) => (a && a !== zeroAddress ? a : dim('none'));
|
|
1422
|
+
const zeroToNull = (a) => (a === undefined ? null : a === zeroAddress ? null : a);
|
|
1423
|
+
const { maxRoyaltyBps: royaltyCap, burnable } = await readCollectionPolicy(publicClient, address);
|
|
1424
|
+
const payload = jsonSafe({
|
|
1425
|
+
address,
|
|
1426
|
+
chain: CHAIN,
|
|
1427
|
+
chainId: resolveChain(CHAIN).id,
|
|
1428
|
+
type: kind.kind,
|
|
1429
|
+
canonical: isCanonical, // tri-state, same as the 721 readout
|
|
1430
|
+
abxGeneration: provenance.generation,
|
|
1431
|
+
abxCoreVersion: provenance.coreVersion,
|
|
1432
|
+
owner: owner ?? null,
|
|
1433
|
+
maxInvocations: maxInvocations ?? null,
|
|
1434
|
+
supply0: supply0 ?? null,
|
|
1435
|
+
maxSupply0: maxSupply0 ?? null,
|
|
1436
|
+
paused: paused ?? null,
|
|
1437
|
+
minter: zeroToNull(minter),
|
|
1438
|
+
primaryPayee: zeroToNull(primaryPayee),
|
|
1439
|
+
tokenURIRenderer: zeroToNull(renderer),
|
|
1440
|
+
onChainTokenUri: !!(renderer && renderer !== zeroAddress),
|
|
1441
|
+
royalty: royalty ? { receiver: royalty[0], bps: Number(royalty[1]) } : null,
|
|
1442
|
+
royaltyCap,
|
|
1443
|
+
burnable,
|
|
1444
|
+
creatorToken: !!creatorToken,
|
|
1445
|
+
transferValidator: creatorToken ? zeroToNull(transferValidator) : null,
|
|
1446
|
+
seedSource: zeroToNull(seedSource),
|
|
1447
|
+
hasSeedSource: seedSource !== undefined,
|
|
1448
|
+
params: { governed: [], contractScopeSet: [], enumerable: true },
|
|
1449
|
+
});
|
|
1450
|
+
emit(payload);
|
|
1451
|
+
console.log(bold(`\n ${describeKind(kind)} state`) + dim(` ${address} · ${CHAIN}`));
|
|
1452
|
+
info(`canonical ${canonicalLabel(isCanonical, provenance)}`);
|
|
1453
|
+
if (isCanonical === false)
|
|
1454
|
+
warnNotCanonical(address);
|
|
1455
|
+
info(`owner ${owner ?? dim('?')}`);
|
|
1456
|
+
const capNote = (cap) => (cap !== undefined && cap > 0n ? `${cap} cap` : 'open — no cap');
|
|
1457
|
+
if (kind.kind === '1of1-edition') {
|
|
1458
|
+
info(`supply ${supply0 ?? 0n} cop${(supply0 ?? 0n) === 1n ? 'y' : 'ies'} of #0 ${dim(`(${capNote(maxSupply0)})`)}`);
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
info(`id space up to ${maxInvocations ?? '?'} distinct id(s)`);
|
|
1462
|
+
info(`#0 copies ${supply0 ?? 0n} ${dim(`(${capNote(maxSupply0)} — per-id breakdown: \`abx tokens ${address}\`)`)}`);
|
|
1463
|
+
}
|
|
1464
|
+
info(`paused ${paused ? `${c.orange}true${c.reset} ${dim('— owner-only minting until `abx unpause`')}` : `false ${dim('— open to minter/public')}`}`);
|
|
1465
|
+
info(`minter ${addrOrNone(minter)}${minter && minter !== zeroAddress ? '' : dim(' (owner-only)')}`);
|
|
1466
|
+
info(`primary payee ${addrOrNone(primaryPayee)}`);
|
|
1467
|
+
if (royalty)
|
|
1468
|
+
info(`royalty ${Number(royalty[1]) / 100}% → ${royalty[0]}`);
|
|
1469
|
+
if (royaltyCap !== null)
|
|
1470
|
+
info(`royalty cap ${royaltyCapLine(address, royaltyCap, royalty ? Number(royalty[1]) : null)}`);
|
|
1471
|
+
if (burnable !== null)
|
|
1472
|
+
info(`burnable ${burnable ? `${c.orange}true${c.reset} ${dim('— holders may burn their own token')}` : dim('false — no token can be destroyed')}`);
|
|
1473
|
+
info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
|
|
1474
|
+
// ERC-1155C: shown ONLY for an enrolled collection — same rule (and same interface id) as 721C.
|
|
1475
|
+
if (creatorToken) {
|
|
1476
|
+
info(`1155C validator ${transferValidator && transferValidator !== zeroAddress ? transferValidator : `${c.orange}suspended${c.reset} ${dim('(zero — enforcement off; re-enable with `abx set-transfer-validator`)')}`}`);
|
|
1477
|
+
}
|
|
1478
|
+
if (seedSource !== undefined)
|
|
1479
|
+
info(`seed source ${describeSeedSource(seedSource)}`);
|
|
1480
|
+
// EditionCode has the same ConfigurableParams surface as SeriesCode. Keep the JSON shape and
|
|
1481
|
+
// human readout identical; ERC-1155 changes ownership semantics, not param discoverability.
|
|
1482
|
+
try {
|
|
1483
|
+
const rawKeys = await readParamSchemaKeys(publicClient, address);
|
|
1484
|
+
const enumerable = rawKeys !== null;
|
|
1485
|
+
const rows = rawKeys?.length
|
|
1486
|
+
? await Promise.all(rawKeys.map(async (key) => ({ key, s: await readParamSchema(publicClient, address, key) })))
|
|
1487
|
+
: [];
|
|
1488
|
+
const governed = rows.filter((r) => r.s.exists);
|
|
1489
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1490
|
+
payload.params.enumerable = enumerable;
|
|
1491
|
+
payload.params.governed = jsonSafe(governed.map(({ key, s }) => ({
|
|
1492
|
+
key,
|
|
1493
|
+
paramType: PARAM_TYPES[s.paramType] ?? String(s.paramType),
|
|
1494
|
+
auth: AUTH_OPTIONS[s.auth] ?? String(s.auth),
|
|
1495
|
+
authAddress: s.authAddress === zeroAddress ? null : s.authAddress,
|
|
1496
|
+
lockAfter: s.lockAfter,
|
|
1497
|
+
retired: s.lockAfter !== 0 && s.lockAfter < now,
|
|
1498
|
+
selectOptions: s.selectOptions,
|
|
1499
|
+
})));
|
|
1500
|
+
emit(payload);
|
|
1501
|
+
if (governed.length) {
|
|
1502
|
+
console.log(`\n ${bold('PostParams')} ${dim(`${governed.length} governed · shared by every copy of an id`)}`);
|
|
1503
|
+
for (const { key, s } of governed) {
|
|
1504
|
+
const retired = s.lockAfter !== 0 && s.lockAfter < now;
|
|
1505
|
+
const locks = s.lockAfter !== 0 && !retired ? dim(` locks ${new Date(s.lockAfter * 1000).toISOString().slice(0, 10)}`) : '';
|
|
1506
|
+
const tag = retired ? ` ${c.orange}retired${c.reset}${dim(' — writes revert')}` : locks;
|
|
1507
|
+
console.log(` ${describeSchema({ key, paramType: s.paramType, auth: s.auth, authAddress: s.authAddress, lockAfter: 0, min: s.min, max: s.max, selectOptions: s.selectOptions })}${tag}`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
else if (!enumerable) {
|
|
1511
|
+
console.log(`\n ${bold('PostParams')} ${c.orange}cannot be enumerated on this contract${c.reset} ${dim('(pre-enumeration or non-canonical) — this does NOT prove none exist')}`);
|
|
1512
|
+
}
|
|
1513
|
+
const set = await readSetParamKeys(publicClient, address);
|
|
1514
|
+
if (set?.contract.length) {
|
|
1515
|
+
payload.params.contractScopeSet = [...set.contract].sort();
|
|
1516
|
+
emit(payload);
|
|
1517
|
+
info(`${dim('contract-scope params set:')} ${[...set.contract].sort().join(', ')} ${dim('(apply to every id)')}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
/* best-effort — image edition families have no params surface */
|
|
1522
|
+
}
|
|
1523
|
+
// EditionCode composes ConfigurableParams exactly like SeriesCode, so an edition has hooks too —
|
|
1524
|
+
// and the transfer hook's veto reaches a mint, which on this lane is a buyer picking an id. Report
|
|
1525
|
+
// the same pair as the 721 body (which hooks, and whether they can still change); silent for the
|
|
1526
|
+
// image/1-of-1 edition twins, which have no params surface at all.
|
|
1527
|
+
const hooks = await readParamHooks(publicClient, address);
|
|
1528
|
+
let paramHooksLock;
|
|
1529
|
+
if (hooks) {
|
|
1530
|
+
paramHooksLock = await readParamHooksLocked(publicClient, address);
|
|
1531
|
+
const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
|
|
1532
|
+
.filter(([, a]) => a !== zeroAddress)
|
|
1533
|
+
.map(([role, a]) => `${role} ${a}`);
|
|
1534
|
+
payload.params.hooks = jsonSafe({
|
|
1535
|
+
configureHook: hooks.configureHook === zeroAddress ? null : hooks.configureHook,
|
|
1536
|
+
augmentHook: hooks.augmentHook === zeroAddress ? null : hooks.augmentHook,
|
|
1537
|
+
transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
|
|
1538
|
+
locked: paramHooksLock ?? null, // null = unknown; never collapse it to false
|
|
1539
|
+
});
|
|
1540
|
+
emit(payload);
|
|
1541
|
+
const lockNote = paramHooksLock === true
|
|
1542
|
+
? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
|
|
1543
|
+
: paramHooksLock === false
|
|
1544
|
+
? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
|
|
1545
|
+
: dim('freeze state unknown (the node refused the check)');
|
|
1546
|
+
info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
|
|
1547
|
+
if (hooks.transferHook !== zeroAddress) {
|
|
1548
|
+
info(dim(` the transfer hook is a VETO: if it reverts, the transfer fails — and mints too (mint = transfer from 0x0).`));
|
|
1549
|
+
}
|
|
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);
|
|
1554
|
+
console.log('');
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* `abx status [address] [--remote [name|url]] [--watch]` — INDEXING status: where a project sits in
|
|
1558
|
+
* the lifecycle (`queued | backfilling | live | stale | failed`) and how far behind head it is.
|
|
1559
|
+
*
|
|
1560
|
+
* One vocabulary for both sides of the membrane, which is the point: bare = this node, `--remote` =
|
|
1561
|
+
* ask the service, and the same five words either way. Distinct from `abx state <address>`, which
|
|
1562
|
+
* reads the CHAIN (owner, royalty, locks) and knows nothing about who is serving it.
|
|
1563
|
+
*/
|
|
1564
|
+
export async function cmdStatus(address, flags) {
|
|
1565
|
+
// `abx status --remote <name>` has no address — without this guard the flag itself lands in
|
|
1566
|
+
// rest[0] and gets sent as the address path segment (the same guard every other command applies).
|
|
1567
|
+
if (address?.startsWith('--'))
|
|
1568
|
+
address = undefined;
|
|
1569
|
+
const remote = remoteFlag(flags);
|
|
1570
|
+
if (remote)
|
|
1571
|
+
return cmdStatusRemote(address, remote, flags);
|
|
1572
|
+
const indexer = localIndexer();
|
|
1573
|
+
const regs = indexer.store.listRegistrations();
|
|
1574
|
+
if (address) {
|
|
1575
|
+
const reg = indexer.store.getRegistration(address);
|
|
1576
|
+
if (!reg)
|
|
1577
|
+
throw new Error(`${address} isn't tracked by this node. Add it with \`abx add ${address}\`, or ask a service: \`abx status ${address} --remote <name>\``);
|
|
1578
|
+
const s = indexer.getProject(address);
|
|
1579
|
+
const row = indexer.indexStatus(address);
|
|
1580
|
+
const head = indexer.store.getMeta(`watch:${reg.chainKey}:head`);
|
|
1581
|
+
// Don't print the address twice when there's no name to lead with.
|
|
1582
|
+
console.log(s?.name ? `\n ${bold(s.name)} ${dim(address)}` : `\n ${bold(address)}`);
|
|
1583
|
+
statusRow('status', statusLine({
|
|
1584
|
+
chainId: resolveChain(reg.chainKey).id,
|
|
1585
|
+
address,
|
|
1586
|
+
status: row.status,
|
|
1587
|
+
fromBlock: reg.fromBlock,
|
|
1588
|
+
toBlock: s?.toBlock ?? null,
|
|
1589
|
+
headBlock: head,
|
|
1590
|
+
eventCount: s?.eventCount ?? 0,
|
|
1591
|
+
tokenCount: s?.tokens.length ?? 0,
|
|
1592
|
+
mintedCount: 0,
|
|
1593
|
+
reconstructedAt: s?.reconstructedAt ?? null,
|
|
1594
|
+
...(row.errorClass ? { error: { class: row.errorClass, message: row.errorMessage ?? undefined } } : {}),
|
|
1595
|
+
}));
|
|
1596
|
+
statusRow('floor', `${reg.fromBlock}${row.attempts ? dim(` attempts ${row.attempts}`) : ''}`);
|
|
1597
|
+
statusRow('indexed', s ? `${s.eventCount} events · ${s.tokens.length} token(s) ${dim(`· ${s.reconstructedAt}`)}` : dim('no projection yet'));
|
|
1598
|
+
if (!head)
|
|
1599
|
+
statusRow('head', dim("unknown — this node isn't watching the chain (ABX_WATCH_INTERVAL_MS=0, or `abx serve` isn't running)"));
|
|
1600
|
+
console.log('');
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
console.log(bold(`\n ABX self-host node`));
|
|
1604
|
+
info(`chain: ${CHAIN} · factory: ${factoryAddress() ?? 'none'} · storage: ${activeBackendId()} · data: ${indexer.store.path}`);
|
|
1605
|
+
if (regs.length === 0) {
|
|
1606
|
+
console.log(dim('\n No projects yet. `abx demo` to create one.\n'));
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
console.log('');
|
|
1610
|
+
for (const reg of regs) {
|
|
1611
|
+
const s = indexer.getProject(reg.address);
|
|
1612
|
+
const st = indexer.indexStatus(reg.address).status;
|
|
1613
|
+
const mark = st === 'failed' || st === 'stale' ? `${c.orange}●${c.reset}` : s ? g('●') : dim('○');
|
|
1614
|
+
const tail = s ? `${s.eventCount} events` : dim('(registered, not indexed)');
|
|
1615
|
+
console.log(` ${mark} ${s?.name ?? reg.address} ${dim(reg.address)} ${tail} ${statusLabel(st)}`);
|
|
1616
|
+
}
|
|
1617
|
+
console.log(dim(`\n one project in detail: abx status <address>\n`));
|
|
1618
|
+
}
|
|
1619
|
+
/** The `--remote` half of {@link cmdStatus}: one project, or the roll-up for every project the token
|
|
1620
|
+
* can see. `--watch` tails until everything reaches a terminal state. */
|
|
1621
|
+
export async function cmdStatusRemote(address, remote, flags) {
|
|
1622
|
+
requireRemoteToken(remote);
|
|
1623
|
+
const client = serviceClient(remote);
|
|
1624
|
+
const chainId = resolveChain(CHAIN).id;
|
|
1625
|
+
const watch = flags.watch !== undefined;
|
|
1626
|
+
const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
|
|
1627
|
+
info(`${bold('REMOTE')} → ${remote.url} ${dim('(indexing status as the service reports it)')}`);
|
|
1628
|
+
if (address) {
|
|
1629
|
+
for (;;) {
|
|
1630
|
+
let s;
|
|
1631
|
+
try {
|
|
1632
|
+
s = await client.projectStatus(chainId, address);
|
|
1633
|
+
}
|
|
1634
|
+
catch (err) {
|
|
1635
|
+
throw describeRemoteError(err, remote, 'remote status');
|
|
1636
|
+
}
|
|
1637
|
+
console.log(`\n ${bold(address)}`);
|
|
1638
|
+
statusRow('status', statusLine(s));
|
|
1639
|
+
statusRow('floor', `${s.fromBlock}${s.attempts ? dim(` attempts ${s.attempts}`) : ''}`);
|
|
1640
|
+
statusRow('indexed', `${s.eventCount} events · ${s.tokenCount} token(s)${s.reconstructedAt ? dim(` · ${s.reconstructedAt}`) : ''}`);
|
|
1641
|
+
// Spell out what `watching` means on the line itself; a bare `no` does not say whether it is a
|
|
1642
|
+
// problem (it usually is not).
|
|
1643
|
+
if (s.watcher) {
|
|
1644
|
+
statusRow('watching', s.watcher.watching
|
|
1645
|
+
? `${g('yes')}${s.watcher.head ? dim(` · head ${s.watcher.head}`) : ''}${dim(' — it tails new blocks, so on-chain changes land on their own')}`
|
|
1646
|
+
: `${dim('no')} ${dim('— this service updates on an explicit add/index, not by tailing new blocks (normal for many providers)')}`);
|
|
1647
|
+
}
|
|
1648
|
+
// Don't leave a creator staring at a red word with no next move. `failed` especially reads as
|
|
1649
|
+
// terminal when it isn't — name what it means and the one command that follows it.
|
|
1650
|
+
if (!watch && s.status !== 'live') {
|
|
1651
|
+
if (s.status === 'failed' && s.error)
|
|
1652
|
+
statusRow('what now', indexErrorAction(s.error.class));
|
|
1653
|
+
statusRow('follow', dim(`this is not final — ${bold(`abx status ${address} --remote${spec ? ` ${spec}` : ''} --watch`)} tails it until it settles`));
|
|
1654
|
+
}
|
|
1655
|
+
console.log('');
|
|
1656
|
+
if (!watch || s.status === 'live' || s.status === 'failed')
|
|
1657
|
+
return;
|
|
1658
|
+
await sleep(3000);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
for (;;) {
|
|
1662
|
+
let projects;
|
|
1663
|
+
try {
|
|
1664
|
+
projects = await client.listProjects();
|
|
1665
|
+
}
|
|
1666
|
+
catch (err) {
|
|
1667
|
+
throw describeRemoteError(err, remote, 'remote status');
|
|
1668
|
+
}
|
|
1669
|
+
if (projects.length === 0) {
|
|
1670
|
+
console.log(dim('\n no projects visible to this token\n'));
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
console.log('');
|
|
1674
|
+
for (const p of projects) {
|
|
1675
|
+
console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(p.address)} ` +
|
|
1676
|
+
`${p.status ? statusLabel(p.status) : dim('status not reported')}` +
|
|
1677
|
+
`${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''} ${dim(`${p.tokenCount ?? '?'} token(s)`)}`);
|
|
1678
|
+
}
|
|
1679
|
+
console.log(`\n ${rollUp(projects)}\n`);
|
|
1680
|
+
const settled = projects.every((p) => !p.status || p.status === 'live' || p.status === 'failed');
|
|
1681
|
+
if (!watch || settled)
|
|
1682
|
+
return;
|
|
1683
|
+
await sleep(3000);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
// ── forget ────────────────────────────────────────────────────────────────--
|
|
1687
|
+
// Drop a project this node tracks (registration + projection) — for cleaning up
|
|
1688
|
+
// test/junk deploys. On-chain data is untouched; `abx add` can re-register it.
|
|
1689
|
+
export async function cmdForget(address, flags) {
|
|
1690
|
+
if (!address || address.startsWith('--')) {
|
|
1691
|
+
console.error('usage: abx forget <address> [--remote [name|url]]\n');
|
|
1692
|
+
process.exitCode = 1;
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
const remote = remoteFlag(flags);
|
|
1696
|
+
if (remote) {
|
|
1697
|
+
requireRemoteToken(remote);
|
|
1698
|
+
info(`${bold('REMOTE')} → ${remote.url} ${dim('(deregistering on the remote resolver — NOT this machine)')}`);
|
|
1699
|
+
let removed;
|
|
1700
|
+
try {
|
|
1701
|
+
({ removed } = await serviceClient(remote).removeProject(resolveChain(CHAIN).id, address));
|
|
1702
|
+
}
|
|
1703
|
+
catch (err) {
|
|
1704
|
+
throw describeRemoteError(err, remote, 'remote forget');
|
|
1705
|
+
}
|
|
1706
|
+
if (removed)
|
|
1707
|
+
ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
|
|
1708
|
+
else
|
|
1709
|
+
console.log(dim(` ${address} wasn't registered on ${remote.url} — nothing to forget.`));
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const indexer = localIndexer();
|
|
1713
|
+
if (!indexer.store.getRegistration(address)) {
|
|
1714
|
+
console.log(dim(` ${address} isn't tracked by this node — nothing to forget.`));
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
indexer.store.deregister(address);
|
|
1718
|
+
ok(`forgot ${address} — dropped its registration + projection. On-chain data is untouched.`);
|
|
1719
|
+
}
|
|
1720
|
+
//# sourceMappingURL=project.js.map
|