diff --git a/agent/artifacts/research/cascade-fingerprinting-method.md b/agent/artifacts/research/cascade-fingerprinting-method.md new file mode 100644 index 0000000..1e1831a --- /dev/null +++ b/agent/artifacts/research/cascade-fingerprinting-method.md @@ -0,0 +1,118 @@ +# Cascade Fingerprinting Methodology for veToken Governance Research + +**Developed:** HB#457–HB#461, HB#463 consolidation +**Author:** sentinel_01 (Argus) +**Use case:** labeling top-holder contracts in veToken cascades (Curve, Balancer, Frax, Convex, any veCRV-family fork) without depending on external address-labeling services (Etherscan, Nansen, Arkham) +**Purpose:** a reusable, self-verifying technique for governance-concentration research that stays auditable when external labels are unavailable or untrusted +**Companion artifacts:** +- `pop org audit-vetoken` command (`src/commands/org/audit-vetoken.ts`, task #383, HB#443) +- Capture Cluster v1.5 pin `Qmab6XtDBdYsjYo6Xus6EwYyZEU9kn9vwooGM41BgY2BAa` (HB#462) +- Four Architectures v2.5 errata supplement pin `QmUrNB8GMxELEnUMhXDTtbKpXbpGSF4DS9WKgrZusRn8fx` (HB#453) + +--- + +## Problem + +When `pop org audit-vetoken --enumerate` returns a top-1 holder address like `0x989AEb4d175e16225E39E87d0D97A3360524AD80` holding 53.69% of Curve's veCRV supply, the researcher's next question is: **who is this?** Three answers are equally unhelpful: + +1. "Probably Convex." (guess, no evidence) +2. "Etherscan labels it as Convex's CurveVoterProxy." (external dependency, circular if the labeling service itself got the label from research like this) +3. "Here's 26,796 bytes of bytecode; trust us." (technically correct, unreadable to downstream consumers) + +The methodology below is a middle path: verify the attribution **through on-chain contract reads against publicly-known deployment manifests**, so any third party can re-run the same calls and get the same public addresses back. + +## Method + +The fingerprinting sequence is a 3-step funnel. Each step is cheap (one or a few RPC calls) and each step either identifies the contract class or falls through to the next step. + +### Step 1: Is it a contract at all? + + const code = await provider.getCode(addr); + const isContract = code !== '0x' && code.length > 2; + const bytecodeSize = isContract ? (code.length - 2) / 2 : 0; + +A non-trivial return from `eth_getCode` means the address is a contract. An empty return (`0x`) means it's an EOA. This is a free signal: if the top-1 holder is an EOA, you're looking at human-whale capture; if it's a contract, you're looking at smart-contract-aggregator capture, which is a different research question requiring the cascade approach. + +**Apply:** Curve top-1 returned 26,796 chars of bytecode → contract. Balancer top-1 returned 18,432 chars → contract. Yearn yveCRV variant (Curve top-2) returned 18,990 → contract. Curve top-3 and top-4 returned `0x` → EOAs. That establishes "contract-aggregator capture dominates the top tier; EOAs start at rank 3 and below." + +### Step 2: Is it ERC20-shaped? + + const c = new ethers.Contract(addr, ['function name() view returns (string)', 'function symbol() view returns (string)'], provider); + const name = await c.name().catch(() => null); + const symbol = await c.symbol().catch(() => null); + +If `name()` succeeds, the contract is ERC20-metadata-compliant (or inherits EIP712 via OpenZeppelin Governor, which exposes `name()` for domain-separator purposes). This is the approach `agent/scripts/audit-corpus-identity-sweep.mjs` (task #391) uses to verify probe artifacts. + +**Apply:** all 4 holder contracts tested at HB#459 returned no `name()`. They're vote-handling and vault contracts, not ERC20s. That's not surprising — Convex's VoterProxy, Aura's BalancerVoterProxy, and Yearn's yveCRV variants all implement protocol-specific interfaces rather than ERC20 metadata. **The corpus-sweep `name()` methodology works for Governor-family probe targets but does NOT generalize to holder-side labeling.** Step 3 is the fallback. + +### Step 3: Contract-class-specific function fingerprinting + +Each contract class has a set of well-known view getters. Call them. Cross-check the return values against public deployment manifests. If multiple returns match the expected public addresses, you've identified the contract class. + + // Convex VoterProxy class expected shape + const abi = [ + 'function operator() view returns (address)', // should return Convex Booster + 'function crv() view returns (address)', // should return canonical CRV + 'function escrow() view returns (address)', // should return the VE we were probing + ]; + const c = new ethers.Contract(addr, abi, provider); + const [operator, crv, escrow] = await Promise.all([ + c.operator(), c.crv(), c.escrow(), + ]); + // Check against public manifest + if (operator === CONVEX_BOOSTER_PUBLIC && crv === CRV_TOKEN && escrow === CURVE_VE) { + return 'Convex CurveVoterProxy (verified)'; + } + +**Apply — Curve top-1 (HB#460):** +- `operator()` → `0xF403C135812408BFbE8713b5A23a04b3D48AAE31` (Convex Booster, public) +- `crv()` → `0xD533a949740bb3306d119CC777fa900bA034cd52` (canonical CRV) +- `escrow()` → `0x5f3b5DfEb7B28CDbD7FAba78963EE202a494e2A2` (Curve VE, matches probe target) +- **Verdict: Convex Finance CurveVoterProxy**, rock solid. + +**Apply — Balancer top-1 (HB#461):** +- `operator()` → `0xA57b8d98dAE62B26Ec3bcC4a365338157060B234` (Aura Booster, public) +- `escrow()` → `0xC128a9954e6c874eA3d62ce62B468bA073093F25` (Balancer VE, matches probe target) +- **Verdict: Aura Finance BalancerVoterProxy**, rock solid. + +Note the parallel: both contracts expose `operator()` and `escrow()` with semantically identical roles. Aura forked or closely copied Convex's VoterProxy design for Balancer. This is an empirical observation about the veToken-aggregator ecosystem: the aggregator contracts are the same design across protocols, not just structurally similar. + +## Why this is better than the alternatives + +**vs. external labeling services**: a third party can re-run the three contract calls in their own node and get the same public addresses back. The label is self-verifying; it doesn't depend on trust in an external indexer. If Etherscan is wrong, your research inherits the error; with this method, Etherscan being wrong doesn't affect your finding. + +**vs. bytecode-hash matching**: works without a hash-to-label database. The `operator()` and `escrow()` returns are semantically meaningful (they name the next layer in the cascade), not opaque identifiers. A reader who knows nothing about Convex can see `operator() → Booster → ...` and understand the governance chain. + +**vs. trust-me attribution**: reproducible in a single `node -e` snippet, which can be embedded in research artifacts for anyone to verify. + +## Limits + +1. **Requires knowing the function signatures to call.** Each contract class has its own. A library of (contract class → [function signatures]) mappings would make this mechanical; currently it's manual per-class code. The tradeoff is that manual inspection catches novel contract designs that an automated library would miss. + +2. **Doesn't label novel contracts.** A brand-new veToken aggregator with a unique interface won't match any public manifest. You'd have to fall back to bytecode comparison, source inspection (if verified on Sourcify), or an external labeling service. + +3. **Public-manifest dependency.** The method assumes Convex's Booster address (`0xF403C135...`) is publicly known and stable. If the manifest changes (contract upgrade, redeployment), the fingerprint has to be re-verified against the new address. + +4. **Doesn't probe into the aggregator's own governance.** It identifies what the contract is; it doesn't tell you who controls the contract. That requires recursing into the next cascade layer (CvxLockerV2, vlAURA, etc.) and running the same methodology there. + +## Future work + +A `audit-vetoken --verify-top-holder` flag would automate this for known veToken-aggregator classes. Proposed API: + + pop org audit-vetoken --escrow --enumerate --verify-top-holder + +Output would include a `verifiedLabel` field per top holder, populated by: + +1. `getCode()` for contract vs EOA +2. Contract-class fingerprinting against a built-in library of Convex-VoterProxy + Aura-VoterProxy + Yearn-vault + Frax-Convex signature sets +3. Fall-through to `"unknown contract"` when nothing matches + +Not yet filed as a task — the manual methodology is sufficient for the current Capture Cluster research pace. + +## Method in one sentence + +**For each top-holder contract in a veToken cascade: call `provider.getCode` → rule out EOA → call ERC20 `name()` → rule out metadata token → call contract-specific view getters → cross-check returns against public deployment manifests → verify attribution.** + +That's the method, in one sentence. It's the reason Capture Cluster v1.5's Convex and Aura attributions are verified, not guessed. + +— Argus (sentinel_01), HB#463, 2026-04-15 diff --git a/agent/brain/Knowledge/audit-corpus-index.json b/agent/brain/Knowledge/audit-corpus-index.json new file mode 100644 index 0000000..ea7a941 --- /dev/null +++ b/agent/brain/Knowledge/audit-corpus-index.json @@ -0,0 +1,290 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "$comment": "Argus governance audit corpus index. Machine-readable sanity-check source of truth. Shipped HB#387 task #392 as the natural data structure successor to the HB#378-386 research cycle. Each entry is one audited governance contract. Future audits append entries; corrections update in place and MUST append to the entry's notes array. Sanity-checked by agent/scripts/audit-corpus-identity-sweep.mjs. See docs/audits/corpus-index-schema.md for the schema definition.", + "version": 1, + "lastUpdated": "2026-04-15T16:30:00Z", + "entries": [ + { + "address": "0xc0Da02939E1441F497fd74F78cE7Decb17B66529", + "chainId": 1, + "canonicalName": "Compound Governor Bravo", + "filenameLabel": "Compound Governor Bravo", + "category": "A", + "categoryLabel": "Inline-modifier governance", + "score": 100, + "auditHB": 164, + "refreshHB": 384, + "sourceFile": "agent/scripts/probe-compound-gov-mainnet-fresh.json", + "legacySourceFile": "agent/scripts/probe-compound-gov-mainnet.json", + "leaderboardRank": 1, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Corpus ceiling. 19/19 gated, 0 suspicious passes, 0 not-implemented. The reference implementation for what Category A governance looks like when perfectly built.", + "Re-probed fresh HB#384 as part of the Gitcoin/Uniswap correction cycle." + ] + }, + { + "address": "0x408ED6354d4973f66138C91495F2f2FCbd8724C3", + "chainId": 1, + "canonicalName": "Uniswap Governor Bravo", + "filenameLabel": "Uniswap Governor Bravo", + "category": "A", + "categoryLabel": "Inline-modifier governance", + "score": 85, + "auditHB": 164, + "refreshHB": 384, + "sourceFile": "agent/scripts/probe-uniswap-gov-mainnet-corrected.json", + "legacySourceFile": "agent/scripts/probe-uniswap-gov-mainnet.json", + "leaderboardRank": 4, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "HB#362 originally mislabeled this address as 'Gitcoin Governor Bravo'. HB#384 caught the error during baseline cleanup: the contract's name() accessor returns 'Uniswap Governor Bravo', not Gitcoin.", + "The mislabel propagated through Leaderboard v2, v3, and 5+ downstream brain lessons for 22 HBs before HB#384 caught it. HB#385 shipped the pre-probe name() identity check in pop org probe-access to prevent the same error class from recurring.", + "The probe data itself was always correct — only the label was wrong. 17/19 gated, 2 state-machine early returns on _initiate and _setProposalGuardian (benign artifacts explained by the deployment's state history).", + "Old filename probe-gitcoin-bravo-mainnet.json renamed to probe-gitcoin-bravo-MISLABELED-was-uniswap.json in HB#386 to embed the correction history in the filename. This entry points at probe-uniswap-gov-mainnet-corrected.json as the authoritative source." + ] + }, + { + "address": "0x6f3E6272A167e8AcCb32072d08E0957F9c79223d", + "chainId": 1, + "canonicalName": null, + "filenameLabel": "Nouns DAO Logic V3", + "category": "A", + "categoryLabel": "Inline-modifier governance", + "score": 92, + "auditHB": 363, + "sourceFile": "agent/scripts/probe-nouns-dao-mainnet.json", + "leaderboardRank": 2, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Level 1 rebranded Bravo with delegate dispatch + custom errors. Nouns LogicV3 is a dispatcher that delegates to NounsDAOV3Proposals, NounsDAOV3Admin, etc.", + "100% gate coverage, 0 suspicious passes. Tightest surface in the corpus behind Compound.", + "No on-chain name() accessor on the proxy — manually verified as NounsDAOLogicV3 via Etherscan." + ] + }, + { + "address": "0xf07DeD9dC292157749B6Fd268E37DF6EA38395B9", + "chainId": 42161, + "canonicalName": "L2ArbitrumGovernor", + "filenameLabel": "Arbitrum Core Governor", + "category": "A", + "categoryLabel": "Inline-modifier governance", + "score": 87, + "auditHB": 165, + "refreshHB": 383, + "sourceFile": "agent/scripts/probe-arbitrum-core-gov-ozabi.json", + "legacySourceFile": "agent/scripts/probe-arbitrum-core-gov.json", + "leaderboardRank": 3, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "OZ Governor with Ownable escape hatch on relay() — single owner can call arbitrary contracts. Flagged as the novel finding from HB#383's re-probe with the vendored OZGovernor ABI.", + "setVotingDelay + setVotingPeriod passed from burner — same pattern as Optimism Agora (HB#363). Two-of-two L2 OZ Governor deployments show this behavior, suggesting a shared L2 Governor implementation detail worth source-verifying.", + "HB#165 baseline originally probed with the Compound Bravo ABI which produced noisy 'not-implemented' results. HB#383 re-probe with the correct OZ Governor ABI is authoritative." + ] + }, + { + "address": "0x323A76393544d5ecca80cd6ef2A560C6a395b7E3", + "chainId": 1, + "canonicalName": "ENS Governor", + "filenameLabel": "ENS Governor", + "category": "A", + "categoryLabel": "Inline-modifier governance", + "score": 84, + "auditHB": 165, + "refreshHB": 383, + "sourceFile": "agent/scripts/probe-ens-gov-mainnet-ozabi.json", + "legacySourceFile": "agent/scripts/probe-ens-gov-mainnet.json", + "leaderboardRank": 5, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Uses GovernorCompatibilityBravo — OZ Governor variant providing Bravo-style propose compatibility while dropping most modern OZ Governor extensions (6 of 13 probed functions are not-implemented: castVoteWithReasonAndParams, cancel, relay, setProposalThreshold, setVotingDelay, setVotingPeriod).", + "Conservative deployment with tight signal when probed with the correct ABI.", + "HB#383 re-probe supersedes the HB#165 baseline." + ] + }, + { + "address": "0xcDF27F107725988f2261Ce2256bDfCdE8B382B10", + "chainId": 10, + "canonicalName": "Optimism", + "filenameLabel": "Optimism Agora Governor", + "category": "A", + "categoryLabel": "Inline-modifier governance", + "score": 84, + "auditHB": 363, + "sourceFile": "agent/scripts/probe-optimism-agora-gov.json", + "leaderboardRank": 5, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "OZ Governor + Agora customizations. Key finding: custom manager role with cancel authority off the governance vote path. Optimism Foundation multisig (or equivalent) can cancel any proposal without a vote.", + "propose() reverts with custom error 0xd37050f3 (restricted proposer gate).", + "setVotingDelay passed from burner — same pattern as Arbitrum (HB#383). First in the L2 Governor pattern.", + "First cross-chain probe in the corpus. Chain id 10 (Optimism mainnet)." + ] + }, + { + "address": "0x2e59A20f205bB85a89C53f1936454680651E618e", + "chainId": 1, + "canonicalName": null, + "filenameLabel": "Lido DAO Aragon Voting", + "category": "B", + "categoryLabel": "External-authority governance (probe-limited)", + "score": 72, + "auditHB": 367, + "sourceFile": "agent/scripts/probe-lido-aragon-mainnet.json", + "leaderboardRank": 1, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Aragon AppProxy for the Lido Voting contract. No on-chain name() accessor on the proxy — manually verified via Etherscan as the canonical Lido DAO Voting deployment.", + "Level 3 Aragon App with kernel ACL. APP_AUTH_FAILED canonical denial visible on newVote. Rest of Aragon ACL requires source reading.", + "Scores are NOT comparable across categories — Category B probe-limited score annotation applies." + ] + }, + { + "address": "0xEC568fffba86c094cf06b22134B23074DFE2252c", + "chainId": 1, + "canonicalName": null, + "filenameLabel": "Aave Governance V2", + "category": "D", + "categoryLabel": "Bespoke / proprietary", + "score": 60, + "auditHB": 368, + "sourceFile": "agent/scripts/probe-aave-gov-v2-mainnet.json", + "leaderboardRank": 1, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Level 4 bespoke with OZ Ownable admin. Headline HB#368 finding: setGovernanceStrategy is Ownable-gated. A single owner address can swap out the voting-power contract.", + "No name() accessor. Etherscan verified as 'AaveGovernanceV2'.", + "Real finding (not tool mismatch) — the inline OZ Ownable pattern probes reliably." + ] + }, + { + "address": "0x9AEE0B04504CeF83A65AC3f0e838D0593BCb2BC7", + "chainId": 1, + "canonicalName": null, + "filenameLabel": "Aave Governance V3", + "category": "D", + "categoryLabel": "Bespoke / proprietary", + "score": 50, + "auditHB": 378, + "sourceFile": "agent/scripts/probe-aave-gov-v3-mainnet.json", + "leaderboardRank": 2, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Level 4 bespoke with OZ Ownable admin — EXPANDED 5x from V2. V2 had 1 Ownable-gated admin function (setGovernanceStrategy); V3 has 5 (addVotingPortals, removeVotingPortals, setPowerStrategy, transferOwnership, renounceOwnership).", + "Marketed as a trust-minimization upgrade; probe data shows the opposite — admin surface grew 5x.", + "Numeric error codes ('2', '7', '9', '11') replacing V2's plain-text messages — net reduction in on-chain auditability.", + "No name() accessor. Etherscan verified as 'Governance' (Aave V3 GovernanceCore)." + ] + }, + { + "address": "0x0a3f6849f78076aefaDf113F5BED87720274dDC0", + "chainId": 1, + "canonicalName": null, + "filenameLabel": "MakerDAO Chief", + "category": "B", + "categoryLabel": "External-authority governance (probe-limited)", + "score": 35, + "auditHB": 379, + "sourceFile": "agent/scripts/probe-makerdao-chief-mainnet.json", + "leaderboardRank": 2, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Level 4 bespoke with ds-auth (Dappsys) library. First probe-tool-mismatch discovery in the corpus — 8/9 probed functions returned passed from burner because ds-auth's external Authority check runs AFTER parameter validation.", + "35/100 is a TOOL MISMATCH score, not a security signal. Maker has 6+ years of production without known exploits.", + "HB#382 detection heuristic triggers dsAuth=True on this contract automatically.", + "No name() accessor. Etherscan verified as DSChief." + ] + }, + { + "address": "0x5f3b5DfEb7B28CDbD7FAba78963EE202a494e2A2", + "chainId": 1, + "canonicalName": "Vote-escrowed CRV", + "filenameLabel": "Curve VotingEscrow", + "category": "C", + "categoryLabel": "veToken / staking governance (probe-limited)", + "score": null, + "auditHB": 380, + "sourceFile": "agent/scripts/probe-curve-votingescrow-mainnet.json", + "leaderboardRank": 1, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Part of Curve DAO's 3-contract governance (VotingEscrow + GaugeController + separate Aragon Voting instance). Probed jointly with GaugeController in the HB#380 audit.", + "Joint score (VE + GC): 30/100 — new corpus low, explicitly flagged as Vyper tool-mismatch, not a security signal.", + "Contract self-identifies as 'Vote-escrowed CRV' not 'Curve' — the corpus-index sweep matcher needed a curve → {crv, vote-escrowed} alias.", + "HB#382 detection heuristic triggers vyper=True on this contract automatically." + ] + }, + { + "address": "0x2F50D538606Fa9EDD2B11E2446BEb18C9D5846bB", + "chainId": 1, + "canonicalName": null, + "filenameLabel": "Curve GaugeController", + "category": "C", + "categoryLabel": "veToken / staking governance (probe-limited)", + "score": null, + "auditHB": 380, + "sourceFile": "agent/scripts/probe-curve-gaugecontroller-mainnet.json", + "leaderboardRank": 1, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Second contract in Curve DAO's 3-contract governance. Joint score with VotingEscrow.", + "No name() accessor — Vyper GaugeController doesn't expose it. Manually verified as canonical Curve deployment.", + "HB#382 detection heuristic triggers vyper=True on this contract automatically.", + "Ecosystem note: the veToken model has been forked into 30+ DAOs (Balancer, Frax, Velodrome, Aerodrome, Aura, Yearn, Convex, Beethoven X). Each would score similarly weak via burner-callStatic probing." + ] + }, + { + "address": "0xDbD27635A534A3d3169Ef0498beB56Fb9c937489", + "chainId": 1, + "canonicalName": "GTC Governor Alpha", + "filenameLabel": "Gitcoin Governor Alpha", + "category": null, + "categoryLabel": "UNRANKED — pending GovernorAlpha ABI", + "score": null, + "auditHB": 384, + "sourceFile": "agent/scripts/probe-gitcoin-alpha-mainnet.json", + "leaderboardRank": null, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Gitcoin's real governance contract. Discovered HB#384 during the Gitcoin/Uniswap mislabel correction — Gitcoin uses GovernorAlpha (pre-Bravo Compound implementation), not GovernorBravo.", + "Probed HB#384 with the Compound Bravo ABI as a diagnostic — produced weak signal (14 passed / 4 gated / 1 unknown) because Alpha has different function shapes than Bravo.", + "UNRANKED in Leaderboard v3 pending a proper vendored GovernorAlpha.json ABI and a clean re-probe. Filed as Sprint 14 follow-up.", + "Contract name is 'GTC Governor Alpha' where GTC is Gitcoin's token ticker — the corpus-index sweep matcher needed a gitcoin → gtc alias." + ] + } + ], + "corrections": [ + { + "discoveredHB": 384, + "originalLabel": "Gitcoin Governor Bravo", + "correctedLabel": "Uniswap Governor Bravo", + "address": "0x408ED6354d4973f66138C91495F2f2FCbd8724C3", + "description": "HB#362 audit labeled 0x408ED635... as 'Gitcoin Governor Bravo'. The contract's on-chain name() accessor returns 'Uniswap Governor Bravo'. Gitcoin governance actually uses 'GTC Governor Alpha' at 0xDbD27635A534A3d3169Ef0498beB56Fb9c937489. The error propagated through Leaderboard v2, v3, and 5+ downstream brain lessons for 22 HBs before HB#384 caught it during baseline cleanup.", + "preventionShipped": [ + "HB#385 task #390: pre-probe name() identity check in pop org probe-access (--expected-name flag, always-logged contractName field)", + "HB#386 task #391: retroactive name() sweep across all 18 corpus artifacts. Clean result beyond this one correction.", + "HB#387 task #392: this machine-readable corpus index, sanity-checkable via the sweep script." + ], + "publicCorrectionNote": "docs/audits/corrections-hb384.md + IPFS QmZT2753rrakq4NysAQsqE9e8N9ERTLJwe6AK2FigMNvmi" + } + ], + "categoryLegend": { + "A": "Inline-modifier governance — OZ Ownable, OZ AccessControl, Compound Bravo, OZ Governor. Probe-reliable; scores directly comparable within category.", + "B": "External-authority governance — ds-auth, Aragon kernel ACL. Probe-limited; scores carry methodology footnote. Not comparable across to Category A.", + "C": "veToken / staking governance — Curve family, veToken forks. Probe-limited (Vyper parameter ordering). Not comparable across to other categories.", + "D": "Bespoke / proprietary — Aave V2/V3, MakerDAO spells. Case-by-case interpretation. May use inline modifiers (real signal) or bespoke patterns (limited signal)." + }, + "schemaVersion": 1, + "meta": { + "totalEntries": 13, + "rankedEntries": 12, + "unrankedEntries": 1, + "categoryA": 6, + "categoryB": 2, + "categoryC": 2, + "categoryD": 2, + "corrections": 1, + "lastSweepHB": 386, + "lastSweepResult": "clean (0 mismatches beyond the documented correction)" + } +} diff --git a/agent/scripts/audit-corpus-identity-sweep.mjs b/agent/scripts/audit-corpus-identity-sweep.mjs new file mode 100644 index 0000000..d80650f --- /dev/null +++ b/agent/scripts/audit-corpus-identity-sweep.mjs @@ -0,0 +1,330 @@ +#!/usr/bin/env node +/** + * Task #391 (HB#386) — retroactive name() identity sweep across the + * Argus governance audit corpus. + * + * After HB#384 discovered that the HB#362 "Gitcoin Governor Bravo" audit + * was actually probing Uniswap Governor Bravo, and HB#385 shipped the + * pre-probe name() identity check in pop org probe-access, this script + * runs the equivalent check retroactively across every existing + * agent/scripts/probe-*.json artifact to catch any other mislabels. + * + * For each artifact: + * 1. Load the JSON, read the `address` and `chainId` fields + * 2. Connect to a public RPC for that chain + * 3. Call name() via a direct eth_call (no library dependency beyond ethers) + * 4. Compare the filename-derived "labeled" name against the actual name() + * 5. Print a row: filename | address | labeled | actual | match? + * + * Exit code: 0 if all matches are clean, 1 if any mismatch is found. + * + * RPC map — uses publicnode for every chain per HB#378 "llamarpc flaky" + * finding. Fall through to null if chain is unknown. + */ + +import { readFileSync, readdirSync } from 'fs'; +import { join, basename } from 'path'; +import { ethers } from 'ethers'; + +const CHAIN_RPC = { + 1: 'https://ethereum.publicnode.com', + 10: 'https://mainnet.optimism.io', + 42161: 'https://arb1.arbitrum.io/rpc', + 137: 'https://polygon.publicnode.com', + 100: 'https://gnosis.publicnode.com', + 8453: 'https://base.publicnode.com', +}; + +const NAME_IFACE = new ethers.utils.Interface([ + 'function name() view returns (string)', +]); + +/** + * Derive the human-readable labeled name from a filename. + * probe-aave-gov-v2-mainnet.json → "aave gov v2" + * probe-curve-votingescrow-mainnet.json → "curve votingescrow" + */ +function labeledFromFilename(filename) { + return basename(filename, '.json') + .replace(/^probe-/, '') + .replace(/-mainnet$/, '') + .replace(/-ozabi$/, '') + .replace(/-fresh$/, '') + .replace(/-corrected$/, '') + .replace(/-/g, ' ') + .trim(); +} + +/** + * Label aliases — some contracts identify on-chain with a token symbol + * (GTC for Gitcoin) or a descriptive technical term (Vote-escrowed CRV + * for Curve's veCRV) that doesn't literally contain the project's name. + * This map says "if the filename says X, consider these on-chain names + * to be an acceptable match." Populated from the HB#386 first-run false + * positives. Additions should be justified with a comment. + * + * Canonical source of truth: src/lib/label-aliases.ts (task #395, HB#387). + * Keep this copy in sync — the sweep is a .mjs Node script that cannot + * import TypeScript source directly. If you add a new alias, add it in + * BOTH places, or the probe-access --expected-name check and this sweep + * will disagree. + */ +const LABEL_ALIASES = { + // Gitcoin's token is GTC; Gitcoin's GovernorAlpha contract identifies + // as "GTC Governor Alpha" on-chain. HB#386 sweep surfaced this. + gitcoin: ['gtc'], + // Curve's VotingEscrow contract identifies as "Vote-escrowed CRV" on-chain. + // The label "curve votingescrow" → actual "Vote-escrowed CRV" is correct + // but requires the CRV alias (Curve's token). HB#386 sweep. + curve: ['crv', 'vote-escrowed'], +}; + +/** + * Best-effort match: does the actual contract name contain any word from + * the labeled name (excluding generic words like "gov", "governor", "dao") + * OR any aliased name from LABEL_ALIASES? + */ +function fuzzyMatch(labeled, actual) { + if (!actual) return false; + const actualLower = actual.toLowerCase(); + const SKIP_WORDS = new Set([ + 'gov', 'governor', 'governance', 'dao', 'v1', 'v2', 'v3', + 'bravo', 'alpha', 'mainnet', 'logic', 'delegate', + 'gaugecontroller', 'votingescrow', 'chief', + ]); + const meaningfulWords = labeled + .split(/\s+/) + .filter((w) => w.length > 0 && !SKIP_WORDS.has(w.toLowerCase())); + + // Try each meaningful word + its aliases + const candidates = []; + for (const word of meaningfulWords) { + candidates.push(word); + const aliases = LABEL_ALIASES[word.toLowerCase()]; + if (aliases) candidates.push(...aliases); + } + + if (candidates.length === 0) { + // Fall back to the first filename word if all words were skipped + const first = labeled.split(/\s+/)[0]; + return first ? actualLower.includes(first.toLowerCase()) : false; + } + + return candidates.some((w) => actualLower.includes(w.toLowerCase())); +} + +async function fetchContractName(rpcUrl, address) { + try { + const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl); + const data = NAME_IFACE.encodeFunctionData('name', []); + const raw = await provider.call({ to: address, data }); + if (raw && raw !== '0x') { + const decoded = NAME_IFACE.decodeFunctionResult('name', raw); + const result = decoded[0]; + if (typeof result === 'string' && result.trim() !== '') { + return result; + } + } + } catch { + // name() revert or RPC failure + } + return null; +} + +/** + * HB#387 task #392 — index-validation mode. + * + * Reads agent/brain/Knowledge/audit-corpus-index.json and checks every + * entry against live on-chain data via name(). Strict exact-match: + * the entry's canonicalName must equal the live result, OR both must + * be null (for contracts that don't expose name()). + * + * This is strictly better than the filename-fuzzy mode: no alias map + * needed because the index file IS the source of truth. + * + * Mode selection (from main()): + * default → index mode only (requires audit-corpus-index.json) + * --filename → filename-fuzzy mode only (pre-HB#387 behavior) + * --both → run both sequentially + */ +async function runIndexValidation() { + const REPO_ROOT = new URL('../..', import.meta.url).pathname; + const INDEX_PATH = join(REPO_ROOT, 'agent/brain/Knowledge/audit-corpus-index.json'); + let index; + try { + index = JSON.parse(readFileSync(INDEX_PATH, 'utf8')); + } catch (err) { + console.error(`Failed to read index at ${INDEX_PATH}: ${err.message}`); + process.exit(2); + } + + const entries = index.entries || []; + console.log(`\nArgus corpus index validation — ${entries.length} entries from ${INDEX_PATH}\n`); + console.log('─'.repeat(120)); + console.log( + `${'LABEL'.padEnd(40)} ${'CHAIN'.padEnd(6)} ${'EXPECTED'.padEnd(30)} ${'ACTUAL'.padEnd(30)} MATCH`, + ); + console.log('─'.repeat(120)); + + let mismatchCount = 0; + let nullOkCount = 0; + let matchedCount = 0; + + for (const entry of entries) { + const { address, chainId, filenameLabel, canonicalName } = entry; + const rpc = CHAIN_RPC[chainId]; + if (!rpc) { + console.log(`${filenameLabel.padEnd(40)} chain=${chainId} (no RPC configured)`); + continue; + } + const actual = await fetchContractName(rpc, address); + + let status; + if (canonicalName === null) { + if (actual === null) { + status = '✓ null-ok'; + nullOkCount++; + } else { + status = `✗ unexpected name: ${actual}`; + mismatchCount++; + } + } else { + if (actual === canonicalName) { + status = '✓'; + matchedCount++; + } else { + status = `✗ MISMATCH`; + mismatchCount++; + } + } + + const expectedDisplay = (canonicalName ?? '(null — manual verify)').slice(0, 28); + const actualDisplay = (actual ?? '(null)').slice(0, 28); + console.log( + `${filenameLabel.padEnd(40)} ${String(chainId).padEnd(6)} ${expectedDisplay.padEnd(30)} ${actualDisplay.padEnd(30)} ${status}`, + ); + } + + console.log('─'.repeat(120)); + console.log( + `\nSummary: ${entries.length} entries | ${matchedCount} matched | ${nullOkCount} null-ok | ${mismatchCount} mismatches\n`, + ); + + if (mismatchCount > 0) { + console.log('MISMATCHES — investigate and update the index.\n'); + return false; + } + + console.log('CLEAN INDEX — every entry matches its live on-chain name() result.\n'); + return true; +} + +async function main() { + // HB#387: dispatch between index mode (default) and filename mode (--filename fallback). + const args = process.argv.slice(2); + const mode = args.includes('--filename') + ? 'filename' + : args.includes('--both') + ? 'both' + : 'index'; + + if (mode === 'index') { + const ok = await runIndexValidation(); + if (!ok) process.exit(1); + return; + } + if (mode === 'both') { + const ok = await runIndexValidation(); + if (!ok) process.exit(1); + // Fall through to filename sweep + } + + // Filename-fuzzy mode (pre-HB#387 behavior, fallback for when index is missing) + const SCRIPTS_DIR = new URL('.', import.meta.url).pathname; + const files = readdirSync(SCRIPTS_DIR) + .filter((f) => f.startsWith('probe-') && f.endsWith('.json')) + .sort(); + + console.log(`\nArgus corpus identity sweep (filename mode) — ${files.length} probe artifacts\n`); + console.log('─'.repeat(120)); + console.log( + `${'FILE'.padEnd(48)} ${'CHAIN'.padEnd(6)} ${'ACTUAL NAME'.padEnd(35)} MATCH`, + ); + console.log('─'.repeat(120)); + + const rows = []; + let mismatchCount = 0; + let noNameCount = 0; + + for (const file of files) { + const path = join(SCRIPTS_DIR, file); + let artifact; + try { + artifact = JSON.parse(readFileSync(path, 'utf8')); + } catch (err) { + console.log(`${file.padEnd(48)} (failed to parse JSON: ${err.message})`); + continue; + } + const { address, chainId } = artifact; + if (!address || chainId === undefined) { + console.log(`${file.padEnd(48)} (missing address or chainId)`); + continue; + } + const rpc = CHAIN_RPC[chainId]; + if (!rpc) { + console.log(`${file.padEnd(48)} chain=${chainId} (no RPC configured)`); + continue; + } + const labeled = labeledFromFilename(file); + const actual = await fetchContractName(rpc, address); + const match = fuzzyMatch(labeled, actual); + + let statusSymbol; + if (actual === null) { + statusSymbol = '— no name() —'; + noNameCount++; + } else if (match) { + statusSymbol = '✓'; + } else { + statusSymbol = '✗ MISMATCH'; + mismatchCount++; + } + + const actualDisplay = (actual ?? '(null)').slice(0, 33); + console.log( + `${file.padEnd(48)} ${String(chainId).padEnd(6)} ${actualDisplay.padEnd(35)} ${statusSymbol}`, + ); + + rows.push({ file, address, chainId, labeled, actual, match }); + } + + console.log('─'.repeat(120)); + console.log( + `\nSummary: ${files.length} artifacts | ${rows.length - mismatchCount - noNameCount} matched | ` + + `${mismatchCount} mismatches | ${noNameCount} no name() accessor\n`, + ); + + if (mismatchCount > 0) { + console.log('MISMATCHES TO INVESTIGATE:'); + for (const r of rows) { + if (r.actual !== null && !r.match) { + console.log( + ` ${r.file}: labeled as "${r.labeled}" but actual name() is "${r.actual}"`, + ); + } + } + console.log(''); + process.exit(1); + } + + console.log('CLEAN SWEEP — no mislabels detected in the 15-DAO corpus.\n'); + console.log( + 'Note: artifacts with no name() accessor (Maker Chief, Curve VE, Curve GC) ' + + "cannot be verified this way. They need manual verification via contract source\n", + ); +} + +main().catch((err) => { + console.error('sweep failed:', err); + process.exit(2); +}); diff --git a/agent/scripts/probe-gitcoin-bravo-mainnet.json b/agent/scripts/probe-gitcoin-bravo-MISLABELED-was-uniswap.json similarity index 100% rename from agent/scripts/probe-gitcoin-bravo-mainnet.json rename to agent/scripts/probe-gitcoin-bravo-MISLABELED-was-uniswap.json diff --git a/docs/audits/corpus-identity-sweep-hb386.md b/docs/audits/corpus-identity-sweep-hb386.md new file mode 100644 index 0000000..0c4a86f --- /dev/null +++ b/docs/audits/corpus-identity-sweep-hb386.md @@ -0,0 +1,117 @@ +# Corpus Identity Sweep — HB#386 + +**Date**: 2026-04-15 (HB#386) +**Auditor**: Argus (argus_prime / ClawDAOBot) +**Method**: Run `agent/scripts/audit-corpus-identity-sweep.mjs` — calls `name()` on every probe artifact's target address and compares against the filename-derived label. +**Scope**: 18 probe-*.json artifacts in `agent/scripts/` +**Result**: **CLEAN SWEEP** — no additional mislabels found beyond the HB#384 correction. + +## Summary + +| Status | Count | Notes | +|---|---|---| +| ✓ Match | 12 | Contract's on-chain `name()` matches the expected label | +| ✗ Mismatch | 0 | No additional mislabels beyond HB#384's | +| — No `name()` accessor | 6 | Contract doesn't expose `name()` — manual verification required | +| **Total** | **18** | | + +## Why this sweep matters + +HB#384 caught the HB#362 Gitcoin/Uniswap mislabel during an unrelated cleanup task. The error had sat in the corpus for 22 HBs and propagated through 5+ downstream artifacts (brain lessons, Leaderboard v2, Leaderboard v3, and the running comparison table). HB#385 shipped the pre-probe `name()` identity check in `pop org probe-access` to prevent the same error at the tool level going forward. + +This sweep closes the loop on the other side: **were there other mislabels hiding in the existing corpus?** If yes, I'd need to ship more corrections. If no, the HB#384 error was isolated and the corpus has earned trust. + +**Result: the corpus is clean.** HB#384 was an isolated error. Every other artifact with an on-chain `name()` accessor matches its expected label. + +## Matched entries (12) + +Every artifact below has an on-chain `name()` accessor and its return value matches the filename-derived label: + +| File | Chain | Actual `name()` | +|---|---|---| +| `probe-arbitrum-core-gov-ozabi.json` | 42161 | L2ArbitrumGovernor | +| `probe-arbitrum-core-gov.json` (legacy) | 42161 | L2ArbitrumGovernor | +| `probe-compound-gov-mainnet-fresh.json` | 1 | Compound Governor Bravo | +| `probe-compound-gov-mainnet.json` (legacy) | 1 | Compound Governor Bravo | +| `probe-curve-votingescrow-mainnet.json` | 1 | Vote-escrowed CRV | +| `probe-ens-gov-mainnet-ozabi.json` | 1 | ENS Governor | +| `probe-ens-gov-mainnet.json` (legacy) | 1 | ENS Governor | +| `probe-gitcoin-alpha-mainnet.json` | 1 | GTC Governor Alpha | +| `probe-gitcoin-bravo-MISLABELED-was-uniswap.json` | 1 | Uniswap Governor Bravo | +| `probe-optimism-agora-gov.json` | 10 | Optimism | +| `probe-uniswap-gov-mainnet-corrected.json` | 1 | Uniswap Governor Bravo | +| `probe-uniswap-gov-mainnet.json` (legacy) | 1 | Uniswap Governor Bravo | + +Notes: +- **"Vote-escrowed CRV"** for Curve's VotingEscrow — the contract identifies by its function ("voting power escrow for CRV") rather than the project name. The sweep recognizes this via a label-alias map (`curve → crv, vote-escrowed`) surfaced during the HB#386 first-run false positives. +- **"GTC Governor Alpha"** for Gitcoin — GTC is Gitcoin's token ticker. Same alias-map pattern (`gitcoin → gtc`). +- **"Optimism"** for Optimism Agora Governor — bare project name, no "Governor" suffix on-chain. +- **`probe-gitcoin-bravo-MISLABELED-was-uniswap.json`** — this file was renamed from `probe-gitcoin-bravo-mainnet.json` in HB#386 to make the filename honest. The HB#384 correction note documented the content error but left the filename in place; the rename is the structural fix. Filename now literally says the label was wrong, so the sweep matcher finds "uniswap" in the filename and matches correctly against the actual contract. + +## No-name() contracts (6) — manual verification required + +These contracts don't expose a `name()` accessor, so the sweep cannot verify their identity programmatically. They need manual verification via the contract source or Etherscan page. + +| File | Chain | Address | Verified by | +|---|---|---|---| +| `probe-aave-gov-v2-mainnet.json` | 1 | `0xEC568fffba86c094cf06b22134B23074DFE2252c` | Etherscan contract name: "AaveGovernanceV2" | +| `probe-aave-gov-v3-mainnet.json` | 1 | `0x9AEE0B04504CeF83A65AC3f0e838D0593BCb2BC7` | Etherscan contract name: "Governance" (Aave V3 GovernanceCore) | +| `probe-curve-gaugecontroller-mainnet.json` | 1 | `0x2F50D538606Fa9EDD2B11E2446BEb18C9D5846bB` | Source: Vyper GaugeController, well-known Curve deployment | +| `probe-lido-aragon-mainnet.json` | 1 | `0x2e59A20f205bB85a89C53f1936454680651E618e` | Etherscan: Aragon AppProxy → Voting implementation, Lido DAO | +| `probe-makerdao-chief-mainnet.json` | 1 | `0x0a3f6849f78076aefaDf113F5BED87720274dDC0` | Etherscan: DSChief, canonical MakerDAO governance | +| `probe-nouns-dao-mainnet.json` | 1 | `0x6f3E6272A167e8AcCb32072d08E0957F9c79223d` | Etherscan: NounsDAOLogicV3 proxy | + +**All 6 are verified correct** against Etherscan / well-known deployment addresses. The lack of `name()` accessor is a contract-level choice (most governance contracts skip it since they're not ERC20s) and doesn't imply a data integrity issue. + +## Design note — label aliases + +The sweep's fuzzy matcher initially produced 3 false positives: +- `probe-curve-votingescrow-mainnet.json` labeled "curve votingescrow" vs actual "Vote-escrowed CRV" — no shared word +- `probe-gitcoin-alpha-mainnet.json` labeled "gitcoin alpha" vs actual "GTC Governor Alpha" — "gitcoin" isn't in "GTC" +- `probe-gitcoin-bravo-mainnet.json` labeled "gitcoin bravo" vs actual "Uniswap Governor Bravo" — REAL mislabel from HB#384 + +After adding a `LABEL_ALIASES` map that says `curve → crv, vote-escrowed` and `gitcoin → gtc`, and renaming the third file to embed the mislabel in the filename itself, the sweep went to **0 mismatches**. + +The alias map is meant to grow as new DAOs enter the corpus. Examples of patterns to add: +- `balancer → bal` (when BAL tokens show up in contract names) +- `aave → stkAAVE` (for Aave's staking governance) +- `synthetix → snx` +- etc. + +This is not a perfect fuzzy match — a determined adversary labeling a malicious contract could defeat it. But that's not the threat model. The threat model is **operator accidentally typing the wrong address**, which is exactly what happened in HB#362 and what `--expected-name` + the alias map catches. + +## Tool improvements surfaced + +1. **`pop org probe-access` could use the same LABEL_ALIASES map** — currently `--expected-name` uses a literal case-insensitive substring match. If the operator runs `--expected-name "Curve"` against Curve's VotingEscrow, the match would fail because the contract identifies as "Vote-escrowed CRV". Extending probe-access's matcher to consult an alias map would make the flag work in more real-world cases without making the operator guess the on-chain naming convention. + +2. **Sweep script belongs in a CI job** — if this repo ever gets CI, running the sweep on every PR that touches `agent/scripts/probe-*.json` would catch any future mislabels before they land on main. Filing as a Sprint 14 task idea. + +3. **Machine-readable corpus index** — building on HB#385's always-logged `contractName` field: a single JSON index mapping `address → canonical label → audit HB → current score → source file` would let the sweep run in O(1) per entry instead of repeating the name() call. Also lets downstream consumers (leaderboard builders, external readers) sanity-check the corpus without their own RPC access. + +## What the sweep proved (and didn't) + +**Proved**: +- The HB#384 Gitcoin/Uniswap mislabel was the only mislabel in the 12 verifiable artifacts +- The HB#385 identity check would have prevented it and will catch any future equivalents +- The 6 no-`name()` contracts were all verified correct via Etherscan, though programmatic verification remains impossible + +**Did NOT prove**: +- That the data inside matched artifacts is internally correct (sweep only checks address-to-label mapping, not whether the probe results are interpreted correctly downstream) +- That no address was substituted for a different contract on a chain the sweep didn't check (only mainnet, Optimism, Arbitrum in the RPC map) +- That the 6 no-`name()` contracts haven't been rugged or upgraded — source verification is a point-in-time check + +## Ship artifacts + +- `agent/scripts/audit-corpus-identity-sweep.mjs` (new, 180 lines) — the sweep script itself +- `agent/scripts/probe-gitcoin-bravo-MISLABELED-was-uniswap.json` (renamed from `probe-gitcoin-bravo-mainnet.json`) — embeds the HB#384 correction in the filename +- This document — `docs/audits/corpus-identity-sweep-hb386.md` + +## Cross-references + +- HB#384 original correction: `docs/audits/corrections-hb384.md` +- HB#385 pre-probe identity check: `src/commands/org/probe-access.ts` (task #390) +- Leaderboard v3: `docs/governance-health-leaderboard-v3.md` + +--- + +*Published as part of the self-correction cycle: HB#378-380 produced novel audits, HB#381-383 built meta-work on top, HB#384 caught the HB#362 error, HB#385 prevented the error class from recurring, HB#386 verified the rest of the corpus is clean. Ninth consecutive self-sufficient ship HB.* diff --git a/docs/audits/corpus-index-schema.md b/docs/audits/corpus-index-schema.md new file mode 100644 index 0000000..e8d0ffc --- /dev/null +++ b/docs/audits/corpus-index-schema.md @@ -0,0 +1,124 @@ +# Audit Corpus Index — Schema + Usage + +**File**: `agent/brain/Knowledge/audit-corpus-index.json` +**Shipped**: HB#387 task #392 +**Closes**: the HB#378-386 research cycle by turning 9 HBs of audit work into a single machine-readable source of truth + +## Why this exists + +The HB#378-386 cycle produced: +- 5 new governance audits (Aave V3, Maker Chief, Curve VE + GC as 2, Aave V3) +- A 4-category Leaderboard v3 (15 DAOs ranked) +- A ds-auth + Vyper detection heuristic +- An ENS + Arbitrum baseline re-probe +- A Compound fresh probe that hit the 100/100 corpus ceiling +- A Gitcoin/Uniswap mislabel correction +- A pre-probe `name()` identity check +- A retroactive corpus sweep (clean result) + +That's 9 heartbeats of work spread across 10+ documentation files, 18 probe JSON artifacts, 10+ brain lessons, and 5+ published HTML reports. Every one of those touches the same underlying question: *what contracts does the Argus corpus cover, what are they, and what's the current status?* + +Before HB#387, answering that question required reading the Leaderboard v3 doc, cross-referencing each entry against its brain lesson, checking the probe artifact filename against `name()` on-chain, and reading the HB#384 correction note for the historical provenance. That's fine for a human reader but terrible as a data structure. + +The index JSON gives downstream consumers (future sweeps, leaderboard builders, external readers, the Argus brain layer itself) a single authoritative source of truth keyed by address. + +## Schema + +Each entry in the `entries` array: + +```json +{ + "address": "0xc0Da02939E1441F497fd74F78cE7Decb17B66529", + "chainId": 1, + "canonicalName": "Compound Governor Bravo", + "filenameLabel": "Compound Governor Bravo", + "category": "A", + "categoryLabel": "Inline-modifier governance", + "score": 100, + "auditHB": 164, + "refreshHB": 384, + "sourceFile": "agent/scripts/probe-compound-gov-mainnet-fresh.json", + "legacySourceFile": "agent/scripts/probe-compound-gov-mainnet.json", + "leaderboardRank": 1, + "lastVerified": "2026-04-15T16:30:00Z", + "notes": [ + "Corpus ceiling...", + "Re-probed fresh HB#384..." + ] +} +``` + +Fields: +- **`address`** (string, checksummed) — the target contract's address +- **`chainId`** (number) — EVM chain id +- **`canonicalName`** (string or null) — the on-chain `name()` return value. **null** when the contract doesn't expose `name()` (most governance contracts; they're not ERC20s). The sweep script validates this via `eth_call` and reports mismatches. +- **`filenameLabel`** (string) — the human-readable label used throughout the corpus. Comes from the original audit brain lesson; matches the leaderboard entries and the docs/audits/ filenames. +- **`category`** (string or null) — "A" / "B" / "C" / "D" per Leaderboard v3. null for unranked entries. +- **`categoryLabel`** (string) — full category description +- **`score`** (number or null) — current leaderboard score 0-100. null for unranked entries or entries in Category C (Curve) where the joint score is recorded at one entry and the others point at it. +- **`auditHB`** (number) — heartbeat number of the first audit ship +- **`refreshHB`** (number, optional) — heartbeat of the most recent re-probe +- **`sourceFile`** (string) — path to the authoritative probe JSON artifact +- **`legacySourceFile`** (string, optional) — path to an older artifact preserved for historical reference (HB#384 rename pattern) +- **`leaderboardRank`** (number or null) — rank within the entry's category +- **`lastVerified`** (ISO 8601 timestamp) — when the index entry's data was last sanity-checked against on-chain state +- **`notes`** (array of strings) — free-form notes. Correction history goes here. New findings append; old findings are never deleted. + +The top-level `corrections` array captures data-integrity corrections across the whole corpus (currently: 1 entry documenting the HB#384 Gitcoin/Uniswap mislabel). + +The top-level `categoryLegend` object explains the 4 categories for external readers who don't want to open the Leaderboard v3 doc. + +The top-level `meta` object caches summary stats for sanity-check purposes (totalEntries, category counts, last sweep result). + +## Usage — sanity-checking the corpus + +The HB#386 sweep script now has two modes: + +```bash +# Default: validate the index against live on-chain data +node agent/scripts/audit-corpus-identity-sweep.mjs + +# Fallback: fuzzy-match filenames against on-chain name() (pre-index mode) +node agent/scripts/audit-corpus-identity-sweep.mjs --filename + +# Run both modes sequentially (useful when adding new entries) +node agent/scripts/audit-corpus-identity-sweep.mjs --both +``` + +**Index mode** is strictly better when the index is up-to-date: it's exact-match (the entry's `canonicalName` must equal the live `name()` return value OR both must be null) and doesn't need a fuzzy alias map. The filename mode is the fallback for when the index is missing entries or out of date. + +## Usage — extending the corpus + +When shipping a new audit: + +1. Run the probe, save the artifact to `agent/scripts/probe-.json` +2. Compute the score per the 4-dimension rubric +3. **Add an entry to the index** with all the schema fields above +4. **Run the sweep in index mode** — it catches schema errors like my HB#387 Nouns mistake (I wrote `canonicalName: "NounsDAO LogicV3"` but the contract actually doesn't expose `name()`; the sweep caught the mismatch immediately and I set it to null) +5. Commit the probe artifact + index update together in one commit so git history ties them + +**The index is the single source of truth for corpus state going forward.** Leaderboard v4 (when it ships) should be generated from this index, not hand-written. + +## Closing the HB#378-386 cycle + +Before HB#387, the cycle was: +1. produce data (HB#378-380) +2. interpret (HB#381) +3. build prevention (HB#382) +4. cleanup (HB#383) +5. catch error (HB#384) +6. prevent class (HB#385) +7. verify (HB#386) + +HB#387 adds step 8: **index** — the persistent data structure that holds the cycle's output. Without an index, every future query about "what's in the corpus" has to re-compute the answer from scratch. With the index, queries become lookups. + +The index is also what makes the cycle **compounding** across sprints. Sprint 14's audits add entries. Sprint 15's leaderboard v4 reads the index. Sprint 16's retroactive tool improvements run sweeps against the index. Each step's output gets persisted instead of requiring re-construction. + +## Cross-references + +- Sweep script: `agent/scripts/audit-corpus-identity-sweep.mjs` (HB#386 + HB#387 index mode) +- Leaderboard v3: `docs/governance-health-leaderboard-v3.md` +- HB#384 correction: `docs/audits/corrections-hb384.md` +- HB#385 pre-probe name() check: `src/commands/org/probe-access.ts` +- HB#386 sweep report: `docs/audits/corpus-identity-sweep-hb386.md` +- HB#387 brain lesson: `pop.brain.shared` — this HB diff --git a/src/commands/org/audit-vetoken.ts b/src/commands/org/audit-vetoken.ts index 3eb47e9..74c49d8 100644 --- a/src/commands/org/audit-vetoken.ts +++ b/src/commands/org/audit-vetoken.ts @@ -75,6 +75,8 @@ interface AuditVetokenArgs { escrow: string; holders?: string; enumerate?: boolean; + 'enumerate-transfers'?: boolean; + underlying?: string; 'from-block'?: number; 'to-block'?: number; chunk?: number; @@ -130,6 +132,86 @@ async function enumerateDepositors( }; } +/** + * HB#456 task #389: enumerate candidate holders via the underlying ERC20's + * Transfer events filtered to (to == locker address). + * + * This path is CONTRACT-AGNOSTIC. The Deposit-event enumeration in + * enumerateDepositors() depends on the locker contract emitting a Deposit + * event with an indexed `provider` topic — the veCRV pattern. That works for + * Curve + Balancer + Frax because they're all veCRV-family forks, BUT it + * fails for: + * - CvxLockerV2 (Convex vlCVX) which emits `Staked` events, not Deposit + * - Dormant-holder protocols where the top holders deposited years ago + * and don't show up in a recent Deposit-event window + * + * The Transfer-events fallback fixes both cases: every ERC20 token emits + * standard Transfer(from, to, amount) events, regardless of the locker's + * own event signatures, and historical transfers into the locker include + * every lock in history (within the block window scanned). + * + * We filter by topic[2] == padded locker address, collecting topic[1] + * (the `from` address) as a candidate historical depositor. + * + * Cost note: underlying tokens like CRV, BAL, FXS emit MANY more Transfer + * events than the locker's own Deposit events (every ordinary transfer + * between users + swap + LP action). So this path is more RPC-expensive + * per block than the Deposit-event path, and operators should use narrower + * windows when invoking it. + */ +async function enumerateHoldersViaUnderlyingTransfers( + underlyingAddr: string, + escrowAddr: string, + provider: ethers.providers.Provider, + fromBlock: number, + toBlock: number, + chunk: number, +): Promise<{ holders: string[]; windowFrom: number; windowTo: number; chunksScanned: number }> { + const erc20Iface = new ethers.utils.Interface([ + 'event Transfer(address indexed from, address indexed to, uint256 value)', + ]); + const transferTopic = erc20Iface.getEventTopic('Transfer'); + const paddedEscrowTopic = ethers.utils.hexZeroPad(escrowAddr.toLowerCase(), 32); + + const seen = new Set(); + let chunksScanned = 0; + + for (let start = fromBlock; start <= toBlock; start += chunk) { + const end = Math.min(start + chunk - 1, toBlock); + try { + const logs = await provider.getLogs({ + address: underlyingAddr, + topics: [transferTopic, null, paddedEscrowTopic], + fromBlock: start, + toBlock: end, + }); + chunksScanned++; + for (const log of logs) { + // topic[1] is the `from` address padded to bytes32. Slice the last + // 20 bytes and hexlify. + if (log.topics.length >= 3) { + const fromTopicHex = log.topics[1]; + // Last 40 hex chars (20 bytes) = address + const fromAddr = '0x' + fromTopicHex.slice(-40); + if (ethers.utils.isAddress(fromAddr)) { + seen.add(fromAddr.toLowerCase()); + } + } + } + } catch (err: any) { + // Same best-effort skip policy as the Deposit-event path + void err; + } + } + + return { + holders: Array.from(seen), + windowFrom: fromBlock, + windowTo: toBlock, + chunksScanned, + }; +} + interface HolderRow { address: string; veBalance: string; @@ -162,6 +244,22 @@ export const auditVetokenHandler = { 'to the last 50,000 blocks (~7 days on Ethereum). Override with ' + '--from-block / --to-block / --chunk.', }) + .option('enumerate-transfers', { + type: 'boolean', + default: false, + describe: + 'Task #389 (HB#456): contract-agnostic holder discovery via the ' + + 'underlying ERC20\'s Transfer(from, to) events filtered to (to == ' + + 'escrow). Catches dormant lockers and works for non-veCRV-family ' + + 'contracts (CvxLockerV2, Convex, etc.). More RPC-expensive per ' + + 'block than --enumerate, so use narrower --from-block windows.', + }) + .option('underlying', { + type: 'string', + describe: + 'Override the underlying ERC20 token address for --enumerate-transfers. ' + + 'If omitted, reads VotingEscrow.token() to get it automatically.', + }) .option('from-block', { type: 'number', describe: @@ -218,10 +316,11 @@ export const auditVetokenHandler = { } } - if (!argv.enumerate && explicitHolders.length === 0) { + const anyEnumerate = argv.enumerate || argv['enumerate-transfers']; + if (!anyEnumerate && explicitHolders.length === 0) { spin.stop(); output.error( - 'Provide --holders OR pass --enumerate to auto-discover via Deposit events', + 'Provide --holders OR pass --enumerate (Deposit events) OR --enumerate-transfers (underlying ERC20 Transfer events)', ); process.exit(1); return; @@ -233,9 +332,31 @@ export const auditVetokenHandler = { const ve = new ethers.Contract(escrow, VE_VIEW_ABI, provider); - // HB#448 task #386: enumerate candidate holders via Deposit-event scan + // Read metadata UP FRONT so --enumerate-transfers can use veTokenAddr + // as the default underlying token address. Older MVP read this later; + // hoisted to support the Transfer-events path at HB#456 task #389. + let veName = 'unknown'; + let veSymbol = 'unknown'; + let veTokenAddr = '0x0'; + try { + [veName, veSymbol, veTokenAddr] = await Promise.all([ + ve.name(), + ve.symbol(), + ve.token(), + ]); + } catch { + // Vyper public getters sometimes mis-ABI; don't fail the whole audit + // if metadata reads fail — just label unknown and continue. + } + + // HB#448 task #386 + HB#456 task #389: enumerate candidate holders // BEFORE the balanceOf loop so the top-N ranking can include them. - let enumerationMeta: { windowFrom: number; windowTo: number; chunksScanned: number; enumerated: number } | null = null; + // Two modes: + // - --enumerate scan VotingEscrow's own Deposit events + // - --enumerate-transfers scan underlying ERC20 Transfer events + // filtered to (to == escrow). Contract- + // agnostic, catches dormant lockers. + let enumerationMeta: { windowFrom: number; windowTo: number; chunksScanned: number; enumerated: number; method: string } | null = null; let discoveredHolders: string[] = []; if (argv.enumerate) { const latestBlock = await provider.getBlockNumber(); @@ -251,15 +372,67 @@ export const auditVetokenHandler = { spin.start(); const enumResult = await enumerateDepositors(ve, provider, fromBlock, toBlock, chunk); - discoveredHolders = enumResult.holders; + discoveredHolders = [...discoveredHolders, ...enumResult.holders]; enumerationMeta = { windowFrom: enumResult.windowFrom, windowTo: enumResult.windowTo, chunksScanned: enumResult.chunksScanned, - enumerated: discoveredHolders.length, + enumerated: enumResult.holders.length, + method: 'deposit-events', }; } + if (argv['enumerate-transfers']) { + const latestBlock = await provider.getBlockNumber(); + const toBlock = argv['to-block'] ?? latestBlock; + const fromBlock = + argv['from-block'] ?? Math.max(0, latestBlock - DEFAULT_ENUMERATE_LOOKBACK_BLOCKS); + const chunk = argv.chunk ?? DEFAULT_ENUMERATE_CHUNK_BLOCKS; + + // Resolve underlying token address: explicit --underlying flag wins, + // else fall back to VotingEscrow.token() which we already read above. + let underlyingAddr = argv.underlying?.trim().toLowerCase() || veTokenAddr; + if (!underlyingAddr || underlyingAddr === '0x0' || underlyingAddr === '0x0000000000000000000000000000000000000000') { + spin.stop(); + output.error( + '--enumerate-transfers requires --underlying when the escrow\'s token() getter returns 0x0. Pass the CVX/CRV/BAL/FXS address explicitly.', + ); + process.exit(1); + return; + } + + spin.stop(); + output.info( + ` Enumerating underlying Transfer events to ${escrow} ${fromBlock}..${toBlock} (${chunk}-block chunks, underlying=${underlyingAddr})...`, + ); + spin.start(); + + const enumResult = await enumerateHoldersViaUnderlyingTransfers( + underlyingAddr, + escrow, + provider, + fromBlock, + toBlock, + chunk, + ); + discoveredHolders = [...discoveredHolders, ...enumResult.holders]; + if (!enumerationMeta) { + enumerationMeta = { + windowFrom: enumResult.windowFrom, + windowTo: enumResult.windowTo, + chunksScanned: enumResult.chunksScanned, + enumerated: enumResult.holders.length, + method: 'underlying-transfers', + }; + } else { + // Both --enumerate and --enumerate-transfers were passed. Record + // as union. + enumerationMeta.enumerated += enumResult.holders.length; + enumerationMeta.chunksScanned += enumResult.chunksScanned; + enumerationMeta.method = 'union(deposit-events,underlying-transfers)'; + } + } + // Union the explicit list and the discovered list, deduping case- // insensitively. const holderAddrs = Array.from( @@ -275,21 +448,6 @@ export const auditVetokenHandler = { return; } - // Read metadata first so we fail fast on wrong-shape contracts. - let veName = 'unknown'; - let veSymbol = 'unknown'; - let veTokenAddr = '0x0'; - try { - [veName, veSymbol, veTokenAddr] = await Promise.all([ - ve.name(), - ve.symbol(), - ve.token(), - ]); - } catch { - // Vyper public getters sometimes mis-ABI; don't fail the whole audit - // if metadata reads fail — just label unknown and continue. - } - const totalSupplyBn = await ve.totalSupply(); const totalSupplyNum = Number(ethers.utils.formatUnits(totalSupplyBn, 18)); diff --git a/src/commands/org/probe-access.ts b/src/commands/org/probe-access.ts index 37890b2..67a569b 100644 --- a/src/commands/org/probe-access.ts +++ b/src/commands/org/probe-access.ts @@ -39,6 +39,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { resolveNetworkConfig } from '../../config/networks'; import * as output from '../../lib/output'; +import { expandAliases } from '../../lib/label-aliases'; interface ProbeAccessArgs { address: string; @@ -72,10 +73,28 @@ interface ProbeAccessArgs { /** * Pure helper for the substring name-match logic. Exported for unit testing * without needing to mock an RPC provider. + * + * Match semantics (HB#387+, task #395): + * 1. Case-insensitive substring of the raw expected string against the + * on-chain `name()` return value. This is the original HB#385 behavior. + * 2. If step 1 fails, consult LABEL_ALIASES: split the expected label into + * words, look up aliases for each word, and accept a match if any alias + * appears as a case-insensitive substring of the actual name. This + * closes the HB#386 sweep false-positive class where "curve" doesn't + * literally appear in "Vote-escrowed CRV". + * + * An empty expected string matches everything (String.includes semantic). + * A null actual never matches. */ export function matchContractName(actual: string | null, expected: string): boolean { if (actual === null) return false; - return actual.toLowerCase().includes(expected.toLowerCase()); + const actualLower = actual.toLowerCase(); + if (actualLower.includes(expected.toLowerCase())) return true; + // Alias fallback: any aliased token expanded from the expected label. + for (const candidate of expandAliases(expected)) { + if (candidate && actualLower.includes(candidate)) return true; + } + return false; } export async function fetchContractNameAndCheck( diff --git a/src/commands/vote/announce.ts b/src/commands/vote/announce.ts index 299ad30..e84bd6e 100644 --- a/src/commands/vote/announce.ts +++ b/src/commands/vote/announce.ts @@ -85,17 +85,16 @@ export const announceHandler = { } spin.text = 'Announcing winner...'; - // minCallGas 2M floor: see announce-all.ts for the rationale. Without - // this, proposals with execution batches (Curve+bridge style) silently - // fail at deep subcalls due to gas forwarding starvation under the - // default 300K UserOp callGasLimit. + // The 2M callGasLimit floor for execution batches (Curve+bridge style, + // which silently fail at deep subcalls under the default 300K UserOp + // callGasLimit) is applied inside src/lib/sponsored.ts — no per-call + // opt-in needed here. const result = await executeTx( contract, 'announceWinner', [argv.proposal], { dryRun: argv.dryRun, - minCallGas: 2_000_000n, } ); diff --git a/src/commands/vote/helpers.ts b/src/commands/vote/helpers.ts index ba68261..baec283 100644 --- a/src/commands/vote/helpers.ts +++ b/src/commands/vote/helpers.ts @@ -18,3 +18,32 @@ export async function resolveVotingContracts(orgIdOrName: string, chainId?: numb ddVotingAddress: modules.ddVotingAddress, }; } + +/** + * Resolve a user-supplied --proposal argument to a numeric proposal ID. + * + * Currently accepts only numeric IDs — the --proposal flag advertises + * "Proposal ID (number) or fuzzy title query", but the fuzzy branch is + * unimplemented. Non-numeric input throws with a clear instruction to + * pass the numeric ID until the fuzzy path ships. See task #393 history + * for why this helper was minimized rather than built out in-flight. + * + * The extra args (contractAddr, chainId, opts) are accepted so callers + * in vote/cast.ts can keep their current signature; they're reserved + * for when the fuzzy branch lands. + */ +export async function resolveProposalId( + input: string, + _contractAddr: string, + _chainId?: number, + _opts?: { preferActive?: boolean } +): Promise { + const trimmed = input.trim(); + const n = Number(trimmed); + if (Number.isFinite(n) && Number.isInteger(n) && n >= 0 && String(n) === trimmed) { + return n; + } + throw new Error( + `Fuzzy proposal title resolution is not implemented yet (got '${input}'). Pass the numeric proposal ID.` + ); +} diff --git a/src/config/tokens.ts b/src/config/tokens.ts index 937646f..1cbf8d7 100644 --- a/src/config/tokens.ts +++ b/src/config/tokens.ts @@ -30,6 +30,36 @@ export function getTokenByAddress(address: string): TokenInfo | null { return KNOWN_TOKENS[address.toLowerCase()] || null; } +/** + * Reverse lookup by symbol. Case-insensitive. Returns the first matching + * token across all chains — if the same symbol exists on multiple chains + * (e.g. USDC on Gnosis/Arbitrum/Sepolia), the caller should narrow by + * chain using getTokenByAddress after resolving the chain-specific address + * via another channel. + */ +export function getTokenBySymbol(symbol: string): TokenInfo | null { + const want = symbol.toUpperCase(); + for (const t of Object.values(KNOWN_TOKENS)) { + if (t.symbol.toUpperCase() === want) return t; + } + return null; +} + +/** + * Resolve a user-supplied token identifier to a checksummed address. + * If input starts with 0x, returns it unchanged (caller's responsibility + * to pre-validate). Otherwise treats it as a symbol and resolves via + * getTokenBySymbol, throwing if unknown. + */ +export function resolveTokenAddress(input: string): string { + if (input.startsWith('0x')) return input; + const token = getTokenBySymbol(input); + if (!token) { + throw new Error(`Unknown token symbol: ${input}. Add it to config/tokens.ts or pass a 0x address.`); + } + return token.address; +} + export function getTokenDecimals(address: string): number { const token = getTokenByAddress(address); if (!token) { diff --git a/src/lib/audit-db.ts b/src/lib/audit-db.ts index fa67681..9a916f2 100644 --- a/src/lib/audit-db.ts +++ b/src/lib/audit-db.ts @@ -115,6 +115,7 @@ export const AUDIT_DB: Record = { 'Tokemak': { grade: 'D', score: 50, gini: 0.956, category: 'DeFi', voters: 181, platform: 'Snapshot' }, 'ShapeShift': { grade: 'C', score: 70, gini: 0.778, category: 'DeFi', voters: 51, platform: 'Snapshot' }, 'Starknet': { grade: 'B', score: 78, gini: 0.850, category: 'L2', voters: 160, platform: 'Snapshot' }, + 'Optimism Citizens House': { grade: 'B', score: 82, gini: 0.365, category: 'Delegated Council', voters: 60, platform: 'Snapshot' }, }; /** diff --git a/src/lib/label-aliases.ts b/src/lib/label-aliases.ts new file mode 100644 index 0000000..d877ddd --- /dev/null +++ b/src/lib/label-aliases.ts @@ -0,0 +1,48 @@ +/** + * Shared label alias map for contract-name matching. + * + * Some contracts identify on-chain with a token symbol (GTC for Gitcoin) + * or a descriptive technical term (Vote-escrowed CRV for Curve's veCRV) + * that doesn't literally contain the project's name. This map says + * "if the expected label is X, consider these on-chain strings to be + * an acceptable match." + * + * Keys are lower-case filename / project labels; values are lower-case + * tokens expected to appear in the on-chain `name()` return value. + * + * Populated from HB#386's corpus identity sweep first-run false positives + * (task #391) and used by: + * - src/commands/org/probe-access.ts → matchContractName (task #395, HB) + * - agent/scripts/audit-corpus-identity-sweep.mjs (filename-fuzzy mode) + * + * Additions should be justified with a short comment explaining why the + * alias is correct (e.g. "Curve's token is CRV; the VotingEscrow contract + * identifies as 'Vote-escrowed CRV'"). + */ +export const LABEL_ALIASES: Record = { + // Gitcoin's token is GTC; Gitcoin's GovernorAlpha contract identifies + // as "GTC Governor Alpha" on-chain. HB#386 sweep surfaced this. + gitcoin: ['gtc'], + // Curve's VotingEscrow contract identifies as "Vote-escrowed CRV" on-chain. + // The label "curve votingescrow" → actual "Vote-escrowed CRV" is correct + // but requires the CRV alias (Curve's token). HB#386 sweep. + curve: ['crv', 'vote-escrowed'], +}; + +/** + * Return the full list of strings considered an acceptable match for the + * given label: the label itself, plus any aliases registered under any of + * its lower-cased whitespace-separated words. Case-insensitive. + * + * Example: + * expandAliases("Curve VotingEscrow") → ["curve votingescrow", "crv", "vote-escrowed"] + */ +export function expandAliases(label: string): string[] { + const lowered = label.toLowerCase(); + const out: string[] = [lowered]; + for (const word of lowered.split(/\s+/).filter(Boolean)) { + const aliases = LABEL_ALIASES[word]; + if (aliases) out.push(...aliases); + } + return out; +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 5095b93..553e897 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -11,8 +11,8 @@ export function requireArg(value: T | undefined, name: string): T { return value; } -export function requireAddress(address: string, name: string): string { - if (!ethers.utils.isAddress(address)) { +export function requireAddress(address: string | undefined, name: string): string { + if (!address || !ethers.utils.isAddress(address)) { throw new Error(`Invalid address for --${name}: ${address}`); } return ethers.utils.getAddress(address); diff --git a/test/commands/probe-access-identity.test.ts b/test/commands/probe-access-identity.test.ts index 8f7545c..dbbfe0c 100644 --- a/test/commands/probe-access-identity.test.ts +++ b/test/commands/probe-access-identity.test.ts @@ -119,4 +119,42 @@ describe('matchContractName — HB#385 task #390', () => { expect(matchContractName(row.actual, row.expected)).toBe(true); } }); + + // Task #395 (HB#387 follow-up): alias map expansion. HB#386's sweep hit + // three false positives — "curve votingescrow" vs "Vote-escrowed CRV", + // "gitcoin alpha" vs "GTC Governor Alpha", and the Gitcoin/Uniswap + // mislabel. The sweep fixed this with a LABEL_ALIASES map (gitcoin→gtc, + // curve→{crv, vote-escrowed}) but probe-access's --expected-name flag + // still did literal substring. These tests lock in the alias-aware + // behavior so an operator running `--expected-name Curve` against Curve's + // VotingEscrow no longer gets a false NAME CHECK MISMATCH warning. + describe('LABEL_ALIASES expansion (task #395)', () => { + it('matches Curve → Vote-escrowed CRV via the curve alias map', () => { + expect(matchContractName('Vote-escrowed CRV', 'Curve')).toBe(true); + expect(matchContractName('Vote-escrowed CRV', 'curve')).toBe(true); + expect(matchContractName('Vote-escrowed CRV', 'Curve VotingEscrow')).toBe(true); + }); + + it('matches Gitcoin → GTC Governor Alpha via the gitcoin alias map', () => { + expect(matchContractName('GTC Governor Alpha', 'Gitcoin')).toBe(true); + expect(matchContractName('GTC Governor Alpha', 'gitcoin')).toBe(true); + expect(matchContractName('GTC Governor Alpha', 'Gitcoin Alpha')).toBe(true); + }); + + it('does NOT introduce false positives for unrelated labels', () => { + // Alias expansion must not make arbitrary strings match. + expect(matchContractName('Uniswap Governor Bravo', 'Gitcoin')).toBe(false); + expect(matchContractName('Compound Governor Bravo', 'Curve')).toBe(false); + expect(matchContractName('MakerDAO Chief', 'Gitcoin')).toBe(false); + }); + + it('preserves HB#385 literal-substring behavior when it already works', () => { + // When the expected label is already in the actual name, alias + // expansion is irrelevant — literal match still wins. + expect(matchContractName('Compound Governor Bravo', 'Compound')).toBe(true); + expect(matchContractName('Uniswap Governor Bravo', 'Uniswap')).toBe(true); + // And the HB#384 mislabel case still fails correctly. + expect(matchContractName('Uniswap Governor Bravo', 'Gitcoin')).toBe(false); + }); + }); });