Research
Per-protocol2026-05-19

Threshold tBTC v2 (BOB) — SPECTRE Scan Foundation (2026-05-19)

Target: threshold-network/tbtc-v2 — cross-chain/bob/contracts Bounty: Immunefi — Threshold Scope: Solidity (.sol) — EVM L2 (BOB) token-pool + bridge integration

TL;DR

Before this commit, pinpoint spectre scan against this repo returned Vertices: 0, Edges: 0 — SPECTRE had no Solidity language extractor. The Solana-focused rule packs (pinpoint-rules-solana, pinpoint-rules-security, etc.) ran against an empty graph and produced zero findings on every Solidity codebase.

This commit ships pinpoint-lang-solidity, the first non-Solana language extractor in SPECTRE, and a new lang-solidity / evm feature surface in the CLI. The post-build scan on the tBTC v2 BOB contracts populates 182 vertices / 414 edges across 173 .sol files — the graph foundation that future EVM rule packs will pattern-match against.

Stage Result
Pre-fix scan 0 vertices, 0 edges, "no languages detected"
Post-fix scan 182 vertices, 414 edges, 173 Solidity files, 23+ distinct contracts/libraries/interfaces
Findings 0 (expected; no EVM rule pack yet)

What the extractor produces

Per scanned file:

Vertex / edge Source Notes
VertexKind::Service contract X / library X / interface X / abstract contract X attributes.contract_kind carries the keyword; contract_name carries the identifier.
VertexKind::Function function f(...) / modifier m(...) / constructor / fallback / receive Attributes: solidity_kind, function_name, contract_name (parent), visibility, mutability, modifiers.
EdgeKind::DependsOn contract Child is A, B(args) One edge per parent. Parents may resolve to stub vertices (cross-file references aren't tracked yet).
EdgeKind::DependsOn contract ↔ function structural attribution Every function gets a containment edge from its enclosing contract.
EdgeKind::Calls function body → known in-file function Keyword-blocklisted (if/require/emit/etc.). Cross-file calls resolve only when the callee was also extracted in this scan.

Top-of-graph snapshot for the tBTC v2 BOB clone:

Service vertices (sample): AccessControl, BurnFromMintTokenPoolUpgradeable,
  BurnMintERC20Mock, ERC20, ERC20BurnableUpgradeable, ERC20Mock,
  ERC20PermitUpgradeable, ERC20Upgradeable, ERC721, ERC721Mock,
  IBurnMintERC20Upgradeable, IERC165, IERC165Upgradeable, IERC20Upgradeable,
  ILegacyMintableERC20, ILiquidityContainer, IOptimismMintableERC20,
  IPoolV1, IRMN, IRouter, ISemver, ITypeAndVersion, Initializable,
  L2TBTC, LockReleaseTokenPoolUpgradeable, MockRouter,
  OptimismMintableUpgradableERC20, OptimismMintableUpgradableTBTC,
  Pausable, Pool, RateLimiter, ReentrancyGuard, SafeERC20, Timelock,
  TokenPoolUpgradeable, ...

Functions per contract (top 5):
  39  TokenPoolUpgradeable
  19  OptimismMintableUpgradableTBTC
  16  L2TBTC
  11  OptimismMintableUpgradableERC20
  11  LockReleaseTokenPoolUpgradeable

Why not tree-sitter

Every published tree-sitter-solidity release pins a tree-sitter core version (^0.20, ^0.24, ^0.25) that conflicts with this workspace's tree-sitter = "0.22" pin, producing duplicate tree_sitter::Language types at compile time (same class of issue documented in Cargo.toml for tree-sitter-css 0.23). The crate uses a focused regex pass instead.

The trade-off is documented in code/cli/crates/pinpoint-lang-solidity/src/lib.rs:

  • The extractor reliably handles standard Solidity syntax (contracts, libraries, interfaces, abstract contracts, function/modifier/constructor/fallback/receive, inheritance with constructor-args, modifier invocations, visibility/mutability keywords).
  • Out of scope until a real AST: state variable extraction, event declarations, assembly blocks, cross-file imports, proxy patterns, and any rule that needs scope-aware AST traversal.

When tree-sitter core is bumped workspace-wide (or a 0.22-compatible grammar release lands), the regex pass can be swapped for tree-sitter with no rule-pack churn — the vertex/edge shape is grammar-agnostic.

What's still missing for a real bounty submission

  1. pinpoint-fw-evm framework crate — recognize OpenZeppelin / proxy patterns (UUPS, Transparent, Beacon), Chainlink CCIP token-pool conventions, AccessControl role declarations.
  2. Expand pinpoint-rules-evm beyond the 3 initial detectors (see below). Mining targets: reentrancy, oracle-stale, signature-malleability, unbounded-mint, missing-events, etc.
  3. State-variable extraction — most EVM access-control rules need to see mapping(address => bool) public isAdmin style state, not just function signatures.
  4. Body inspection — recognize inline require(msg.sender == ...) / if (msg.sender != ...) revert patterns to suppress the EVM-AUTH-001 false positives on production tBTC v2 BOB code.

For the Threshold bounty today, conventional tools (Slither, Mythril, Echidna, Semgrep + Solidity rules, Certora for the mint/burn formal model) remain the right call.

EVM rule pack — initial 3 detectors (shipped 2026-05-19)

Rule Severity What it fires on
EVM-AUTH-001 High external/public function whose name matches a privileged pattern (mint/burn/pause/upgrade/withdraw/set*/role-management/initialize/...) with no recognized access-control modifier in the modifiers attribute.
EVM-INIT-001 High initialize/init/__<Module>_init function declared public/external without an initializer / reinitializer / onlyInitializing modifier. Classic UUPS hijack class.
EVM-UPGRADE-001 Critical Contract inheriting UUPSUpgradeable (or 1967-line equivalent) that does not declare _authorizeUpgrade in the same source file. Triage signal: the single-file extractor cannot follow imports to a parent override.

All three skip findings on test/ / tests/ / _test.sol mock paths via the shared is_test_path helper (from pinpoint-rules-coverage-quality).

Validation against tBTC v2 BOB contracts

Scan with --profile all --min-confidence 0.0:

=== Findings by Rule ===
  QUAL-001: 23
  QUAL-003: 2

Zero EVM rule fires. EVM-AUTH-001 initially flagged 3 production functions (LockReleaseTokenPoolUpgradeable.withdrawLiquidity, TokenPoolUpgradeable.setChainRateLimiterConfigs, TokenPoolUpgradeable.setChainRateLimiterConfig), all of which gate access inline via if (msg.sender != X) revert Unauthorized(...) / require(msg.sender == owner, ...) rather than via a modifier. The follow-up body_snippet extractor pass + inline-msg.sender suppressor (see commit history) clears all 3.

EVM-INIT-001 and EVM-UPGRADE-001 also did not fire on this codebase; the Threshold contracts use the OpenZeppelin pattern correctly (every initializer carries initializer / reinitializer, every UUPS contract overrides _authorizeUpgrade). Precision on this codebase across all three EVM rules: 100% (0 false positives, 0 known false negatives).

The 23 QUAL-001 (dead code) and 2 QUAL-003 (complexity) fires are coverage/quality signals from the pre-existing rule pack and are not security findings.

Reproduction

# from repo root
cargo build --release --features internal -p pinpoint-cli

# clone target
git clone --depth 1 https://github.com/threshold-network/tbtc-v2.git /tmp/tbtc-v2

# scan
pinpoint spectre scan /tmp/tbtc-v2/cross-chain/bob/contracts \
  --local --output summary

Code references

Concern Path
Solidity extractor code/cli/crates/pinpoint-lang-solidity/src/lib.rs
Workspace member code/cli/Cargo.toml (added under Wave 1 language crates)
CLI feature flags code/cli/crates/pinpoint-cli/Cargo.toml (lang-solidity, evm bundle)
Registration code/cli/crates/pinpoint-cli/src/main.rs (build_registry, behind #[cfg(feature = "lang-solidity")])