-
Notifications
You must be signed in to change notification settings - Fork 288
Description
Problem Statement
EVM smart contracts on Bittensor interact with the chain through precompiles — fixed-address contracts that expose Substrate storage and extrinsics to Solidity callers. Today, these precompiles have no formal versioning or deprecation mechanism, creating several fragility vectors.
1. Raw Storage Access is Brittle
StorageQueryPrecompile (address 0x807) allows contracts to read arbitrary pallet storage by passing raw SCALE-encoded keys. If a contract hardcodes a storage key like twox_128("SubtensorModule") ++ twox_128("Weights"), any of the following changes silently break it:
- Renaming a pallet or storage item
- Changing the storage hasher (e.g.,
Blake2_128ConcattoTwox64Concat) - Restructuring a
StorageMapinto aStorageDoubleMap - Changing the value encoding (e.g., adding a field to a stored struct)
The contract reads empty or garbage data with no error, no warning - just silently wrong behavior. This is especially dangerous for contracts that cannot be redeployed (e.g., due to legal or governance constraints).
2. Typed Precompile Views Lack an Evolution Strategy
Typed precompiles (e.g., MetagraphPrecompile, AlphaPrecompile) expose clean Solidity interfaces, but there's no strategy for evolving them:
- If
getStake(uint16, uint16) -> uint64needs to becomegetStake(uint16, uint16) -> StakeInfo, the signature change breaks all existing callers. - The current workaround (creating entirely new precompile contracts like
StakingV2at a new address) forces contracts to update hardcoded addresses — the exact problem we're trying to avoid. - There's no way to signal that a function is deprecated and point callers to a replacement.
3. Incomplete Coverage
Not every storage item and extrinsic has a precompile equivalent. This pushes users toward StorageQueryPrecompile for missing items, compounding problem 1. Ideally, the precompile surface should have 1:1 coverage with the Substrate API — every storage item should have a view function, and every extrinsic should have a callable equivalent.
4. No Compile-Time Safety Net
With 1:1 typed views, changing a storage item in a pallet means the precompile won't compile anymore — the compiler forces you to update the precompile side. With StorageQueryPrecompile, nothing breaks at compile time; the breakage is silent and only visible at runtime.
Design Goals
- Contracts at rest don't break. A deployed contract calling
getStake(uint16, uint16)at address0x802should keep working indefinitely, even if the underlying storage layout changes. - Soft deprecation is the default. Old functions keep working. Hard deprecation (returning an error) is only used when the underlying logic is genuinely gone and cannot be replaced.
- Deprecation is discoverable. Deprecated functions should be marked in the Solidity interfaces and queryable by tooling, so developers know before they deploy.
- Raw storage access is phased out.
StorageQueryPrecompileshould be deprecated in favor of typed views that abstract storage layout. - Full Substrate parity. Every storage item and extrinsic should have a precompile equivalent.
Proposed Design
A. Per-Function Versioning (Not Per-Contract)
Instead of creating entirely new precompile contracts for breaking changes (StakingV1 -> StakingV2 -> StakingV3...), version individual functions within the same contract address:
// IMetagraph — lives at 0x802 forever
interface IMetagraph {
// V1 — returns flat u64
function getStake(uint16 netuid, uint16 uid) external view returns (uint64);
// V2 — returns rich struct, added when delegation feature lands
function getStakeV2(uint16 netuid, uint16 uid) external view returns (StakeInfo memory);
}Why per-function over per-contract:
- Solidity dispatches by the 4-byte function selector (
keccak256of the signature), not by contract address. AddinggetStakeV2doesn't affectgetStake— both selectors route independently. - Avoids proliferating separate precompile contracts and INDEX slots for every version (no
StakingV1at0x801,StakingV2at0x805,StakingV3at0x80F...). - One precompile per domain (metagraph, staking, etc.) is simpler to maintain, document, and audit.
Note: both approaches preserve backward compatibility equally — old precompiles at old addresses would keep working either way. Per-function versioning is about organizational simplicity, not preventing breakage.
Naming convention: functionName (V1, implicit), functionNameV2, functionNameV3, etc.
B. Deprecation Lifecycle
Each precompile function goes through defined lifecycle states:
| State | Behavior | How it's signaled |
|---|---|---|
| Active | Normal execution | — |
| Soft-deprecated | Still executes identically, zero runtime overhead | Marked @deprecated in Solidity interface. Queryable via a deprecation registry view. |
| Hard-deprecated | Returns PrecompileFailure::Error |
Only when the underlying logic is genuinely removed and cannot be replaced. |
| Disabled | Returns precompile-disabled error | Already supported via PrecompileEnable in pallet-admin-utils. |
Soft deprecation should be the overwhelming default. It covers:
- Adding a field to a returned struct or tuple (old function returns the original subset, new function returns the full version)
- Changing how a value is computed internally (old function adapts, signature stays the same)
- Reorganizing storage layout (precompile implementation changes, Solidity interface stays identical)
Hard deprecation is a last resort. It's only appropriate when:
- A storage item is completely removed with no replacement
- The semantics changed so fundamentally that the old return type cannot represent the new reality
- Keeping the old function working would require maintaining dead storage or fake data
C. Deprecation Registry
Instead of emitting EVM events on every deprecated call (which adds gas cost and is only visible off-chain in transaction receipts — not to the calling contract), a registry precompile exposes deprecation metadata statically:
interface IPrecompileRegistry {
struct DeprecationInfo {
bool isDeprecated;
address newPrecompile; // replacement precompile address (often the same)
bytes4 newSelector; // replacement function selector
string message; // human-readable migration guidance
}
/// Check if a function at a given precompile is deprecated
function getDeprecationInfo(
address precompile,
bytes4 selector
) external view returns (DeprecationInfo memory);
}This has advantages over EVM events:
- No runtime overhead on deprecated calls — the function itself runs at zero extra cost.
- Queryable by tooling — frontends, linters, deployment scripts can check before submitting.
- Queryable by contracts — a contract can call this view to check if something it depends on is deprecated (useful for upgradeable proxy patterns).
Additionally, Solidity interface files should use @deprecated natspec annotations so developers see warnings at compile time:
interface IMetagraph {
/// @deprecated Use getStakeV2 instead
function getStake(uint16 netuid, uint16 uid) external view returns (uint64);
function getStakeV2(uint16 netuid, uint16 uid) external view returns (StakeInfo memory);
}D. Replacing StorageQueryPrecompile with Typed Views
Goal: Every storage item in authorized pallets gets a typed precompile view function. The precompile owns the encoding/decoding — if the underlying storage layout changes, the precompile adapts while the Solidity interface stays the same.
Before (fragile):
bytes memory key = abi.encodePacked(
SUBTENSOR_PREFIX,
twox128("Weights"),
blake2_128Concat(netuid),
blake2_128Concat(uid)
);
(bool success, bytes memory result) = STORAGE_QUERY.staticcall(key);After (stable):
uint64 weight = IMetagraph(METAGRAPH_ADDRESS).getWeight(netuid, uid);If we later restructure Weights into a different storage shape, we update the precompile implementation — the Solidity interface stays the same. The contract is insulated from Substrate internals.
Deprecation path for StorageQueryPrecompile:
- Achieve typed view coverage for all currently-authorized pallets (SubtensorModule, Balances, Proxy, Scheduler, Drand, Crowdloan, Sudo, Multisig, Timestamp, Swap).
- Soft-deprecate
StorageQueryPrecompile— mark in registry and Solidity interface. - Hard-deprecate after a migration window.
- Eventually disable via
PrecompileEnable.
E. Handling Different Change Types
Not all changes are equally breaking. The versioning strategy distinguishes:
Additive (soft-deprecation only):
Adding a field to a returned struct. The old function keeps returning the original subset, a new V2 function returns the full struct. No breakage.
// V1: keeps working, returns (stake, hotkey)
fn get_neuron_lite(netuid: u16, uid: u16) -> (u64, H256) { ... }
// V2: returns (stake, hotkey, delegation) — the new full picture
fn get_neuron_lite_v2(netuid: u16, uid: u16) -> (u64, H256, u64) { ... }Semantic change (soft-deprecation, precompile adapts):
If getStake used to return total stake and now the chain tracks self-stake and delegated-stake separately, the V1 function can return self_stake + delegated_stake to preserve the old semantics, while V2 returns the breakdown.
Storage restructuring (transparent to callers):
Changing the underlying storage map structure, hasher, or key format. The precompile implementation changes but the Solidity signature stays identical. This is the core value of typed views over raw storage access.
Complete removal (hard-deprecation):
A storage item or extrinsic is removed entirely with no replacement. The precompile function returns PrecompileFailure::Error with a descriptive message. This should be rare.
F. Macro / Tooling (Exploratory)
The boilerplate of maintaining versioned functions, deprecation registry entries, and 1:1 storage mappings may benefit from macro support in the future. Some directions to explore:
- Version annotations on precompile functions that auto-populate the deprecation registry and generate Solidity
@deprecatednatspec annotations. - Auto-generation of view functions from
#[pallet::storage]definitions, reducing the manual surface area. - Compile-time checks that every public storage item in an authorized pallet has a corresponding precompile view.
The right abstraction is TBD. The patterns should be established manually first, and macros introduced when the boilerplate patterns become clear and stable.
Open Questions
-
Coverage scope: Should 1:1 coverage extend to all pallets or only a curated set? Some storage items may not make sense to expose (e.g., internal bookkeeping).
-
Selector stability: Should we commit to never reusing a function selector once deployed? This prevents a removed function's selector from colliding with a new function.
-
Registry precompile address: Should the deprecation registry live at a new precompile address, or be a set of view functions added to each existing precompile (e.g.,
IMetagraph.isDeprecated(bytes4) -> bool)?
Current State for Reference
StakingPrecompile(V1,0x801) andStakingPrecompileV2(0x805) coexist — both enabled, both callable.- No deprecation signal on V1 (just a code comment: "kinda deprecated, but exists for backward compatibility").
- New version got a new address (per-contract versioning) — this RFC proposes moving to per-function versioning instead.