@artblocks/abx-cli 0.1.0-alpha.4 → 0.1.0-alpha.5

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/dist/main.js CHANGED
@@ -43,7 +43,7 @@
43
43
  * the tx for a multisig / offline signer). The agent picks the lane; the CLI signs.
44
44
  */
45
45
  import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
46
- import { randomBytes } from 'node:crypto';
46
+ import { createHash, randomBytes } from 'node:crypto';
47
47
  import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
48
48
  import { fileURLToPath } from 'node:url';
49
49
  import { homedir } from 'node:os';
@@ -986,7 +986,9 @@ async function cmdDeploy(flags, serveAfter) {
986
986
  // editable later via `abx add --traits`); `--traits-onchain` inlines them on-chain (durable,
987
987
  // lockable) — mirrors the description model. On-chain wins if both ever set the same trait.
988
988
  const traits = parseDeployTraits(flags);
989
- console.log(bold(`\n ABX Self-Host Toolkit — ${serveAfter ? 'demo' : 'deploy'}\n ${dim('a project, served from chain alone')}`));
989
+ console.log(serveAfter
990
+ ? bold(`\n ABX — guided first run\n ${dim('a token deployed, then rebuilt from the chain alone')}`)
991
+ : bold(`\n ABX Self-Host Toolkit — deploy\n ${dim('a project, served from chain alone')}`));
990
992
  // Signing lane. `demo` used to hard-force the hot lane, which SILENTLY dropped `--sign`: with no
991
993
  // env key it died confusingly, and WITH one it signed from that key while the operator had asked
992
994
  // for their browser wallet — a signing choke point that ignored the lane it was handed. The demo
@@ -1005,6 +1007,17 @@ async function cmdDeploy(flags, serveAfter) {
1005
1007
  throw new Error('`abx demo --dry-run` can\'t work: a dry run sends nothing, and the demo indexes + serves what ' +
1006
1008
  'it deployed. Use `abx deploy --dry-run` to preview a 1/1 deploy without sending.');
1007
1009
  }
1010
+ // Port preflight BEFORE anything irreversible. The demo ends by serving, and `listen` used to be
1011
+ // the first thing to discover the port was taken — after the deploy tx had already been signed and
1012
+ // paid for, so a second `abx demo` (a very normal thing to try) spent gas and then died with a raw
1013
+ // Node EADDRINUSE stack trace. Check first, and name the fix.
1014
+ if (serveAfter) {
1015
+ const wanted = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
1016
+ if (await portInUse(wanted)) {
1017
+ throw new Error(`port ${wanted} is already in use — probably an \`abx demo\`/\`abx serve\` still running in another terminal.\n` +
1018
+ ` Nothing was deployed. Stop that one (Ctrl-C), or run this on another port: \`abx demo --port ${wanted + 1}\`.`);
1019
+ }
1020
+ }
1008
1021
  const dryRun = !serveAfter && !!flags['dry-run']; // preview only — no send, no custody, no factory deploy
1009
1022
  const publicClient = makePublicClient({ chainKey: CHAIN });
1010
1023
  // Verify the RPC really is CHAIN before any send (factory/renderer/staging/deploy). A dry run
@@ -1050,6 +1063,12 @@ async function cmdDeploy(flags, serveAfter) {
1050
1063
  else {
1051
1064
  factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory']);
1052
1065
  }
1066
+ // The demo explains what it just did; `deploy` stays terse. The authenticity point is the least
1067
+ // obvious thing about ABX and the easiest to get wrong, so it's worth a sentence here.
1068
+ if (serveAfter) {
1069
+ info(dim('why it matters: a contract counts as ABX because THIS factory made it (isAbxClone),'));
1070
+ info(dim('not because it emitted an event claiming to be — anyone can emit that event.'));
1071
+ }
1053
1072
  // --onchain-uri: resolve tokenURI/contractURI fully on-chain via the canonical renderer
1054
1073
  // (the JSON is assembled from on-chain fields → no resolver needed, ever). The off-chain
1055
1074
  // URLs are still baked as a fallback if the renderer is later cleared.
@@ -1076,7 +1095,13 @@ async function cmdDeploy(flags, serveAfter) {
1076
1095
  renderer = await ensureRenderer(flags.renderer);
1077
1096
  }
1078
1097
  }
1079
- step(`Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1098
+ step(serveAfter ? `Deploy — one transaction to ${CHAIN}` : `Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1099
+ // The custody split is the single most useful thing to understand about an ABX token, and it's
1100
+ // invisible unless someone says it out loud. Demo only — `deploy` prints the same facts per-field.
1101
+ if (serveAfter) {
1102
+ info(dim(`on chain: name · symbol · royalty · a tokenURI base · a keccak256 commitment to the image`));
1103
+ info(dim(`NOT on chain: the image bytes themselves — this node serves them, the chain pins their hash`));
1104
+ }
1080
1105
  // Off-chain custody (no renderer) bakes the resolver URL straight into the on-chain
1081
1106
  // tokenURI/contractURI at deploy. A localhost / loopback URL there resolves for NO ONE —
1082
1107
  // not marketplaces, not wallets, not even your own browser unless `abx serve` is running —
@@ -1374,10 +1399,13 @@ async function cmdDeploy(flags, serveAfter) {
1374
1399
  attributes: offChainTraits,
1375
1400
  };
1376
1401
  indexer.register(baseReg);
1377
- const { state, elapsedMs } = await indexer.reindex(clone);
1378
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1402
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
1403
+ if (state.eventCount > 0)
1404
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1379
1405
  info(`name "${state.name}" · owner ${state.owner} · canonical: ${state.isCanonical ? 'yes (factory-verified)' : 'unverified'}`);
1380
1406
  info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1407
+ if (serveAfter)
1408
+ walkthroughSpine(state);
1381
1409
  // For off-chain custody (image committed as keccak256), resolve the durable locator (ipfs://…)
1382
1410
  // from this machine's content index and store it on the registration — so the LOCAL resolver
1383
1411
  // points `image` at IPFS, and `abx add --remote` can ship it to a hosted one (the localhost-image fix).
@@ -1426,8 +1454,17 @@ async function cmdDeploy(flags, serveAfter) {
1426
1454
  }
1427
1455
  return;
1428
1456
  }
1429
- step('Serve the token API + dashboard');
1457
+ // The demo's teaching sections — see the walkthrough helpers. `deploy` skips them: someone
1458
+ // shipping real work doesn't need their projection deleted to make a point.
1459
+ if (serveAfter)
1460
+ await walkthroughRebuild(indexer, clone, state);
1461
+ // Start the server BEFORE the read-back step (which fetches the served metadata over HTTP), but
1462
+ // print the serve banner after it — otherwise the "Serve" step header lands with nothing under it
1463
+ // while the read-back prints below, which reads like the step failed.
1430
1464
  const { url } = await startTokenApiServer({ indexer, port, baseUrl, storage: resolveBackend(storageOptions()) });
1465
+ if (serveAfter)
1466
+ await walkthroughReadBack(state, url);
1467
+ step('Serve the token API + dashboard');
1431
1468
  printServing(url, clone);
1432
1469
  keepAlive();
1433
1470
  }
@@ -1898,8 +1935,9 @@ async function cmdDeploySeries(flags) {
1898
1935
  tokenAttributes: offChainTokenTraits,
1899
1936
  };
1900
1937
  indexer.register(baseReg);
1901
- const { state, elapsedMs } = await indexer.reindex(clone);
1902
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
1938
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
1939
+ if (state.eventCount > 0)
1940
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
1903
1941
  info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1904
1942
  // Off-chain custody: bridge each token's keccak → durable locator so the resolver (local and,
1905
1943
  // via `abx add --remote`, a hosted one) points images off this node.
@@ -2163,7 +2201,11 @@ async function cmdAdd(address, flags) {
2163
2201
  info(`scanning blocks ${start}${dim(' → ')}${head} ${dim(`(~${span} blocks)`)} — on a range-capped RPC (see ${g('abx doctor')}) this can take a few minutes with no per-block output; leave it running.`);
2164
2202
  }
2165
2203
  catch { /* advisory only — the real scan still runs */ }
2166
- const { state, elapsedMs } = await indexer.reindex(address);
2204
+ // Don't accept a zero here either: `deploy-code` finishes through this command, so this IS the
2205
+ // post-deploy index for a code project — and a real ABX clone always emits a spine (its extension
2206
+ // registrations at minimum), so 0 events means the RPC hasn't served the logs yet, not that the
2207
+ // project is empty. See reindexAfterDeploy.
2208
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, address);
2167
2209
  // Resolve durable locators for off-chain-by-hash content from this machine's index.
2168
2210
  const locators = await collectContentLocators(state, resolveBackend(storageOptions(storageOverrides(flags))));
2169
2211
  if (Object.keys(locators).length) {
@@ -2180,7 +2222,14 @@ async function cmdAdd(address, flags) {
2180
2222
  contentLocators: JSON.stringify(locators),
2181
2223
  });
2182
2224
  }
2183
- ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2225
+ // A on 0 events is the lie that produced an empty dashboard; reindexAfterDeploy has already
2226
+ // explained the failure and named the recovery command, so don't stamp it as success too.
2227
+ if (state.eventCount > 0) {
2228
+ ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2229
+ }
2230
+ else {
2231
+ warn(`registered ${state.name ?? address}, but with NO reconstructed state — it will serve empty until the index succeeds.`);
2232
+ }
2184
2233
  info(`serve it from here with ${bold('abx serve')} — or push it to a hosted resolver with ${bold('abx add ' + address + ' --remote')}`);
2185
2234
  }
2186
2235
  /** The code-project trust anchor: use the canonical factory, else deploy one (a sandbox /
@@ -2247,6 +2296,158 @@ async function ensureSeedSource(publicClient) {
2247
2296
  * Schemas: --schema key:Type:Auth[,key:Type:Auth…] (e.g. palette:HexColor:TokenOwner).
2248
2297
  * Seeds: canonical randomizer by default; --no-seed opts out.
2249
2298
  */
2299
+ // ── the demo walkthrough: teaching sections, demo-only ────────────────────────
2300
+ //
2301
+ // `abx demo` is a TEACHING command, not a shortcut — the docs point a first-time reader here to
2302
+ // learn what the toolkit does on their behalf. Its old form asserted the interesting claims
2303
+ // ("reconstructed 9 events — no provider involved") without ever showing them, which made it a
2304
+ // smoke test wearing a demo's clothes. These sections demonstrate instead: print the spine the
2305
+ // chain now holds, throw the local projection away and rebuild it, then read the token back the way
2306
+ // a marketplace would. They run only for `demo` (never `deploy`), and never pause — an agent or CI
2307
+ // run has to behave identically.
2308
+ /**
2309
+ * The deterministic part of a projection: everything that is a pure function of the chain.
2310
+ * Deliberately EXCLUDES `reconstructedAt`, `rpcUrl` and `toBlock` — a timestamp, the endpoint that
2311
+ * happened to answer, and the head at scan time all legitimately differ between two replays, so
2312
+ * folding them in would make the rebuild proof fail for reasons that aren't about correctness.
2313
+ */
2314
+ function projectionFingerprint(s) {
2315
+ const canonical = {
2316
+ address: s.address.toLowerCase(),
2317
+ name: s.name,
2318
+ symbol: s.symbol,
2319
+ owner: s.owner?.toLowerCase() ?? null,
2320
+ isCanonical: s.isCanonical,
2321
+ deployBlock: s.deployBlock,
2322
+ eventCount: s.eventCount,
2323
+ royalty: s.royalty ? { bps: s.royalty.bps, receiver: s.royalty.receiver.toLowerCase() } : null,
2324
+ extensions: s.extensions.map((e) => e.name).sort(),
2325
+ collectionFields: s.collectionFields.map((f) => `${f.field}=${f.value}`).sort(),
2326
+ tokens: s.tokens.map((t) => ({ id: t.tokenId, minted: t.minted, owner: t.owner?.toLowerCase() ?? null })),
2327
+ events: s.events.map((e) => `${e.blockNumber}:${e.logIndex}:${e.name}`),
2328
+ };
2329
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
2330
+ }
2331
+ /**
2332
+ * Print the reconstructed spine — the point being that this list IS the database. Rendered inside
2333
+ * the index step (no header of its own: "Index it — replay the event spine" immediately followed by
2334
+ * a separate "The event spine" step read as a stutter).
2335
+ */
2336
+ function walkthroughSpine(state) {
2337
+ if (state.events.length === 0) {
2338
+ warn('no events to show (the index came back empty — see the recovery hint above).');
2339
+ return;
2340
+ }
2341
+ const width = Math.max(...state.events.map((e) => e.name.length));
2342
+ state.events.forEach((e, i) => {
2343
+ // ERC vs ABX register: standard ERC-721 events a marketplace already understands, versus ABX's
2344
+ // own. Worth surfacing — it's why an ABX token indexes fine on tools that know nothing about ABX.
2345
+ const reg = e.register === 2 ? p('ABX') : dim('ERC');
2346
+ console.log(` ${dim(`#${String(i + 1).padStart(2)}`)} ${reg} ${e.name.padEnd(width)} ${dim(e.what)}`);
2347
+ });
2348
+ console.log(` ${dim('→')} those ${bold(String(state.events.length))} rows are the entire database — ` +
2349
+ dim('no row in a server anywhere is authoritative.'));
2350
+ console.log(` ${p('ABX')} ${dim('= a native ABX event')} ${dim('ERC')} ${dim('= plain ERC-721/7572, so wallets and marketplaces that have never heard of ABX still index this token.')}`);
2351
+ }
2352
+ /**
2353
+ * [4] The claim, demonstrated: delete the local projection and rebuild it from the chain.
2354
+ *
2355
+ * This is the one step that can't be faked by good output — it drops the projection for real
2356
+ * (registration kept), confirms it's gone, replays from the deploy block, and compares a
2357
+ * fingerprint of everything chain-derived. If ABX's premise is wrong, this step fails loudly.
2358
+ */
2359
+ async function walkthroughRebuild(indexer, address, before) {
2360
+ step('Prove it — rebuild from nothing');
2361
+ const fpBefore = projectionFingerprint(before);
2362
+ indexer.dropProjection(address);
2363
+ const gone = indexer.getProject(address) === null;
2364
+ console.log(` ${dim('deleting this machine\'s projection …')} ${gone ? g('✓ gone') : `${c.orange}⚠ still present${c.reset}`}`);
2365
+ const { state: after, elapsedMs } = await indexer.reindex(address, { full: true });
2366
+ const fpAfter = projectionFingerprint(after);
2367
+ console.log(` ${dim(`replaying from block ${after.deployBlock ?? after.fromBlock} …`)} ` +
2368
+ `${g('✓')} ${after.eventCount} events in ${elapsedMs}ms`);
2369
+ if (fpBefore === fpAfter) {
2370
+ ok(`identical state ${dim(`(sha256 ${fpAfter.slice(0, 12)}… over every chain-derived field)`)}`);
2371
+ info('no provider, no backup, no API key — an RPC endpoint and the chain were enough.');
2372
+ }
2373
+ else {
2374
+ warn('the rebuilt state does NOT match what we just had — that is a real bug, please report it.');
2375
+ info(`before ${fpBefore.slice(0, 16)}… · after ${fpAfter.slice(0, 16)}…`);
2376
+ }
2377
+ }
2378
+ /** [5] Read the token back the way a marketplace would, and check the bytes against the chain. */
2379
+ async function walkthroughReadBack(state, baseUrl) {
2380
+ step('Read it back the way a marketplace would');
2381
+ const token = state.tokens[0];
2382
+ if (!token?.minted) {
2383
+ info('no minted token to read yet.');
2384
+ return;
2385
+ }
2386
+ const uri = token.tokenURI;
2387
+ info(`tokenURI(0) ${dim('on chain →')} ${uri ?? dim('(none)')}`);
2388
+ // The demo's default is off-chain custody: the CHAIN holds a keccak256 commitment to the image and
2389
+ // a URI base; this NODE holds the bytes. That split is the thing worth understanding, so name it
2390
+ // rather than implying the JSON came from the chain.
2391
+ info(dim('the chain stored a URI base + a keccak256 commitment; this node serves the bytes.'));
2392
+ try {
2393
+ const res = await fetch(`${baseUrl}/t/${resolveChain(CHAIN).id}/${state.address}/0`);
2394
+ const json = (await res.json());
2395
+ const shown = { name: json.name, image: json.image };
2396
+ console.log(` ${dim(JSON.stringify(shown))}`);
2397
+ if (Array.isArray(json.abx_provenance)) {
2398
+ info(dim(`every field is tagged with where it came from (abx_provenance: ${json.abx_provenance.length} entries)`));
2399
+ }
2400
+ }
2401
+ catch (e) {
2402
+ info(dim(`could not read the served metadata: ${e.message}`));
2403
+ }
2404
+ console.log(` ${dim('prove the bytes match the chain:')} ${bold(`abx verify ${state.address}`)}`);
2405
+ }
2406
+ /** Is a TCP port already bound on localhost? Used to preflight a serve BEFORE spending a tx. */
2407
+ async function portInUse(port) {
2408
+ const { createServer } = await import('node:net');
2409
+ return new Promise((resolve) => {
2410
+ const probe = createServer();
2411
+ probe.once('error', (e) => resolve(e.code === 'EADDRINUSE'));
2412
+ probe.once('listening', () => probe.close(() => resolve(false)));
2413
+ probe.listen(port);
2414
+ });
2415
+ }
2416
+ /**
2417
+ * Index a project we *just* deployed — and don't believe a zero.
2418
+ *
2419
+ * `eth_getLogs` is read-after-write inconsistent on load-balanced RPCs: `waitForTransactionReceipt`
2420
+ * resolves against a node that has the block, then the log query lands on one that doesn't yet, and
2421
+ * returns an empty set for a block we KNOW contains our deploy. The old code took that single read at
2422
+ * face value, printed `✓ reconstructed 0 events`, stored the empty projection, and served an empty
2423
+ * dashboard — a first-run that looks like the toolkit simply doesn't work. It reproduced 100% of the
2424
+ * time on `https://sepolia.base.org`, which is the DEFAULT endpoint when there's no `.env`, i.e. the
2425
+ * documented first run was the broken path. The same block returned all 9 logs seconds later.
2426
+ *
2427
+ * We have the one thing that makes this checkable: we just minted, so the spine cannot be empty.
2428
+ * So verify instead of trusting — re-scan with backoff until events appear, and if they never do,
2429
+ * say so as a FAILURE with the recovery command rather than dressing a zero up as a ✓.
2430
+ */
2431
+ async function reindexAfterDeploy(indexer, address, opts = {}) {
2432
+ const attempts = opts.attempts ?? 6;
2433
+ const delayMs = opts.delayMs ?? 1500;
2434
+ let last = await indexer.reindex(address);
2435
+ for (let i = 1; i < attempts && last.state.eventCount === 0; i++) {
2436
+ if (i === 1) {
2437
+ info(dim("no events yet — the RPC hasn't served the logs for that block; re-scanning…"));
2438
+ }
2439
+ await new Promise((r) => setTimeout(r, delayMs));
2440
+ // `full: true` — the stored projection has 0 events, so there is no valid checkpoint to
2441
+ // resume from; a full replay from the deploy block is the only correct re-scan.
2442
+ last = await indexer.reindex(address, { full: true });
2443
+ }
2444
+ if (last.state.eventCount === 0) {
2445
+ warn(`the RPC still reports no logs for this project after ${attempts} tries — the deploy DID succeed ` +
2446
+ `(it's on chain), but this node can't reconstruct it yet.`);
2447
+ console.log(` ${dim('recover with')} ${bold(`abx index ${address} --full`)} ${dim('in a minute, or point ABX_RPC_URLS at a better endpoint (`abx doctor` ranks them).')}`);
2448
+ }
2449
+ return last;
2450
+ }
2250
2451
  /**
2251
2452
  * `abx preview` — serve the program on localhost, live, for as long as the work is being made.
2252
2453
  *
@@ -4376,7 +4577,13 @@ function printServing(url, address) {
4376
4577
  console.log(` ${dim('image ')}${url}/t/${cid}/${address}/0/image`);
4377
4578
  console.log(` ${dim('state API ')}${url}/api/project/${address}`);
4378
4579
  }
4379
- console.log(`\n ${dim('Open the dashboard, then hit “Re-index from chain” to watch state rebuild live.')}`);
4580
+ // The dashboard is READ-ONLY: re-index/verify are admin actions that 404 unless the node has an
4581
+ // ABX_RESOLVER_ADMIN_TOKEN, so there is no button to press. This line used to say "hit Re-index
4582
+ // from chain", which sent every first-run user hunting for a control that isn't there.
4583
+ console.log(`\n ${dim('The dashboard shows the event spine it replayed — that table IS the reconstruction.')}`);
4584
+ if (address) {
4585
+ console.log(` ${dim('Rebuild it yourself (read-only, safe):')} ${bold(`abx index ${address} --full`)} ${dim('— replays from the deploy block and must land on identical state.')}`);
4586
+ }
4380
4587
  console.log(` ${dim('Ctrl-C to stop.')}\n`);
4381
4588
  }
4382
4589
  // ── deploy-resolver: scaffold a hosted resolver for a provider the operator owns ──────────