Skip to content

Commit bb23d5d

Browse files
committed
fix(collateral): prevent collateral theft via pending reclaim + nodeToMiner clearing
Both finalizeReclaim and slashCollateral cleared nodeToMiner when balances hit zero, ignoring outstanding pending reclaims. A new depositor could then claim the node, and a stale finalize would drain their funds to the old miner. Guard nodeToMiner clearing on pending counters being zero as well. Rewrite alpha-only deny test to exercise the actual code path via vm.store.
1 parent 6d37c57 commit bb23d5d

File tree

4 files changed

+313
-61
lines changed

4 files changed

+313
-61
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ scripts/localnet/configs/miner.toml
113113
terraform.tfstate
114114
*.tfstate
115115
CLAUDE.md
116+
!crates/collateral-contract/CLAUDE.md
116117

117118
scripts/web/basilica-linux-amd64
118119
scripts/web/basilica-macos-arm64
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Collateral Contract
2+
3+
## Bittensor EVM Compatibility Layer
4+
5+
Bittensor runs on **Subtensor**, a Substrate-based blockchain. The EVM layer is built using Frontier (Substrate's EVM compatibility framework) and runs **on top of** Subtensor as an application layer. All execution happens on the Bittensor blockchain, not Ethereum.
6+
7+
### Two Address Worlds
8+
9+
| Property | Substrate Side | EVM Side |
10+
|---|---|---|
11+
| **Format** | SS58 (starts with `5`) | H160 (starts with `0x`) |
12+
| **Size** | 32-byte public key (AccountId32) | 20-byte address |
13+
| **Key type** | Ed25519/Sr25519 | Secp256k1 |
14+
| **Wallet tools** | btcli, Bittensor SDK, Polkadot.js | MetaMask, Hardhat, ethers.js |
15+
| **Can do** | Subtensor extrinsics (staking, registration, transfers) | EVM smart contract calls |
16+
| **Cannot do** | Sign EVM smart contracts | Sign Substrate extrinsics |
17+
18+
### HashedAddressMapping (H160 <-> SS58)
19+
20+
Bittensor uses Frontier's `HashedAddressMapping` for deterministic, one-way address derivation:
21+
22+
**H160 -> SS58 (EVM address -> Substrate mirror):**
23+
```rust
24+
fn into_account_id(address: H160) -> AccountId32 {
25+
let mut data = [0u8; 24];
26+
data[0..4].copy_from_slice(b"evm:");
27+
data[4..24].copy_from_slice(&address[..]);
28+
let hash = blake2_256(&data);
29+
AccountId32::from(hash)
30+
}
31+
```
32+
33+
**SS58 -> H160 (Substrate address -> EVM mirror):**
34+
Take the first 20 bytes of the 32-byte Substrate public key.
35+
36+
**Critical:** Neither direction yields a usable private key. The derived "mirror" addresses are accounting-only.
37+
38+
### Four Addresses, Two Keypairs
39+
40+
```
41+
Keypair A (Ed25519/Sr25519 - Bittensor native):
42+
+-- #1: Native SS58 address (you control, signs extrinsics)
43+
+-- #4: EVM mirror (first 20 bytes of pubkey, NO private key)
44+
45+
Keypair B (Secp256k1 - Ethereum native):
46+
+-- #3: Native H160 address (you control, signs EVM txns)
47+
+-- #2: SS58 mirror (blake2("evm:" ++ h160_bytes), NO private key)
48+
```
49+
50+
An SS58 wallet CANNOT sign EVM transactions. An EVM wallet CANNOT sign Substrate extrinsics. They are separate identity domains on the same chain.
51+
52+
### Balance Flow Between Layers
53+
54+
- Sending TAO from Substrate wallet (#1) to EVM mirror SS58 address (#2) makes it appear in the EVM wallet (#3) in MetaMask.
55+
- Going EVM -> Substrate uses the `BalanceTransfer` precompile or `evm.withdraw()` extrinsic.
56+
- Same TAO token, different account format, no wrapping involved.
57+
58+
### Precompiles (EVM -> Substrate Bridge)
59+
60+
| Precompile | Address | Purpose |
61+
|---|---|---|
62+
| Ed25519Verify | `0x...0402` | Verify Ed25519 signatures (prove SS58 key ownership from EVM) |
63+
| StakingV2 | `0x...0805` | Add/remove stake, move stake between hotkeys |
64+
| BalanceTransfer | custom | Transfer TAO between accounts |
65+
| SubnetPrecompile | custom | Subnet operations |
66+
67+
**Staking caveat:** When a smart contract calls the staking precompile, the **contract's address** is the coldkey (not the original caller).
68+
69+
### Network Config
70+
71+
| Network | RPC URL | Chain ID |
72+
|---|---|---|
73+
| Mainnet | `https://lite.chain.opentensor.ai` | 964 |
74+
| Testnet | `https://test.finney.opentensor.ai` | 945 |
75+
| Localnet | `http://localhost:9944` | 42 |
76+
77+
### Unit Conversion
78+
79+
- EVM side: 1 TAO = 1e18 (like ETH wei)
80+
- Substrate staking (RAO): 1 TAO = 1e9 RAO
81+
- When calling staking precompile from EVM with `msg.value`: `amount_rao = msg.value / 1e9`
82+
83+
## This Crate
84+
85+
### Architecture
86+
87+
- **Solidity contracts** (`src/Collateral.sol`, `src/CollateralUpgradeableV2.sol`): Upgradeable ERC1967 proxy pattern via OpenZeppelin
88+
- **Rust library** (`src/lib.rs`): Alloy-based contract bindings generated via `sol!` macro from ABI JSON
89+
- **CLI** (`src/main.rs`): `collateral-cli` for all contract operations (deposit, reclaim, slash, query, events)
90+
91+
### Contract Identity Model
92+
93+
The contract uses **H160 addresses** for miners and **bytes32** for hotkeys/coldkeys (which are Substrate public keys passed as raw 32-byte values). The `nodeId` is `bytes16` (UUID). The trustee is an H160 address.
94+
95+
Both miners and validators need TAO in their H160 wallets for gas (~0.01 TAO minimum).
96+
97+
### Key Contract State
98+
99+
- `nodeToMiner[hotkey][nodeId] -> address`: Maps (hotkey, nodeId) to the miner's H160 address
100+
- `collaterals[hotkey][nodeId] -> uint256`: TAO collateral amount (in wei, 1e18)
101+
- `alphaCollaterals[hotkey][nodeId] -> uint256`: Alpha token collateral
102+
- `reclaims[reclaimId] -> Reclaim`: Pending reclaim requests with deny timeout
103+
- `CONTRACT_COLDKEY`: bytes32 Substrate coldkey associated with the contract (for staking precompile calls)
104+
105+
### Contract Operations
106+
107+
- **Deposit**: Miner sends TAO + optional alpha to lock as collateral for a (hotkey, nodeId) pair
108+
- **Reclaim**: Miner requests collateral back, starts a timeout window for trustee to deny
109+
- **Finalize Reclaim**: After timeout passes without denial, miner withdraws
110+
- **Deny Reclaim**: Trustee rejects a reclaim within the timeout window
111+
- **Slash**: Trustee slashes a miner's collateral for misconduct
112+
- **Burn Register**: Calls the `0x0804` precompile to burn-register the contract on-chain
113+
114+
### Deployment
115+
116+
- Mainnet deployment is **whitelisted only** (request via Bittensor Discord `#evm-bittensor`)
117+
- Uses `forge script script/DeployUpgradeable.s.sol` with ERC1967 proxy
118+
- After deployment, update ABI via `update_abi.py`
119+
120+
### Testing
121+
122+
```bash
123+
forge test # Solidity contract tests
124+
cargo test --lib # Rust library unit tests
125+
cargo test # All tests
126+
```

crates/collateral-contract/src/CollateralUpgradeable.sol

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,9 @@ contract CollateralUpgradeable is
374374

375375
if (
376376
collaterals[hotkey][nodeId] == 0 &&
377-
alphaCollaterals[hotkey][nodeId] == 0
377+
alphaCollaterals[hotkey][nodeId] == 0 &&
378+
collateralUnderPendingReclaims[hotkey][nodeId] == 0 &&
379+
alphaCollateralUnderPendingReclaims[hotkey][nodeId] == 0
378380
) {
379381
nodeToMiner[hotkey][nodeId] = address(0);
380382
}
@@ -475,7 +477,12 @@ contract CollateralUpgradeable is
475477
if (!success) {
476478
revert TransferFailed();
477479
}
478-
if (amount == slashAmount && alphaAmount == slashAlphaAmount) {
480+
if (
481+
amount == slashAmount &&
482+
alphaAmount == slashAlphaAmount &&
483+
collateralUnderPendingReclaims[hotkey][nodeId] == 0 &&
484+
alphaCollateralUnderPendingReclaims[hotkey][nodeId] == 0
485+
) {
479486
nodeToMiner[hotkey][nodeId] = address(0);
480487
}
481488
emit Slashed(

0 commit comments

Comments
 (0)