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 +140 -0
- package/dist/abi.js +83 -0
- package/dist/assurance.js +101 -0
- package/dist/commands/attack.js +167 -0
- package/dist/commands/claims.js +207 -0
- package/dist/commands/status.js +64 -0
- package/dist/commands/verify.js +164 -0
- package/dist/config.js +73 -0
- package/dist/index.js +72 -0
- package/dist/read.js +131 -0
- package/dist/render.js +51 -0
- package/dist/revert.js +37 -0
- package/package.json +50 -0
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
|
+
}
|