mintbound-cli 0.1.0

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/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # mintbound-cli
2
+
3
+ Check MintBound's solvency evidence yourself, against live chains.
4
+
5
+ ```bash
6
+ npx mintbound-cli status
7
+ ```
8
+
9
+ > **If `npx` reports the package is not found**, it has not been published yet.
10
+ > Everything above also runs straight from a clone, with no publish step:
11
+ > `git clone https://github.com/Lawalgiyath/MintGuard && cd MintGuard && npm install && npm run claims`
12
+
13
+ **No private key. No funded account. No setup.** The Attestcoin Proof Builder is a read
14
+ API, and the guard's entry points are reachable by `eth_call` — so every claim MintBound
15
+ makes can be checked by a stranger who has not been trusted with anything. That is the
16
+ point of the tool.
17
+
18
+ ---
19
+
20
+ ## Commands
21
+
22
+ ### `claims`
23
+
24
+ Audits our own pitch. Every factual claim MintBound's submission makes, with the live
25
+ check that settles it — fetched now, from chains and explorers.
26
+
27
+ ```
28
+ right now: minting FROZEN · proof 23741/200 blocks stale · redemption always open
29
+
30
+ ✓ No off-chain party has to be trusted for the reserve figure.
31
+ trustedParties() == 0 — the figure came from the Block Prover precompile
32
+ ✓ A proof past the staleness bound freezes minting, rather than being used anyway.
33
+ proof is 23741 of 200 blocks stale, and minting is frozen — the gate bit
34
+ ✓ mintWithProof costs about 382,578 gas including proof verification and the invariant.
35
+ the real receipt reports 382578 gas
36
+
37
+ 11/11 claims verified against live state.
38
+ ```
39
+
40
+ Exits non-zero when any claim fails. That matters: a self-audit that cannot return FAIL
41
+ is marketing in a monospace font.
42
+
43
+ Claims resolve to **pass**, **fail**, or **unknown**. Unknown means the evidence could
44
+ not be reached — a block explorer timed out, an RPC refused — which is not the same as a
45
+ claim being false, so it is shown distinctly, never counted as verified, and does not set
46
+ a failing exit code. Only a real failure does.
47
+
48
+ ### `status`
49
+
50
+ The whole balance sheet, read live from Creditcoin CC3 and Ethereum Sepolia.
51
+
52
+ ```
53
+ Balance sheet
54
+ ────────────────────────────────────────────────────────────────────
55
+ proven reserve 1,025,000 @ source height 11570929
56
+ announced exits - 0 (encumbered, no longer counts)
57
+ haircut x 100.00%
58
+ effective backing = 1,025,000
59
+ outstanding supply 25,000 wmTUSD
60
+ headroom to bound 1,000,000
61
+
62
+ Assurance 90/100
63
+ ────────────────────────────────────────────────────────────────────
64
+ ✓ Reserve evidence 25 cryptographic — no reporter in the mint path
65
+ ✓ Proof freshness 20 21 of 200 blocks stale
66
+ ✓ Liability coverage 20 single-chain deployment
67
+ ✓ Encumbrance margin 15 150 block delay vs 40 block detection (3.8x margin)
68
+ ✗ Interval continuity 10 no bonded continuity claim covers the current interval
69
+ ✓ Mint authority 10 emergency withdrawal irreversibly renounced
70
+ ```
71
+
72
+ `--json` for machine-readable output.
73
+
74
+ **On the Assurance number:** it is a presentation-layer aggregation over six
75
+ independently checkable obligations, with weights published in
76
+ [`src/assurance.ts`](src/assurance.ts). No contract reads it and no mint is gated on it —
77
+ enforcement on-chain is binary. It exists so a human can see which parts of the solvency
78
+ argument are currently carried by cryptography and which by an assumption. If you
79
+ disagree with a weight, the full vector is printed alongside the total so you can
80
+ reweight it yourself.
81
+
82
+ ### `verify --source-tx <hash>`
83
+
84
+ Walk one source-chain transaction through the entire evidence pipeline and report what
85
+ the precompile makes of it.
86
+
87
+ ```
88
+ [1/4] Fetching source receipt ✓ block 11570611, 1 logs
89
+ event found ReserveSnapshot
90
+ routes to submitReserveSnapshot()
91
+ [2/4] Checking attestation via ChainInfo precompile 0x0FD3 ✓ height 11570611 attested
92
+ [3/4] Building Merkle + continuity proof ✓ 9 Merkle siblings, 90 continuity roots
93
+ [4/4] Calling submitReserveSnapshot() on CC3 (eth_call, no transaction sent)
94
+ ✓ proof is valid but already spent
95
+ ```
96
+
97
+ The entry point is chosen from the log signatures actually present in the receipt, not
98
+ from a flag you pass. A transaction carrying neither `ReserveSnapshot` nor `Locked` is
99
+ rejected — correctly, since an arbitrary transaction must not be able to move the bound.
100
+
101
+ ### `attack`
102
+
103
+ Fire the documented attacks at the live guard. Every one is expected to revert, and the
104
+ reverts are the product.
105
+
106
+ ```
107
+ 6/6 blocked — 5 by the Block Prover precompile, 1 by the guard before it got that far
108
+ ```
109
+
110
+ **What this proves, exactly:** each attempt carries forged proof material, so each is
111
+ rejected before MintBoundASC's own logic runs. There is no path around the precompile.
112
+ It does **not** exercise the guard's internal rejections — emitter binding, event
113
+ matching, replay — because those need a *valid* proof carrying malicious contents, which
114
+ cannot be forged against live CC3 by construction. Those are covered in
115
+ `packages/contracts/test/MintBound.test.ts` against a mock verifier.
116
+
117
+ ---
118
+
119
+ ## Configuration
120
+
121
+ Everything has a working default pointed at the live deployment. Override via env:
122
+
123
+ | Variable | Default |
124
+ |---|---|
125
+ | `CREDITCOIN_RPC_URL` | `https://rpc.cc3-testnet.creditcoin.network` |
126
+ | `SEPOLIA_RPC_URL` | `https://ethereum-sepolia-rpc.publicnode.com` |
127
+ | `PROOF_BUILDER_URL` | `https://proof-gen-api.cc3-testnet.creditcoin.network` |
128
+
129
+ Run from inside the repo and `deployments/*.json` is picked up automatically, so a fresh
130
+ redeploy needs no edit here.
131
+
132
+ ## Exit codes
133
+
134
+ | Code | Meaning |
135
+ |---|---|
136
+ | 0 | Solvent and fresh / proof valid / all attacks blocked |
137
+ | 1 | Minting frozen / proof rejected / an attack succeeded |
138
+ | 2 | Usage error |
139
+
140
+ Suitable for CI: `npx mintbound-cli status` failing is a real signal.
package/dist/abi.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Hand-written ABIs. The CLI deliberately has no build dependency on the contracts
3
+ * package — it must be runnable against a deployed system by someone who has never
4
+ * compiled this repo.
5
+ */
6
+ export const ASC_ABI = [
7
+ "function submitReserveSnapshot((uint64,uint64,bytes,bytes32,(bytes32,bool)[],bytes32,bytes32[]) q) returns (bool)",
8
+ "function mintWithProof((uint64,uint64,bytes,bytes32,(bytes32,bool)[],bytes32,bytes32[]) q) returns (bool)",
9
+ "function solvencyReport(address sourceAsset) view returns ((uint256 verifiedReserve,uint256 encumberedReserve,uint256 outstandingSupply,uint256 maxMintable,uint32 collateralRatioBps,uint64 attestedAtHeight,uint64 latestAttestedHeight,uint64 stalenessBlocks,uint64 epoch,uint16 haircutBps,uint64 provenAt,uint8 trustedParties,bool fresh,bool solvent,bool mintFrozen))",
10
+ "function trustedParties(address sourceAsset) view returns (uint8)",
11
+ "function maxStalenessBlocks() view returns (uint64)",
12
+ "function SOURCE_CHAIN_KEY() view returns (uint64)",
13
+ "function CANONICAL_VAULT() view returns (address)",
14
+ "function remoteChainKeys(address,uint256) view returns (uint64)",
15
+ "function totalLiabilities(address sourceAsset) view returns (uint256)",
16
+ ];
17
+ /**
18
+ * MintBoundASC's custom errors. Without these in the ABI, ethers reports a real,
19
+ * informative revert as "unknown custom error" — which reads like a malfunction when it
20
+ * is in fact the guard answering precisely.
21
+ */
22
+ export const ASC_ERRORS = [
23
+ "error WrongChainKey(uint64 got, uint64 expected)",
24
+ "error QueryAlreadyProcessed(bytes32 queryId)",
25
+ "error VerificationFailed()",
26
+ "error SourceTransactionReverted()",
27
+ "error UnsupportedTransactionType(uint8 txType)",
28
+ "error NoMatchingEvent()",
29
+ "error AssetNotRegistered(address sourceAsset)",
30
+ "error StaleEpoch(uint64 got, uint64 have)",
31
+ "error RegressiveHeight(uint64 got, uint64 have)",
32
+ "error LockAlreadyConsumed(bytes32 lockId)",
33
+ "error MintFrozen(address sourceAsset)",
34
+ "error NoReserveProof(address sourceAsset)",
35
+ "error ReserveStale(uint64 staleness, uint64 bound)",
36
+ "error InvariantViolated(uint256 wouldBeSupply, uint256 discountedReserve)",
37
+ "error InvalidHaircut(uint16 bps)",
38
+ "error NotHealthy()",
39
+ "error TimelockPending(uint256 eta)",
40
+ "error NoUnfreezeRequested()",
41
+ "error VelocityCapExceeded(uint256 wouldBeInWindow, uint256 cap)",
42
+ "error InvalidVelocityWindow()",
43
+ "error OracleMintDisabled(address sourceAsset)",
44
+ "error AssetNotOracleBacked(address sourceAsset)",
45
+ "error InvalidOracleFeed()",
46
+ "error RemoteChainNotRegistered(address sourceAsset, uint64 chainKey)",
47
+ "error WrongBeacon(address got, address expected)",
48
+ "error RemoteSupplyStale(uint64 chainKey, uint64 staleness, uint64 bound)",
49
+ "error RemoteSupplyMissing(uint64 chainKey)",
50
+ "error TooManyRemoteChains()",
51
+ ];
52
+ export const ERC20_ABI = [
53
+ "function balanceOf(address) view returns (uint256)",
54
+ "function totalSupply() view returns (uint256)",
55
+ "function symbol() view returns (string)",
56
+ "function decimals() view returns (uint8)",
57
+ ];
58
+ export const VAULT_ABI = [
59
+ "function reserveBalance(address asset) view returns (uint256)",
60
+ "function encumbered(address) view returns (uint256)",
61
+ "function availableReserve(address asset) view returns (uint256)",
62
+ "function emergencyEnabled() view returns (bool)",
63
+ "function WITHDRAWAL_DELAY() view returns (uint256)",
64
+ "function epoch(address) view returns (uint256)",
65
+ ];
66
+ export const FEED_ABI = [
67
+ "function latestRoundData() view returns (uint80 roundId,int256 answer,uint256 startedAt,uint256 updatedAt,uint80 answeredInRound)",
68
+ "function decimals() view returns (uint8)",
69
+ "function description() view returns (string)",
70
+ ];
71
+ export const CHAIN_INFO_ABI = [
72
+ "function get_latest_attestation_height_and_hash(uint64 chainKey) view returns ((uint64 height, bytes32 hash, bool isAttestation, bool exists))",
73
+ "function is_height_attested(uint64 chainKey, uint64 targetHeight) view returns (bool)",
74
+ "function get_supported_chains() view returns ((uint64 chainKey, uint64 chainId, bytes chainName, uint8 chainEncoding)[])",
75
+ ];
76
+ export const CONTINUITY_ABI = [
77
+ "function coveredThrough(address asset) view returns (uint64)",
78
+ "function openClaim(address asset) view returns (bytes32)",
79
+ "function anchorHeight(address asset) view returns (uint64)",
80
+ "function claims(bytes32) view returns (address asset,address asserter,uint64 fromHeight,uint64 toHeight,uint64 settleAfter,uint256 bond,bool settled,bool disproven)",
81
+ "function MIN_BOND() view returns (uint256)",
82
+ "function LIVENESS() view returns (uint64)",
83
+ ];
@@ -0,0 +1,101 @@
1
+ /**
2
+ * MintBound Assurance — how much trust the solvency evidence still requires.
3
+ *
4
+ * WHAT THIS IS NOT
5
+ * ----------------
6
+ * This is not a consensus value, it is not read by any contract, and no mint is ever
7
+ * gated on it. Enforcement in MintBound is binary and lives on-chain: either the
8
+ * aggregate bound holds against a fresh proof or the mint reverts. Nothing here can
9
+ * loosen that.
10
+ *
11
+ * WHAT THIS IS
12
+ * ------------
13
+ * A presentation-layer aggregation over six independent proof obligations, so that a
14
+ * human can see at a glance which parts of the solvency argument are currently carried
15
+ * by cryptography and which are carried by an assumption. The weights below are
16
+ * published rather than tuned: they are part of the specification, and changing them
17
+ * changes a documented number, not a security property.
18
+ *
19
+ * The reason a score is defensible at all here is that each obligation is independently
20
+ * checkable from chain state — no obligation is scored on opinion. If you disagree with
21
+ * a weight, the vector is printed alongside the total so you can reweight it yourself.
22
+ */
23
+ /** Published weights. These sum to 100 and are part of the spec. */
24
+ export const WEIGHTS = {
25
+ reserveEvidence: 25,
26
+ freshness: 20,
27
+ liabilityCoverage: 20,
28
+ encumbrance: 15,
29
+ continuity: 10,
30
+ mintAuthority: 10,
31
+ };
32
+ export function assess(i) {
33
+ const marginOk = i.detectionLatencyBlocks > 0 &&
34
+ i.withdrawalDelayBlocks >= 2 * i.detectionLatencyBlocks;
35
+ const chainsOk = i.registeredChains === 0 || i.reportingChains >= i.registeredChains;
36
+ const continuityOk = i.coveredThrough > 0 && i.coveredThrough >= i.anchorHeight;
37
+ const obligations = [
38
+ {
39
+ id: "reserve",
40
+ label: "Reserve evidence",
41
+ weight: WEIGHTS.reserveEvidence,
42
+ met: i.trustedParties === 0,
43
+ evidence: "chain: MintBoundASC.solvencyReport().trustedParties",
44
+ detail: i.trustedParties === 0
45
+ ? "cryptographic — verified by the Block Prover precompile, no reporter in the mint path"
46
+ : `oracle-reported — ${i.trustedParties} off-chain part${i.trustedParties === 1 ? "y" : "ies"} must be honest`,
47
+ },
48
+ {
49
+ id: "freshness",
50
+ label: "Proof freshness",
51
+ weight: WEIGHTS.freshness,
52
+ met: i.fresh,
53
+ evidence: "chain: ChainInfo precompile 0x0FD3, read on-chain",
54
+ detail: `${i.stalenessBlocks} of ${i.maxStalenessBlocks} blocks stale`,
55
+ },
56
+ {
57
+ id: "liabilities",
58
+ label: "Liability coverage",
59
+ weight: WEIGHTS.liabilityCoverage,
60
+ met: chainsOk,
61
+ evidence: "chain: SupplyBeacon proofs aggregated by MintBoundASC.totalLiabilities()",
62
+ detail: i.registeredChains === 0
63
+ ? "single-chain deployment — local totalSupply is the whole liability"
64
+ : `${i.reportingChains} of ${i.registeredChains} registered chains reporting`,
65
+ },
66
+ {
67
+ id: "encumbrance",
68
+ label: "Encumbrance margin",
69
+ weight: WEIGHTS.encumbrance,
70
+ met: marginOk,
71
+ evidence: "chain: ReserveVault.WITHDRAWAL_DELAY vs observed attestation lag",
72
+ detail: marginOk
73
+ ? `${i.withdrawalDelayBlocks} block delay vs ${i.detectionLatencyBlocks} block detection ` +
74
+ `(${(i.withdrawalDelayBlocks / Math.max(i.detectionLatencyBlocks, 1)).toFixed(1)}x margin)`
75
+ : `${i.withdrawalDelayBlocks} block delay does not cover 2x the ${i.detectionLatencyBlocks} block detection latency`,
76
+ },
77
+ {
78
+ id: "continuity",
79
+ label: "Interval continuity",
80
+ weight: WEIGHTS.continuity,
81
+ met: continuityOk,
82
+ evidence: "chain: SolvencyContinuity.coveredThrough()",
83
+ detail: continuityOk
84
+ ? `no-outflow bonded through source height ${i.coveredThrough}`
85
+ : "no bonded continuity claim covers the current interval",
86
+ },
87
+ {
88
+ id: "authority",
89
+ label: "Mint authority",
90
+ weight: WEIGHTS.mintAuthority,
91
+ met: i.emergencyRenounced,
92
+ evidence: "chain: ReserveVault.emergencyEnabled()",
93
+ detail: i.emergencyRenounced
94
+ ? "emergency withdrawal irreversibly renounced — the operator cannot move the reserve"
95
+ : "emergency withdrawal still enabled — the operator retains a unilateral exit",
96
+ },
97
+ ];
98
+ const score = obligations.reduce((n, o) => n + (o.met ? o.weight : 0), 0);
99
+ const max = obligations.reduce((n, o) => n + o.weight, 0);
100
+ return { obligations, score, max };
101
+ }
@@ -0,0 +1,167 @@
1
+ import { AbiCoder, Contract, Interface, getAddress, keccak256, toUtf8Bytes, zeroPadValue, } from "ethers";
2
+ import { ASC_ABI, ASC_ERRORS } from "../abi.js";
3
+ import { EXPLORER, creditcoin, sepolia } from "../config.js";
4
+ import { providers } from "../read.js";
5
+ import { describeRevert } from "../revert.js";
6
+ import { CROSS, TICK, c, heading, rule } from "../render.js";
7
+ /**
8
+ * `mintbound attack` — fire the documented attacks at the live guard and report back.
9
+ *
10
+ * These are real `eth_call`s against the real MintBoundASC on CC3. Every one is
11
+ * expected to revert, and the reverts are the product.
12
+ *
13
+ * WHAT THIS PROVES, EXACTLY: each attempt carries forged proof material, so each is
14
+ * rejected by the Block Prover precompile before the guard's own logic runs. There is
15
+ * no path around the precompile. It does NOT exercise the guard's internal rejections
16
+ * (emitter binding, event matching, replay) — those require a *valid* proof carrying
17
+ * malicious contents, which cannot be forged against live CC3 by construction, and are
18
+ * covered in the unit suite against a mock verifier instead.
19
+ */
20
+ const abi = AbiCoder.defaultAbiCoder();
21
+ const RESERVE_SNAPSHOT_SIG = keccak256(toUtf8Bytes("ReserveSnapshot(address,address,uint256,uint256,uint256)"));
22
+ const LOCKED_SIG = keccak256(toUtf8Bytes("Locked(address,address,uint256,uint256)"));
23
+ const topic = (a) => zeroPadValue(getAddress(a), 32);
24
+ function encodeTx(logs, receiptStatus = 1) {
25
+ const common = abi.encode(["uint64", "uint64", "address", "bool", "address", "uint256", "bytes"], [
26
+ 1n,
27
+ 500000n,
28
+ "0x1111111111111111111111111111111111111111",
29
+ false,
30
+ "0x2222222222222222222222222222222222222222",
31
+ 0n,
32
+ "0x",
33
+ ]);
34
+ const typeSpecific = abi.encode(["uint64", "uint128", "uint128", "tuple(address,bytes32[])[]", "uint8", "bytes32", "bytes32"], [11155111n, 1n, 2n, [], 0, `0x${"11".repeat(32)}`, `0x${"22".repeat(32)}`]);
35
+ const receipt = abi.encode(["uint8", "uint64", "tuple(address,bytes32[],bytes)[]", "bytes"], [
36
+ receiptStatus,
37
+ 100000n,
38
+ logs.map((l) => [l.address_, l.topics, l.data]),
39
+ "0x" + "00".repeat(256),
40
+ ]);
41
+ return abi.encode(["uint8", "bytes[]"], [2, [common, typeSpecific, receipt]]);
42
+ }
43
+ /** Three non-indexed words, matching ReserveVault's event exactly. */
44
+ function snapshotLog(vault, asset, balance, epoch) {
45
+ return {
46
+ address_: vault,
47
+ topics: [RESERVE_SNAPSHOT_SIG, topic(vault), topic(asset)],
48
+ data: abi.encode(["uint256", "uint256", "uint256"], [balance, 0n, epoch]),
49
+ };
50
+ }
51
+ function lockedLog(vault, user, asset, amount, nonce) {
52
+ return {
53
+ address_: vault,
54
+ topics: [LOCKED_SIG, topic(user), topic(asset)],
55
+ data: abi.encode(["uint256", "uint256"], [amount, nonce]),
56
+ };
57
+ }
58
+ function query(blockHeight, encodedTransaction, opts = {}) {
59
+ return [
60
+ opts.chainKey ?? 1n,
61
+ blockHeight,
62
+ encodedTransaction,
63
+ `0x${"ab".repeat(32)}`,
64
+ [
65
+ [keccak256(toUtf8Bytes("s0")), false],
66
+ [keccak256(toUtf8Bytes("s1")), true],
67
+ ],
68
+ `0x${"cd".repeat(32)}`,
69
+ [`0x${"ef".repeat(32)}`],
70
+ ];
71
+ }
72
+ export async function attack() {
73
+ const cc = creditcoin();
74
+ const sep = sepolia();
75
+ const { cc: ccProvider, sep: sepProvider } = providers();
76
+ const ascAddr = cc.contracts.MintBoundASC;
77
+ const vault = String(cc.config?.canonicalVault ?? sep.contracts.ReserveVault);
78
+ const asset = String(cc.config?.sourceAsset ?? sep.contracts.TestUSD);
79
+ const iface = new Interface([...ASC_ABI, ...ASC_ERRORS]);
80
+ const asc = new Contract(ascAddr, iface, ccProvider);
81
+ // Aim at a height that actually exists, so a rejection is never merely "no such block".
82
+ const head = BigInt(await sepProvider.getBlockNumber().catch(() => 11_600_000));
83
+ heading("MintBound — live attack suite");
84
+ console.log(` target ${ascAddr} ${c.grey("(Creditcoin CC3)")}`);
85
+ console.log(` vault ${vault} ${c.grey("(Sepolia)")}`);
86
+ console.log(` height ${head}`);
87
+ console.log("");
88
+ console.log(c.grey(" Real eth_calls against the live guard. Every one should revert. Forged proof\n" +
89
+ " material means each is stopped by the precompile before the guard's own\n" +
90
+ " checks run — which is the property being demonstrated."));
91
+ const cases = [
92
+ {
93
+ name: "Forged inclusion proof",
94
+ expect: "the precompile will not verify fabricated material",
95
+ run: () => asc.submitReserveSnapshot.staticCall(query(head, encodeTx([snapshotLog(vault, asset, 10n ** 30n, 999n)]))),
96
+ },
97
+ {
98
+ name: "Counterfeit vault, identical event",
99
+ expect: "a counterfeit emitter still needs a real inclusion proof",
100
+ run: () => asc.submitReserveSnapshot.staticCall(query(head, encodeTx([
101
+ snapshotLog("0xdeadbeef00000000000000000000000000000000", asset, 10n ** 30n, 999n),
102
+ ]))),
103
+ },
104
+ {
105
+ name: "Reverted source transaction",
106
+ expect: "a reverted receipt is unprovable to begin with",
107
+ run: () => asc.submitReserveSnapshot.staticCall(query(head, encodeTx([snapshotLog(vault, asset, 10n ** 30n, 999n)], 0))),
108
+ },
109
+ {
110
+ name: "Proof from the wrong source chain",
111
+ expect: "chainKey is pinned at construction, before any proof work",
112
+ run: () => asc.submitReserveSnapshot.staticCall(query(head, encodeTx([snapshotLog(vault, asset, 1n, 999n)]), { chainKey: 3n })),
113
+ },
114
+ {
115
+ name: "Mint with no authorising Locked event",
116
+ expect: "nothing here authorises a mint",
117
+ run: () => asc.mintWithProof.staticCall(query(head, encodeTx([snapshotLog(vault, asset, 1n, 1n)]))),
118
+ },
119
+ {
120
+ name: "Unauthorised mint of an enormous amount",
121
+ expect: "rejected before any supply is created",
122
+ run: () => asc.mintWithProof.staticCall(query(head, encodeTx([
123
+ lockedLog(vault, "0x000000000000000000000000000000000000dEaD", asset, 10n ** 30n, 424242n),
124
+ ]))),
125
+ },
126
+ ];
127
+ const results = [];
128
+ for (const t of cases) {
129
+ console.log("");
130
+ console.log(` ${c.bold(t.name)}`);
131
+ console.log(c.grey(` expect: ${t.expect}`));
132
+ try {
133
+ await t.run();
134
+ console.log(` ${c.red("SUCCEEDED")} — this must not happen. Investigate before shipping.`);
135
+ results.push({ name: t.name, blocked: false, reason: "call succeeded" });
136
+ }
137
+ catch (e) {
138
+ const reason = describeRevert(iface, e);
139
+ console.log(` ${c.green(TICK + " BLOCKED")} ${c.grey(reason)}`);
140
+ results.push({ name: t.name, blocked: true, reason });
141
+ }
142
+ }
143
+ const blocked = results.filter((r) => r.blocked).length;
144
+ heading("Summary");
145
+ for (const r of results) {
146
+ console.log(` ${r.blocked ? c.green(TICK) : c.red(CROSS)} ${r.name}`);
147
+ }
148
+ console.log("");
149
+ // Be precise about WHERE each one died. Most are stopped by the precompile; the
150
+ // chain-key case never reaches it, because the key is pinned at construction and
151
+ // checked first. Reporting them all as "blocked at the proof layer" would be tidier
152
+ // and wrong.
153
+ const atProof = results.filter((r) => /merkle|proof/i.test(r.reason)).length;
154
+ const beforeProof = blocked - atProof;
155
+ const tally = `${blocked}/${results.length}`;
156
+ console.log(` ${blocked === results.length ? c.green(tally) : c.red(tally)} blocked` +
157
+ c.grey(` — ${atProof} by the Block Prover precompile` +
158
+ (beforeProof > 0 ? `, ${beforeProof} by the guard before it got that far` : "")));
159
+ console.log(c.grey("\n Guard-internal rejections — emitter binding, event matching, chain-key pinning,\n" +
160
+ " replay protection — need a valid proof carrying malicious contents, which cannot\n" +
161
+ " be forged against live CC3. Those are covered in test/MintBound.test.ts against a\n" +
162
+ " mock verifier."));
163
+ rule();
164
+ console.log(c.grey(` ${EXPLORER.creditcoin}/address/${ascAddr}`));
165
+ console.log("");
166
+ return blocked === results.length ? 0 : 1;
167
+ }