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.
@@ -0,0 +1,207 @@
1
+ import { Contract } from "ethers";
2
+ import { CHAIN_INFO_ABI, ERC20_ABI } from "../abi.js";
3
+ import { CHAIN_INFO_ADDRESS, EXPLORER, creditcoin, sepolia } from "../config.js";
4
+ import { providers, readLive } from "../read.js";
5
+ import { CROSS, TICK, c, heading, rule, units } from "../render.js";
6
+ const BLOCKSCOUT_API = `${EXPLORER.creditcoin}/api`;
7
+ /**
8
+ * Ask the explorer whether a contract's source is actually published.
9
+ *
10
+ * Distinguishes "not published" from "could not ask". Firing seven of these at once
11
+ * made Blockscout time out intermittently, and reporting that as an unpublished
12
+ * contract would be a false FAIL — exactly the kind of misreport this command exists to
13
+ * avoid. So: retry once, and return `reachable: false` rather than a verdict when the
14
+ * explorer will not answer.
15
+ */
16
+ async function sourcePublished(address) {
17
+ for (let attempt = 0; attempt < 2; attempt++) {
18
+ try {
19
+ const res = await fetch(`${BLOCKSCOUT_API}?module=contract&action=getsourcecode&address=${address}`, { signal: AbortSignal.timeout(25_000) });
20
+ if (!res.ok)
21
+ throw new Error(String(res.status));
22
+ const body = await res.json();
23
+ const row = body?.result?.[0];
24
+ const src = String(row?.SourceCode ?? "");
25
+ return { published: src.length > 0, reachable: true, name: String(row?.ContractName ?? "") };
26
+ }
27
+ catch {
28
+ if (attempt === 0)
29
+ await new Promise((r) => setTimeout(r, 1500));
30
+ }
31
+ }
32
+ return { published: false, reachable: false, name: "" };
33
+ }
34
+ /** The guard's own mint gate, mirrored exactly: solvent AND fresh AND not frozen. */
35
+ function canMintNow(s) {
36
+ return s.solvent && s.fresh && !s.mintFrozen;
37
+ }
38
+ export async function claims(opts = {}) {
39
+ const cc = creditcoin();
40
+ const sep = sepolia();
41
+ const { cc: ccProvider } = providers();
42
+ const s = await readLive();
43
+ const out = [];
44
+ const add = (id, claim, source, result, detail) => out.push({
45
+ id,
46
+ claim,
47
+ source,
48
+ verdict: typeof result === "boolean" ? (result ? "pass" : "fail") : result,
49
+ detail,
50
+ });
51
+ // ── 1. no reporter in the mint path ─────────────────────────────────────────
52
+ add("no-reporter", "No off-chain party has to be trusted for the reserve figure.", "chain: MintBoundASC.solvencyReport().trustedParties", s.trustedParties === 0, s.trustedParties === 0
53
+ ? "trustedParties() == 0 — the figure came from the Block Prover precompile"
54
+ : `trustedParties() == ${s.trustedParties} — this asset is oracle-backed, not proven`);
55
+ // ── 2. the reserve was actually proven, not merely configured ───────────────
56
+ const proven = s.epoch > 0 && s.attestedAtHeight > 0 && s.verifiedReserve > 0n;
57
+ add("proof-exists", "The reserve figure came from a verified inclusion proof of a real source transaction.", "chain: reserves[asset].epoch / attestedAtHeight", proven, proven
58
+ ? `${units(s.verifiedReserve, s.decimals)} proven at source height ${s.attestedAtHeight} (epoch ${s.epoch})`
59
+ : "no proof recorded — the guard has never accepted a snapshot for this asset");
60
+ // ── 3. freshness is read from the precompile, not reported to us ────────────
61
+ // Read 0x0FD3 directly and compare with what the guard reports. If an off-chain party
62
+ // could influence the freshness figure, these two would be free to disagree.
63
+ let precompileHeight = 0;
64
+ try {
65
+ const info = new Contract(CHAIN_INFO_ADDRESS, CHAIN_INFO_ABI, ccProvider);
66
+ const r = await info.get_latest_attestation_height_and_hash(s.sourceChainKey);
67
+ precompileHeight = Number(r.height);
68
+ }
69
+ catch {
70
+ /* leaves precompileHeight at 0, which fails the claim below */
71
+ }
72
+ const freshnessAgrees = precompileHeight > 0 && Math.abs(precompileHeight - s.latestAttestedHeight) <= 2;
73
+ add("freshness-on-chain", "Freshness is read on-chain from ChainInfo precompile 0x0FD3, so nobody off-chain can lie about it.", "chain: 0x0FD3 read directly, compared against the guard's own report", freshnessAgrees, freshnessAgrees
74
+ ? `precompile says ${precompileHeight}, guard says ${s.latestAttestedHeight} — same source`
75
+ : `precompile ${precompileHeight} vs guard ${s.latestAttestedHeight} — could not confirm they agree`);
76
+ // ── 4. the freshness bound actually bites ─────────────────────────────
77
+ // The first draft of this command omitted freshness and reported 10/10 while the
78
+ // deployment was frozen on a proof 23,741 blocks past its bound. That is exactly the
79
+ // bias a self-audit exists to remove — it checked only the claims we thought to list.
80
+ //
81
+ // The claim worth checking is not "minting works right now". It is the safety
82
+ // property: a proof past the staleness bound MUST freeze minting. When the proof is
83
+ // stale, that is directly observable, and the freeze is evidence rather than an
84
+ // outage. When it is fresh, the bound simply is not being exercised, and we say so
85
+ // instead of claiming credit.
86
+ const stale = s.stalenessBlocks > s.maxStalenessBlocks;
87
+ const gateBites = stale ? !s.fresh && !canMintNow(s) : s.fresh;
88
+ add("staleness-bites", "A proof past the staleness bound freezes minting, rather than being used anyway.", "chain: stalenessBlocks vs maxStalenessBlocks, against the guard's own mint gate", gateBites, stale
89
+ ? `proof is ${s.stalenessBlocks} of ${s.maxStalenessBlocks} blocks stale, and minting is ${canMintNow(s) ? "STILL OPEN — the gate did not bite" : "frozen — the gate bit"}`
90
+ : `proof is ${s.stalenessBlocks} of ${s.maxStalenessBlocks} blocks stale; the bound is not currently being exercised`);
91
+ // ── 4. the bound holds right now ────────────────────────────────────────────
92
+ const withinBound = s.outstandingSupply <= s.discountedReserve;
93
+ add("bound-holds", "Outstanding supply is within the proven ceiling.", "chain: totalSupply vs (verifiedReserve - encumbered) x haircut", withinBound, `${units(s.outstandingSupply, s.decimals)} supply against ${units(s.discountedReserve, s.decimals)} effective backing`);
94
+ // ── 5. encumbrance arithmetic matches the enforced rule ─────────────────────
95
+ // Recompute the ceiling the way the contract does and check the contract agrees.
96
+ const unenc = s.verifiedReserve > s.encumberedReserve ? s.verifiedReserve - s.encumberedReserve : 0n;
97
+ const expected = (unenc * BigInt(s.haircutBps)) / 10000n;
98
+ add("encumbrance-first", "Announced exits are subtracted from backing BEFORE the haircut, not after.", "chain: recomputed from verifiedReserve, encumbered and haircutBps", expected === s.discountedReserve, expected === s.discountedReserve
99
+ ? `${units(s.encumberedReserve, s.decimals)} encumbered, excluded before the ${(s.haircutBps / 100).toFixed(2)}% haircut`
100
+ : `recomputed ${units(expected, s.decimals)} but the guard reports ${units(s.discountedReserve, s.decimals)}`);
101
+ // ── 6. the liveness/safety margin, against MEASURED latency ─────────────────
102
+ const detection = Math.max(s.sourceHead - s.latestAttestedHeight, 0);
103
+ const marginOk = detection > 0 && s.withdrawalDelayBlocks >= 2 * detection;
104
+ add("margin", "A withdrawal cannot execute before the system could have detected it (delay >= 2x detection latency).", "chain: ReserveVault.WITHDRAWAL_DELAY vs live attestation lag", marginOk, detection > 0
105
+ ? `${s.withdrawalDelayBlocks} block delay vs ${detection} blocks of measured lag — ${(s.withdrawalDelayBlocks / detection).toFixed(1)}x margin`
106
+ : "attestation lag reads as zero; cannot evidence the margin right now");
107
+ // ── 7. the operator cannot move the reserve ─────────────────────────────────
108
+ add("renounced", "The operator has irreversibly given up the ability to move the reserve.", "chain: ReserveVault.emergencyEnabled()", !s.emergencyEnabled, s.emergencyEnabled
109
+ ? "emergency withdrawal is STILL ENABLED — the operator retains a unilateral exit"
110
+ : "emergencyEnabled() == false, permanently — there is no function to re-enable it");
111
+ // ── 8. the minter is immutable and is the guard ─────────────────────────────
112
+ let minter = "";
113
+ try {
114
+ const w = new Contract(cc.contracts.WrappedAsset, [...ERC20_ABI, "function MINTER() view returns (address)"], ccProvider);
115
+ minter = String(await w.MINTER());
116
+ }
117
+ catch {
118
+ /* empty string fails the claim */
119
+ }
120
+ const minterOk = minter.toLowerCase() === String(cc.contracts.MintBoundASC).toLowerCase();
121
+ add("minter-immutable", "Only the guard can mint, and that cannot be changed by anyone.", "chain: WrappedAsset.MINTER (immutable)", minterOk, minterOk
122
+ ? `MINTER is the guard at ${minter.slice(0, 10)}… and is declared immutable`
123
+ : `MINTER is ${minter || "unreadable"} — expected the guard`);
124
+ // ── 9. published source ─────────────────────────────────────────────────────
125
+ const ccNames = Object.keys(cc.contracts);
126
+ // Sequential on purpose. Seven parallel requests made the explorer time out and
127
+ // produced a false FAIL; this takes a few seconds longer and tells the truth.
128
+ const checks = [];
129
+ for (const n of ccNames) {
130
+ checks.push({ n, ...(await sourcePublished(cc.contracts[n])) });
131
+ }
132
+ const published = checks.filter((r) => r.published).length;
133
+ const unreachable = checks.filter((r) => !r.reachable).length;
134
+ add("source-published", "Every deployed contract publishes verified source, so the code behind each address can be read.", "explorer: Blockscout getsourcecode, queried live",
135
+ // Unreachable is not the same as unpublished. Blockscout times out often enough
136
+ // that treating a slow explorer as a failed claim would make this command
137
+ // intermittently accuse its own project of something untrue.
138
+ unreachable > 0 ? "unknown" : published === ccNames.length ? "pass" : "fail", unreachable > 0
139
+ ? `${published}/${ccNames.length} confirmed; ${unreachable} could not be reached — the explorer did not answer, which is not evidence either way`
140
+ : `${published}/${ccNames.length} Creditcoin contracts return source from the explorer`);
141
+ // ── 10. the gas figure we quote ─────────────────────────────────────────────
142
+ // Quoting a measured number is only worth anything if the measurement is reachable.
143
+ const MINT_TX = "0xb5a9c959d5fcadad2608e6c0e0e444cc9854489706e96f0f1ee495ba33f70d56";
144
+ let mintGas = 0n;
145
+ try {
146
+ const rec = await ccProvider.getTransactionReceipt(MINT_TX);
147
+ mintGas = rec?.gasUsed ?? 0n;
148
+ }
149
+ catch {
150
+ /* zero fails the claim */
151
+ }
152
+ add("gas-measured", "mintWithProof costs about 382,578 gas including proof verification and the full invariant.", `chain: receipt for ${MINT_TX.slice(0, 12)}…`, mintGas === 0n ? "unknown" : mintGas < 400000n ? "pass" : "fail", mintGas > 0n
153
+ ? `the real receipt reports ${mintGas.toString()} gas`
154
+ : "the receipt could not be fetched — no evidence either way, not a failure");
155
+ // ── report ──────────────────────────────────────────────────────────────────
156
+ if (opts.json) {
157
+ console.log(JSON.stringify({ claims: out }, null, 2));
158
+ return out.some((x) => x.verdict === "fail") ? 1 : 0;
159
+ }
160
+ heading("MintBound — auditing our own claims");
161
+ console.log(c.grey(" Every factual claim in the submission, with the live check that settles it.\n" +
162
+ " Read now, from chains and explorers. This command can fail, and says so when\n" +
163
+ " it does — a self-audit that cannot return FAIL is marketing in a monospace font."));
164
+ console.log("");
165
+ // Operational status first. The claims below are about SAFETY properties, which hold
166
+ // whether or not the deployment is currently minting — so state the operational
167
+ // position plainly rather than letting a page of green ticks imply it.
168
+ const minting = canMintNow(s);
169
+ console.log(` ${c.grey("right now:")} minting ${minting ? c.green("PERMITTED") : c.yellow("FROZEN")}` +
170
+ c.grey(` · proof ${s.stalenessBlocks}/${s.maxStalenessBlocks} blocks stale · redemption always open`));
171
+ if (!minting) {
172
+ console.log(c.grey(" A frozen mint is the safety property, not an outage: no fresh proof, no new\n" +
173
+ " liabilities. Restart the snapshot worker and it clears on the next proof."));
174
+ }
175
+ console.log("");
176
+ for (const x of out) {
177
+ const mark = x.verdict === "pass"
178
+ ? c.green(TICK)
179
+ : x.verdict === "fail"
180
+ ? c.red(CROSS)
181
+ : c.yellow("?");
182
+ console.log(` ${mark} ${c.bold(x.claim)}`);
183
+ console.log(` ${x.detail}`);
184
+ console.log(` ${c.grey(x.source)}`);
185
+ console.log("");
186
+ }
187
+ const passed = out.filter((x) => x.verdict === "pass").length;
188
+ const failed = out.filter((x) => x.verdict === "fail").length;
189
+ const unknown = out.filter((x) => x.verdict === "unknown").length;
190
+ rule();
191
+ const tally = `${passed}/${out.length}`;
192
+ console.log(` ${failed > 0 ? c.red(tally) : c.green(tally)} claims verified against live state` +
193
+ (unknown > 0 ? c.yellow(` · ${unknown} could not be reached`) : "") +
194
+ (failed > 0 ? c.red(` · ${failed} FAILED`) : ""));
195
+ if (failed > 0) {
196
+ console.log(c.grey("\n The failed claims above are printed exactly as loudly as the met ones,\n" +
197
+ " because a submission you cannot fail is not evidence of anything."));
198
+ }
199
+ if (unknown > 0) {
200
+ console.log(c.grey("\n An unreachable source is not a failed claim. Those rows are marked ? rather\n" +
201
+ " than counted either way, and do not set a failing exit code — a third party\n" +
202
+ " being slow is not evidence about this project."));
203
+ }
204
+ console.log("");
205
+ // Fail only on an actual FAIL. Unknown is honest uncertainty, not a defect.
206
+ return failed > 0 ? 1 : 0;
207
+ }
@@ -0,0 +1,64 @@
1
+ import { assess } from "../assurance.js";
2
+ import { EXPLORER } from "../config.js";
3
+ import { readLive, toAssuranceInput } from "../read.js";
4
+ import { CROSS, TICK, bar, c, heading, kv, rule, units } from "../render.js";
5
+ /**
6
+ * `mintbound status` — the whole solvency argument, read from live chains, in one screen.
7
+ *
8
+ * Every number printed here was fetched over RPC in the last few seconds. Nothing is
9
+ * cached, nothing is synthesised, and no key or funded account is required to run it.
10
+ */
11
+ export async function status(opts = {}) {
12
+ const s = await readLive();
13
+ const a = assess(toAssuranceInput(s));
14
+ if (opts.json) {
15
+ console.log(JSON.stringify({ state: s, assurance: a }, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2));
16
+ return a.score === a.max ? 0 : 1;
17
+ }
18
+ const d = s.decimals;
19
+ const ratio = s.outstandingSupply === 0n ? Infinity : s.collateralRatioBps / 100;
20
+ heading("MintBound — live solvency");
21
+ kv("network", `Creditcoin CC3 (102031) <- Ethereum Sepolia (chainKey ${s.sourceChainKey})`);
22
+ kv("guard", s.asc);
23
+ kv("reserve vault", s.vault);
24
+ heading("Balance sheet");
25
+ kv("proven reserve", `${units(s.verifiedReserve, d)} @ source height ${s.attestedAtHeight}`);
26
+ kv("announced exits", `- ${units(s.encumberedReserve, d)} (encumbered, no longer counts)`);
27
+ kv("haircut", `x ${(s.haircutBps / 100).toFixed(2)}%`);
28
+ kv("effective backing", `= ${units(s.discountedReserve, d)}`);
29
+ kv("outstanding supply", `${units(s.outstandingSupply, d)} ${s.symbol}`);
30
+ kv("headroom to bound", `${units(s.maxMintable, d)}`);
31
+ console.log("");
32
+ const usage = s.discountedReserve === 0n
33
+ ? 0
34
+ : Number((s.outstandingSupply * 10000n) / s.discountedReserve) / 10000;
35
+ console.log(` ${bar(usage)} ${(usage * 100).toFixed(1)}% of the bound used`);
36
+ kv("", c.grey(ratio === Infinity ? "no supply outstanding" : `collateral ratio ${ratio.toFixed(0)}%`));
37
+ heading("Freshness");
38
+ kv("source tip", String(s.sourceHead));
39
+ kv("Creditcoin attested", `${s.latestAttestedHeight} ${c.grey(`(${Math.max(s.sourceHead - s.latestAttestedHeight, 0)} blocks behind tip)`)}`);
40
+ kv("proof staleness", `${s.stalenessBlocks} / ${s.maxStalenessBlocks} blocks`);
41
+ kv("verdict", s.fresh ? c.green(`${TICK} fresh`) : c.red(`${CROSS} stale — minting frozen`));
42
+ heading("Mint gate");
43
+ const gate = s.solvent && s.fresh && !s.mintFrozen;
44
+ kv("solvent", s.solvent ? c.green(TICK) : c.red(CROSS));
45
+ kv("fresh", s.fresh ? c.green(TICK) : c.red(CROSS));
46
+ kv("circuit breaker", s.mintFrozen ? c.red("engaged") : c.green("clear"));
47
+ kv("minting", gate ? c.green("PERMITTED") : c.red("FROZEN"));
48
+ console.log(c.grey("\n Redemption is never gated on any of the above. Every failure mode here\n" +
49
+ " lands on the same side: minting stops, redeeming does not."));
50
+ heading(`Assurance ${a.score}/${a.max}`);
51
+ for (const o of a.obligations) {
52
+ const mark = o.met ? c.green(TICK) : c.red(CROSS);
53
+ console.log(` ${mark} ${o.label.padEnd(22)}${c.grey(String(o.weight).padStart(3))} ${o.detail}`);
54
+ }
55
+ console.log("");
56
+ console.log(c.grey(" Assurance is a presentation-layer aggregation over six independently\n" +
57
+ " checkable obligations, with published weights. No contract reads it and\n" +
58
+ " no mint is gated on it — enforcement on-chain is binary."));
59
+ rule();
60
+ console.log(c.grey(` ${EXPLORER.creditcoin}/address/${s.asc}`));
61
+ console.log(c.grey(` ${EXPLORER.sepolia}/address/${s.vault}`));
62
+ console.log("");
63
+ return gate ? 0 : 1;
64
+ }
@@ -0,0 +1,164 @@
1
+ import { Contract, Interface, id as keccakId } from "ethers";
2
+ import { proofProvider } from "@gluwa/usc-sdk";
3
+ import { ASC_ABI, ASC_ERRORS, CHAIN_INFO_ABI } from "../abi.js";
4
+ import { CHAIN_INFO_ADDRESS, EXPLORER, RPC, creditcoin } from "../config.js";
5
+ import { providers, readLive } from "../read.js";
6
+ import { c, heading, kv, rule, step, stepFail, stepOk, units } from "../render.js";
7
+ import { describeRevert } from "../revert.js";
8
+ const RESERVE_SNAPSHOT_SIG = keccakId("ReserveSnapshot(address,address,uint256,uint256,uint256)");
9
+ const LOCKED_SIG = keccakId("Locked(address,address,uint256,uint256)");
10
+ function toQueryTuple(p) {
11
+ return [
12
+ BigInt(p.chainKey),
13
+ BigInt(p.headerNumber),
14
+ p.txBytes,
15
+ p.merkleProof.root,
16
+ p.merkleProof.siblings.map((s) => [s.hash, s.isLeft]),
17
+ p.continuityProof.lowerEndpointDigest,
18
+ p.continuityProof.roots,
19
+ ];
20
+ }
21
+ /**
22
+ * `mintbound verify --source-tx 0x...`
23
+ *
24
+ * Walks the entire evidence pipeline for one source-chain transaction and reports what
25
+ * the Creditcoin precompile makes of it. This is a read-only path end to end: the final
26
+ * step is an `eth_call`, not a transaction, so it costs nothing and needs no key.
27
+ *
28
+ * The point is that you do not have to take MintBound's word for anything. Point this at
29
+ * a transaction, watch the precompile answer, and decide for yourself.
30
+ */
31
+ export async function verify(txHash) {
32
+ if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) {
33
+ console.error(c.red(`Not a transaction hash: ${txHash}`));
34
+ return 2;
35
+ }
36
+ const cc = creditcoin();
37
+ const { cc: ccProvider, sep: sepProvider } = providers();
38
+ const chainKey = Number(cc.config?.sourceChainKey ?? 1);
39
+ const ascAddr = cc.contracts.MintBoundASC;
40
+ heading("MintBound — verify source transaction");
41
+ kv("source tx", txHash);
42
+ kv("source chain", `Ethereum Sepolia (chainKey ${chainKey})`);
43
+ kv("verifier", `${ascAddr} on Creditcoin CC3`);
44
+ console.log("");
45
+ // ── 1. the transaction itself ───────────────────────────────────────────────
46
+ step(1, 4, "Fetching source receipt");
47
+ const receipt = await sepProvider.getTransactionReceipt(txHash);
48
+ if (!receipt) {
49
+ stepFail("not found on Sepolia");
50
+ return 1;
51
+ }
52
+ if (receipt.status !== 1) {
53
+ stepFail(`transaction reverted (status ${receipt.status})`);
54
+ console.log(c.grey("\n A reverted source transaction proves nothing and MintBound rejects it\n" +
55
+ " outright — the receipt status is part of what the precompile checks."));
56
+ return 1;
57
+ }
58
+ const height = receipt.blockNumber;
59
+ stepOk(`block ${height}, ${receipt.logs.length} logs`);
60
+ // Which entry point does this transaction belong to? Decide from the log signatures
61
+ // actually present, not from a flag the caller passed.
62
+ const topics = new Set(receipt.logs.flatMap((l) => (l.topics[0] ? [l.topics[0]] : [])));
63
+ const isSnapshot = topics.has(RESERVE_SNAPSHOT_SIG);
64
+ const isLock = topics.has(LOCKED_SIG);
65
+ if (!isSnapshot && !isLock) {
66
+ stepFail("no MintBound event in this transaction");
67
+ console.log(c.grey("\n This transaction carries neither a ReserveSnapshot nor a Locked event, so\n" +
68
+ " there is nothing here for the guard to act on. That is a correct rejection,\n" +
69
+ " not a failure: an arbitrary transaction must not be able to move the bound."));
70
+ return 1;
71
+ }
72
+ const entry = isSnapshot ? "submitReserveSnapshot" : "mintWithProof";
73
+ kv(" event found", isSnapshot ? "ReserveSnapshot" : "Locked", 22);
74
+ kv(" routes to", `${entry}()`, 22);
75
+ console.log("");
76
+ // ── 2. attestation ──────────────────────────────────────────────────────────
77
+ step(2, 4, "Checking attestation via ChainInfo precompile 0x0FD3");
78
+ const info = new Contract(CHAIN_INFO_ADDRESS, CHAIN_INFO_ABI, ccProvider);
79
+ const latest = await info.get_latest_attestation_height_and_hash(chainKey);
80
+ const attestedTip = Number(latest.height);
81
+ const attested = attestedTip >= height;
82
+ if (!attested) {
83
+ const behind = height - attestedTip;
84
+ stepFail(`height ${height} not yet attested (tip ${attestedTip}, ${behind} blocks behind)`);
85
+ console.log(c.grey(`\n Creditcoin attests finalized source blocks, so there is roughly a nine minute\n` +
86
+ ` lag before any given Sepolia block becomes provable. This one needs about\n` +
87
+ ` ${Math.ceil((behind * 12) / 60)} more minute(s). That delay is the cost of not trusting a reporter.`));
88
+ return 1;
89
+ }
90
+ stepOk(`height ${height} attested (tip ${attestedTip})`);
91
+ // ── 3. proof construction ───────────────────────────────────────────────────
92
+ step(3, 4, "Building Merkle + continuity proof");
93
+ const builder = new proofProvider.service.ProofBuilder(chainKey, RPC.proofBuilder);
94
+ let proof;
95
+ try {
96
+ const res = await builder.getProof(txHash);
97
+ if (!res?.success || !res?.data)
98
+ throw new Error(String(res?.error ?? "unknown"));
99
+ proof = res.data;
100
+ }
101
+ catch (e) {
102
+ stepFail(String(e?.shortMessage ?? e?.message ?? e).slice(0, 90));
103
+ console.log(c.grey("\n Attestation does not imply the proof is servable yet — the Proof Builder's\n" +
104
+ " block cache is eventually consistent and returns 422 for a short window\n" +
105
+ " after attestation. Try again in a minute."));
106
+ return 1;
107
+ }
108
+ stepOk(`${proof.merkleProof.siblings.length} Merkle siblings, ` +
109
+ `${proof.continuityProof.roots.length} continuity roots`);
110
+ // ── 4. the precompile's verdict ─────────────────────────────────────────────
111
+ step(4, 4, `Calling ${entry}() on CC3 (eth_call, no transaction sent)`);
112
+ const iface = new Interface([...ASC_ABI, ...ASC_ERRORS]);
113
+ const asc = new Contract(ascAddr, iface, ccProvider);
114
+ const query = toQueryTuple(proof);
115
+ let verdict = "accepted";
116
+ let reason = "";
117
+ try {
118
+ await asc[entry].staticCall(query);
119
+ stepOk("precompile verified the proof, guard accepted it");
120
+ }
121
+ catch (e) {
122
+ reason = describeRevert(iface, e);
123
+ if (/processed|consumed|Replay|already/i.test(reason)) {
124
+ verdict = "already-processed";
125
+ stepOk("proof is valid but already spent");
126
+ }
127
+ else {
128
+ verdict = "rejected";
129
+ stepFail(reason.slice(0, 110));
130
+ }
131
+ }
132
+ // ── the answer ──────────────────────────────────────────────────────────────
133
+ const s = await readLive();
134
+ heading("Result");
135
+ if (verdict === "accepted") {
136
+ console.log(" " +
137
+ c.green("PROOF VALID") +
138
+ c.grey(" — the Block Prover precompile confirmed this transaction is in an"));
139
+ console.log(c.grey(" attested Sepolia block, and the guard's own checks passed on top of it."));
140
+ }
141
+ else if (verdict === "already-processed") {
142
+ console.log(" " + c.cyan("PROOF VALID, ALREADY SPENT"));
143
+ console.log(c.grey(" The cryptography checks out; the guard has simply seen this query before and\n" +
144
+ " will not act on it twice. Replay protection working as designed."));
145
+ }
146
+ else {
147
+ console.log(" " + c.red("REJECTED") + c.grey(` — ${reason.slice(0, 120)}`));
148
+ console.log(c.grey("\n A rejection here is the system working. The guard refuses anything it cannot\n" +
149
+ " verify rather than falling back to a reported figure."));
150
+ }
151
+ console.log("");
152
+ kv("proven reserve", `${units(s.verifiedReserve, s.decimals)} @ height ${s.attestedAtHeight}`);
153
+ kv("outstanding supply", `${units(s.outstandingSupply, s.decimals)} ${s.symbol}`);
154
+ kv("effective backing", units(s.discountedReserve, s.decimals));
155
+ kv("status", s.solvent && s.fresh && !s.mintFrozen
156
+ ? c.green("CRYPTOGRAPHICALLY SOLVENT")
157
+ : c.red("MINTING FROZEN"));
158
+ kv("parties trusted", s.trustedParties === 0 ? c.green("0") : c.yellow(String(s.trustedParties)));
159
+ rule();
160
+ console.log(c.grey(` ${EXPLORER.sepolia}/tx/${txHash}`));
161
+ console.log(c.grey(` ${EXPLORER.creditcoin}/address/${ascAddr}`));
162
+ console.log("");
163
+ return verdict === "rejected" ? 1 : 0;
164
+ }
package/dist/config.js ADDED
@@ -0,0 +1,73 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /**
5
+ * Standalone configuration.
6
+ *
7
+ * Everything here has a working default. That is deliberate: the whole point of this
8
+ * CLI is that a stranger can run it against the live deployment without cloning the
9
+ * repo, funding a wallet, or obtaining a key. If you are running inside the repo the
10
+ * deployment files are picked up automatically and override the baked-in addresses.
11
+ */
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ export const RPC = {
14
+ creditcoin: process.env.CREDITCOIN_RPC_URL ?? "https://rpc.cc3-testnet.creditcoin.network",
15
+ sepolia: process.env.SEPOLIA_RPC_URL ?? "https://ethereum-sepolia-rpc.publicnode.com",
16
+ proofBuilder: process.env.PROOF_BUILDER_URL ?? "https://proof-gen-api.cc3-testnet.creditcoin.network",
17
+ };
18
+ export const EXPLORER = {
19
+ creditcoin: "https://creditcoin-testnet.blockscout.com",
20
+ sepolia: "https://sepolia.etherscan.io",
21
+ };
22
+ /** The live CC3 deployment, as of the addresses committed to this repo. */
23
+ const BAKED = {
24
+ creditcoin: {
25
+ chainId: 102031,
26
+ contracts: {
27
+ MintBoundASC: "0x91FAF68A9E5C0e013b5c01b7AACF4C841A6382f8",
28
+ WrappedAsset: "0x1f42B80ebac56AF3f023997A4240D3B97476A557",
29
+ ProvenReserveFeed: "0x5578784ddE6c05c0370119FF68c439847CB307D7",
30
+ ConventionalPoRFeed: "0xbAceA461241F5D9D27e2308D279AB1add95B226F",
31
+ SecureMintReference: "0x8f2A246623b000DE0486242f8806b0dDeF2375b9",
32
+ SolvencyGatedCredit: "0x44082286d90ebB087F34EE4Bc6Bd918B205d7156",
33
+ SolvencyContinuity: "0x448292774b807B49025002e256d004378f788d07",
34
+ },
35
+ config: {
36
+ sourceChainKey: 1,
37
+ canonicalVault: "0x1f42B80ebac56AF3f023997A4240D3B97476A557",
38
+ sourceAsset: "0x91FAF68A9E5C0e013b5c01b7AACF4C841A6382f8",
39
+ maxStalenessBlocks: 200,
40
+ haircutBps: 10000,
41
+ },
42
+ },
43
+ sepolia: {
44
+ chainId: 11155111,
45
+ contracts: {
46
+ TestUSD: "0x91FAF68A9E5C0e013b5c01b7AACF4C841A6382f8",
47
+ ReserveVault: "0x1f42B80ebac56AF3f023997A4240D3B97476A557",
48
+ SupplyBeacon: "0x448292774b807B49025002e256d004378f788d07",
49
+ },
50
+ },
51
+ };
52
+ /**
53
+ * Prefer an on-disk deployment record when one exists, so that a fresh redeploy is
54
+ * picked up without editing this file. Fall back to the baked-in addresses otherwise.
55
+ */
56
+ function load(name) {
57
+ for (const root of [join(here, "..", "..", ".."), join(here, "..", "..", "..", "..")]) {
58
+ const p = join(root, "deployments", `${name}.json`);
59
+ if (existsSync(p)) {
60
+ try {
61
+ return JSON.parse(readFileSync(p, "utf8"));
62
+ }
63
+ catch {
64
+ // A malformed local file should not defeat a working default.
65
+ }
66
+ }
67
+ }
68
+ return BAKED[name];
69
+ }
70
+ export const creditcoin = () => load("creditcoin");
71
+ export const sepolia = () => load("sepolia");
72
+ export const CHAIN_INFO_ADDRESS = "0x0000000000000000000000000000000000000fD3";
73
+ export const BLOCK_PROVER_ADDRESS = "0x0000000000000000000000000000000000000FD2";
package/dist/index.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { attack } from "./commands/attack.js";
3
+ import { claims } from "./commands/claims.js";
4
+ import { status } from "./commands/status.js";
5
+ import { verify } from "./commands/verify.js";
6
+ import { c } from "./render.js";
7
+ const USAGE = `
8
+ ${c.bold("mintbound")} — check MintBound's solvency evidence yourself
9
+
10
+ ${c.bold("status")} the whole balance sheet and assurance vector, live
11
+ ${c.bold("verify")} --source-tx <hash> walk one source transaction through the precompile
12
+ ${c.bold("attack")} fire the documented attacks at the live guard
13
+ ${c.bold("claims")} audit every claim our submission makes, live
14
+
15
+ ${c.grey("Options")}
16
+ --json machine-readable output (status only)
17
+ --help this text
18
+
19
+ ${c.grey("Everything here is read-only. No private key, no funded account, no setup — the")}
20
+ ${c.grey("Proof Builder is a read API and the guard's entry points are reachable by eth_call,")}
21
+ ${c.grey("so a stranger can check every claim MintBound makes without being trusted with")}
22
+ ${c.grey("anything. That is the point.")}
23
+
24
+ ${c.grey("Examples")}
25
+ npx @mintbound/cli status
26
+ npx @mintbound/cli verify --source-tx 0xc42a211e02ee86e5d92bb0bee2cef1679fbd358e474a044bdfe1e7ff7c9efa9c
27
+ npx @mintbound/cli attack
28
+ npx @mintbound/cli claims
29
+ `;
30
+ function arg(argv, name) {
31
+ const i = argv.indexOf(`--${name}`);
32
+ if (i >= 0 && argv[i + 1])
33
+ return argv[i + 1];
34
+ const inline = argv.find((a) => a.startsWith(`--${name}=`));
35
+ return inline ? inline.slice(name.length + 3) : undefined;
36
+ }
37
+ async function main() {
38
+ const argv = process.argv.slice(2);
39
+ const cmd = argv[0];
40
+ if (!cmd || argv.includes("--help") || argv.includes("-h")) {
41
+ console.log(USAGE);
42
+ return 0;
43
+ }
44
+ switch (cmd) {
45
+ case "status":
46
+ return status({ json: argv.includes("--json") });
47
+ case "verify": {
48
+ const tx = arg(argv, "source-tx") ?? arg(argv, "tx") ?? argv[1];
49
+ if (!tx || tx.startsWith("--")) {
50
+ console.error(c.red("verify needs a transaction hash: --source-tx 0x..."));
51
+ return 2;
52
+ }
53
+ return verify(tx);
54
+ }
55
+ case "attack":
56
+ return attack();
57
+ case "claims":
58
+ return claims({ json: argv.includes("--json") });
59
+ default:
60
+ console.error(c.red(`Unknown command: ${cmd}`));
61
+ console.log(USAGE);
62
+ return 2;
63
+ }
64
+ }
65
+ main()
66
+ .then((code) => {
67
+ process.exitCode = code ?? 0;
68
+ })
69
+ .catch((e) => {
70
+ console.error(c.red(`\n${e?.shortMessage ?? e?.message ?? e}`));
71
+ process.exitCode = 1;
72
+ });