@artblocks/abx-cli 0.1.0-alpha.41 → 0.1.0-alpha.42
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 +77 -0
- package/dist/commands/deploy.d.ts +51 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +298 -128
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/project.d.ts.map +1 -1
- package/dist/commands/project.js +28 -4
- package/dist/commands/project.js.map +1 -1
- package/dist/deploy-plan.d.ts +80 -9
- package/dist/deploy-plan.d.ts.map +1 -1
- package/dist/deploy-plan.js +7 -18
- package/dist/deploy-plan.js.map +1 -1
- package/dist/main.js +17 -7
- package/dist/main.js.map +1 -1
- package/dist/ownerops.d.ts +70 -1
- package/dist/ownerops.d.ts.map +1 -1
- package/dist/ownerops.js +166 -7
- package/dist/ownerops.js.map +1 -1
- package/dist/riskgate.d.ts +36 -0
- package/dist/riskgate.d.ts.map +1 -1
- package/dist/riskgate.js +91 -17
- package/dist/riskgate.js.map +1 -1
- package/package.json +6 -6
- package/skill/SKILL.md +1 -1
- package/skill/reference/code.md +14 -0
- package/skill/reference/operate.md +12 -0
package/dist/ownerops.js
CHANGED
|
@@ -16,7 +16,7 @@ import { toHex as toHexSdk } from 'viem';
|
|
|
16
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import { basename, resolve as resolvePath } from 'node:path';
|
|
18
18
|
import { gzipSync } from 'node:zlib';
|
|
19
|
-
import { formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
|
|
19
|
+
import { formatEther, getAddress, hexToBytes, isAddress, parseEther, toHex, zeroAddress } from 'viem';
|
|
20
20
|
import { backendResolution, CHAIN, chainId, fixedPriceMinterAddress, fixedPriceMinter1155Address, localIndexer } from './config.js';
|
|
21
21
|
import { CliError } from './errors.js';
|
|
22
22
|
import { detectTokenKind, isEditionContract, assertHasParamsSurface, hasParamsSurface } from './kind.js';
|
|
@@ -557,6 +557,53 @@ function chainScriptReader(contract) {
|
|
|
557
557
|
},
|
|
558
558
|
};
|
|
559
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* The `gasFloor` `replace-script` restates for its batched multicall (see the call site's own
|
|
562
|
+
* comment for WHY it restates rather than sums the per-op values `prepareSetScriptChunk` already
|
|
563
|
+
* computes). This used to be `writeByteCounts.reduce((sum, n) => sum + n * 200, 0)` — code-deposit
|
|
564
|
+
* cost alone, mirroring `prepareSetScriptChunk`'s own per-chunk floor exactly.
|
|
565
|
+
*
|
|
566
|
+
* A funded on-chain sweep found that number 49% below what a real replace-script send actually
|
|
567
|
+
* needed, and the send failed after passing a `--dry-run` that called it "would succeed" (see
|
|
568
|
+
* riskgate.ts's `simulateDryRun`, fixed alongside this for the OTHER half of that finding: the dry
|
|
569
|
+
* run never checked gas/cost at all, floor included). Investigating this specific number: the
|
|
570
|
+
* deposit-only floor was never WRONG about what it claims — it is still a true lower bound — but it
|
|
571
|
+
* was radically INCOMPLETE for a multi-chunk replacement, because it omits two costs that are just as
|
|
572
|
+
* unconditionally "physics" as the 200 gas/byte deposit, and are not "an invented estimate" the
|
|
573
|
+
* codebase's own philosophy (see execute.ts's `pinGas` doc) warns against including:
|
|
574
|
+
*
|
|
575
|
+
* - `Gcreate` = 32,000 gas per `setScriptChunk` WRITE. `OnChainScript.setScriptChunk` stores its
|
|
576
|
+
* bytes via `SSTORE2.write`, which is one `CREATE` per call — a fixed opcode cost incurred
|
|
577
|
+
* regardless of chain state, chunk size, or whether the index already held something. A replace
|
|
578
|
+
* that (say) uses a small `--chunk-size` to spread one program over many chunks pays this 32,000
|
|
579
|
+
* once per chunk; the old floor counted 0 of it, so the gap grows with chunk COUNT, not just
|
|
580
|
+
* total bytes — exactly the shape a small-`--chunk-size` multi-chunk replacement produces.
|
|
581
|
+
* - The intrinsic cost of the multicall's own calldata — 16 gas per non-zero byte, 4 gas per zero
|
|
582
|
+
* byte (EIP-2028), plus the flat 21,000 base every transaction pays (EIP-2) — computed from the
|
|
583
|
+
* REAL encoded `data`, not approximated from chunk byte counts. `eth_estimateGas` (what `pinGas`
|
|
584
|
+
* compares the floor against) always includes both; the old floor included neither, so it could
|
|
585
|
+
* never clear this amount even for a single-chunk replacement with no removes at all.
|
|
586
|
+
*
|
|
587
|
+
* Deliberately still NOT included, because — unlike the two above — they genuinely depend on
|
|
588
|
+
* on-chain state this function doesn't read, and a floor that could overstate the true minimum stops
|
|
589
|
+
* being a floor: the SSTORE cost of the chunk-pointer mapping and the chunk-count counter (cold vs.
|
|
590
|
+
* warm, zero-vs-nonzero all vary by what's on chain now), `removeLastScriptChunk`'s own cost, and any
|
|
591
|
+
* gas refund (refunds apply AFTER execution and never reduce what a transaction must be GIVEN to
|
|
592
|
+
* avoid running out mid-execution, so they could only ever justify a LOWER floor, working against the
|
|
593
|
+
* "never send an under-funded tx" goal this exists for). So this remains a floor, not an estimate —
|
|
594
|
+
* `simulateDryRun`'s new `estimatedGas`/`estimatedCostWei` (from `pinGas`'s real `eth_estimateGas`,
|
|
595
|
+
* the same call the send itself makes) is the number to trust for cost guidance; this is only ever
|
|
596
|
+
* the threshold that flags an implausible one.
|
|
597
|
+
*/
|
|
598
|
+
function replaceScriptGasFloor(finalCalldata, writeByteCounts) {
|
|
599
|
+
const bytes = hexToBytes(finalCalldata);
|
|
600
|
+
let calldataGas = 0n;
|
|
601
|
+
for (const b of bytes)
|
|
602
|
+
calldataGas += b === 0 ? 4n : 16n;
|
|
603
|
+
const createBase = BigInt(writeByteCounts.length) * 32000n;
|
|
604
|
+
const depositGas = writeByteCounts.reduce((sum, n) => sum + BigInt(n) * 200n, 0n);
|
|
605
|
+
return 21000n + calldataGas + createBase + depositGas;
|
|
606
|
+
}
|
|
560
607
|
/**
|
|
561
608
|
* `abx replace-script <address> --script <path>` — the safe, first-class way to replace an
|
|
562
609
|
* UNLOCKED code project's on-chain program (#119). `OnChainScript.sol` permits `setScriptChunk`/
|
|
@@ -646,11 +693,14 @@ export async function cmdReplaceScript(address, flags) {
|
|
|
646
693
|
throw new Error(`replace-script expected to batch ${ops.length} op(s) into one transaction, got ${batched.length}`);
|
|
647
694
|
}
|
|
648
695
|
// `batchOps`/`prepareMulticall` don't sum a folded gasFloor from their sub-ops (each PER-CHUNK
|
|
649
|
-
// gasFloor is real physics — see prepareSetScriptChunk's own doc — but is dropped once several
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
696
|
+
// gasFloor is real physics — see prepareSetScriptChunk's own doc — but is dropped once several ops
|
|
697
|
+
// merge into one multicall PreparedTx). Restate it here — using the REAL final calldata, not an
|
|
698
|
+
// approximation — so the eth_estimateGas sanity check fires for any replacement that changes
|
|
699
|
+
// anything on-chain, writes or pure removes alike. See replaceScriptGasFloor's own doc for what
|
|
700
|
+
// changed here and why (a real sweep found the deposit-only version 49% under the true minimum).
|
|
701
|
+
const writeByteCounts = plan.toWrite.map((w) => (w.hex.length - 2) / 2);
|
|
702
|
+
const floor = replaceScriptGasFloor(batched[0].data, writeByteCounts);
|
|
703
|
+
const tx = { ...batched[0], gasFloor: `0x${floor.toString(16)}` };
|
|
654
704
|
const sent = await runWrite(contract, tx, flags, owner);
|
|
655
705
|
if (!sent)
|
|
656
706
|
return; // dry run, or the cold lane — nothing landed to verify yet
|
|
@@ -2370,19 +2420,128 @@ async function refuseIfParamKey(contract, field) {
|
|
|
2370
2420
|
`\`abx inspect ${contract}\`.)\n` +
|
|
2371
2421
|
` If you really did mean the metadata field "${field}" and not the param, re-run with --force-field.`);
|
|
2372
2422
|
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Which scope actually SERVES `field` right now — the same question `abx tokenuri`'s
|
|
2425
|
+
* `abx_provenance` line answers, and the one `lock-field` used not to ask (#F7, cold-agent sweep):
|
|
2426
|
+
* `lock-field --field description --token 0` reported "would succeed" — and DID succeed — on a
|
|
2427
|
+
* project where `description` is COLLECTION-scoped, freezing an empty token-scope slot while the
|
|
2428
|
+
* value every viewer actually sees (the collection one) stayed completely mutable. Every statement
|
|
2429
|
+
* the CLI made was true; together they promised a permanent freeze that never happened.
|
|
2430
|
+
*
|
|
2431
|
+
* Mirrors the renderer's own fallback (`AbxMetadataRenderer._field` / `fieldWithFallback` in
|
|
2432
|
+
* token-api/metadata.ts): a token-scope value wins when set; only when the token slot is empty does
|
|
2433
|
+
* the collection-scope value show through. Best-effort by design — a read that could not be
|
|
2434
|
+
* answered (RPC unreachable, or a contract predating the on-chain-metadata extension) returns
|
|
2435
|
+
* `null` rather than asserting a scope this call does not actually know, matching
|
|
2436
|
+
* `refuseIfParamKey`'s posture just above.
|
|
2437
|
+
*
|
|
2438
|
+
* Exported with an injected `client` (mirrors `readCollectionLocks` in project.ts) so a test can
|
|
2439
|
+
* exercise the real chain-read shape against a mocked `PublicClient` — no network, no live fixture.
|
|
2440
|
+
*/
|
|
2441
|
+
export async function fieldScopePresence(client, contract, tokenId, field) {
|
|
2442
|
+
const tag = encodeTag(field);
|
|
2443
|
+
const [tokenEntry, collectionEntry] = await Promise.all([
|
|
2444
|
+
tryReadContract(client, { address: contract, abi: oneOfOneImageAbi, functionName: 'tokenField', args: [tokenId, tag] }),
|
|
2445
|
+
tryReadContract(client, { address: contract, abi: oneOfOneImageAbi, functionName: 'contractField', args: [tag] }),
|
|
2446
|
+
]);
|
|
2447
|
+
if (!tokenEntry || !collectionEntry)
|
|
2448
|
+
return null;
|
|
2449
|
+
return { token: tokenEntry[1] !== '0x', collection: collectionEntry[1] !== '0x' };
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Pure decision core for the wrong-scope lock check (#F7, cold-agent sweep) — exported so it's
|
|
2453
|
+
* unit-testable with no chain at all, matching `computeAvailability`'s split (project.ts): the
|
|
2454
|
+
* chain read is a thin, low-risk passthrough; the judgment call belongs in a function a test can
|
|
2455
|
+
* call directly.
|
|
2456
|
+
*
|
|
2457
|
+
* Mirrors the renderer's own fallback (`AbxMetadataRenderer._field` / `fieldWithFallback` in
|
|
2458
|
+
* token-api/metadata.ts): a token-scope value wins over collection when both are set, so the scope
|
|
2459
|
+
* that's actually SERVED can differ from the scope the caller names. Returns `null` — nothing to
|
|
2460
|
+
* flag — when the chosen scope already IS what's served, or when genuinely neither scope carries a
|
|
2461
|
+
* value yet (locking a truly empty field is unambiguous: there's no "other" visible value this
|
|
2462
|
+
* command could fail to protect).
|
|
2463
|
+
*/
|
|
2464
|
+
export function detectLockFieldScopeMismatch(presence, chosen) {
|
|
2465
|
+
const effective = presence.token ? 'token' : presence.collection ? 'collection' : 'none';
|
|
2466
|
+
if (effective === 'none' || effective === chosen)
|
|
2467
|
+
return null;
|
|
2468
|
+
return { effective, chosen };
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Refuse, or (under `--force-field`) loudly warn but proceed — the two allowed outcomes of a
|
|
2472
|
+
* detected mismatch, decided PURELY from the mismatch + whether the override flag was passed, so
|
|
2473
|
+
* this is unit-testable without a chain or a console.
|
|
2474
|
+
*
|
|
2475
|
+
* Deliberately does NOT refuse unconditionally with no escape hatch: pre-locking an empty slot so
|
|
2476
|
+
* it can never be filled — e.g. permanently guaranteeing one token can never diverge from the
|
|
2477
|
+
* collection default — is a legitimate, deliberate use of this same command, and an unconditional
|
|
2478
|
+
* refusal would just trade one bug (a silent no-op freeze) for another (a real, protocol-supported
|
|
2479
|
+
* shape the CLI now falsely claims is impossible — see "false refusal is a membrane defect").
|
|
2480
|
+
* `--force-field` is lock-field's existing general override for its own protective checks (see
|
|
2481
|
+
* `refuseIfParamKey` above); this reuses it rather than adding a second, narrower flag for the same
|
|
2482
|
+
* "yes, I really mean this" answer.
|
|
2483
|
+
*/
|
|
2484
|
+
export function lockFieldScopeVerdict(mismatch, opts) {
|
|
2485
|
+
const { effective, chosen } = mismatch;
|
|
2486
|
+
const correctCmd = effective === 'collection'
|
|
2487
|
+
? `abx lock-field ${opts.contract} --field ${opts.field} --collection`
|
|
2488
|
+
: `abx lock-field ${opts.contract} --field ${opts.field} --token ${opts.tokenId}`;
|
|
2489
|
+
const servedFrom = effective === 'collection' ? 'COLLECTION scope (shared across every token)' : `TOKEN #${opts.tokenId}'s own scope (a per-token override)`;
|
|
2490
|
+
const chosenDesc = chosen === 'collection' ? 'the COLLECTION scope' : `token #${opts.tokenId}'s scope`;
|
|
2491
|
+
if (!opts.forced) {
|
|
2492
|
+
return {
|
|
2493
|
+
action: 'refuse',
|
|
2494
|
+
message: `locking ${chosenDesc} would report "permanent" and DO NOTHING to freeze what's actually shown: '${opts.field}' for token #${opts.tokenId} ` +
|
|
2495
|
+
`is currently served from ${servedFrom}, not the scope you're about to lock. Lock the scope that's actually visible instead:\n` +
|
|
2496
|
+
` ${correctCmd}\n` +
|
|
2497
|
+
` If you deliberately want to freeze ${chosenDesc}'s slot forever anyway (e.g. guaranteeing this token can never diverge from the ` +
|
|
2498
|
+
`collection default), re-run with --force-field — it will proceed, with a loud warning that ${servedFrom} stays unfrozen.`,
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2501
|
+
return {
|
|
2502
|
+
action: 'warn',
|
|
2503
|
+
message: `⚠⚠ LOCKING A SLOT THAT IS NOT WHAT'S DISPLAYED ⚠⚠\n` +
|
|
2504
|
+
` '${opts.field}' for token #${opts.tokenId} is currently served from ${servedFrom} — THIS LOCK DOES NOT FREEZE THAT VALUE.\n` +
|
|
2505
|
+
` You are about to PERMANENTLY lock ${chosenDesc}'s slot instead (proceeding because of --force-field). That value can never change again, ` +
|
|
2506
|
+
`but the value viewers actually see today is untouched by this operation and remains fully mutable.`,
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Refuse (or, under `--force-field`, loudly warn but proceed) a `lock-field` whose CHOSEN scope is
|
|
2511
|
+
* not the scope actually serving `field` right now. Best-effort by design: a read that could not be
|
|
2512
|
+
* answered (RPC unreachable, or a contract predating the on-chain-metadata extension) skips the
|
|
2513
|
+
* check rather than asserting a scope this call does not actually know, matching
|
|
2514
|
+
* `refuseIfParamKey`'s posture above. Runs BEFORE `runWrite`, so `--dry-run` and a real send see
|
|
2515
|
+
* this identically.
|
|
2516
|
+
*/
|
|
2517
|
+
async function warnOrRefuseWrongScope(contract, tokenId, field, collection, flags) {
|
|
2518
|
+
const presence = await fieldScopePresence(makePublicClient({ chainKey: CHAIN }), contract, tokenId, field);
|
|
2519
|
+
if (!presence)
|
|
2520
|
+
return; // couldn't determine either scope — don't block on a fact we don't have
|
|
2521
|
+
const chosen = collection ? 'collection' : 'token';
|
|
2522
|
+
const mismatch = detectLockFieldScopeMismatch(presence, chosen);
|
|
2523
|
+
if (!mismatch)
|
|
2524
|
+
return; // nothing to freeze, or the right scope
|
|
2525
|
+
const verdict = lockFieldScopeVerdict(mismatch, { contract, tokenId, field, forced: flags['force-field'] !== undefined });
|
|
2526
|
+
if (verdict.action === 'refuse')
|
|
2527
|
+
throw new Error(verdict.message);
|
|
2528
|
+
console.log(` ${red(verdict.message)}`);
|
|
2529
|
+
}
|
|
2373
2530
|
export async function cmdLockField(address, flags) {
|
|
2374
2531
|
const contract = requireAddress(address, 'abx lock-field <address> --field <name> [--collection | --token 0] [--sign|--unsigned]');
|
|
2375
2532
|
const field = requireFlag(flags, 'field', 'abx lock-field <address> --field <name>');
|
|
2376
2533
|
const collection = !!flags.collection;
|
|
2534
|
+
const tokenId = BigInt(flags.token ?? '0');
|
|
2377
2535
|
// The metadata field and a same-named param are different things; only an explicit --force-field
|
|
2378
2536
|
// says "yes, I mean the field". See refuseIfParamKey.
|
|
2379
2537
|
if (flags['force-field'] === undefined)
|
|
2380
2538
|
await refuseIfParamKey(contract, field);
|
|
2539
|
+
await warnOrRefuseWrongScope(contract, tokenId, field, collection, flags);
|
|
2381
2540
|
const owner = await read(contract, 'owner');
|
|
2382
2541
|
console.log(dim(` note: locking the '${field}' field is permanent and irreversible (freezes all its representations).`));
|
|
2383
2542
|
const tx = collection
|
|
2384
2543
|
? prepareLockContractField({ contract, field, chainId: chainId() })
|
|
2385
|
-
: prepareLockTokenField({ contract, tokenId
|
|
2544
|
+
: prepareLockTokenField({ contract, tokenId, field, chainId: chainId() });
|
|
2386
2545
|
await runWrite(contract, tx, flags, owner);
|
|
2387
2546
|
}
|
|
2388
2547
|
// ── set-renderer (toggle on-chain URI resolution) ─────────────────────────────
|