refactor(runtime): introduce RuntimeContractIdentifier for eager contract resolution#15429
Conversation
a00706c to
f7c6a55
Compare
…ract resolution Move contract resolution logic (ETH implicit accounts, global contracts, legacy wallet contracts) from lazy evaluation inside RuntimeContractExt to eager resolution at RuntimeContractIdentifier::resolve() construction time. This simplifies RuntimeContractExt to just storage + identifier and centralizes all special-case handling in one place. Co-Authored-By: Claude Opus 4.6 <[email protected]>
f7c6a55 to
6a6b174
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #15429 +/- ##
==========================================
+ Coverage 69.03% 69.14% +0.10%
==========================================
Files 934 935 +1
Lines 210089 210200 +111
Branches 210089 210200 +111
==========================================
+ Hits 145044 145341 +297
+ Misses 59061 58862 -199
- Partials 5984 5997 +13
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Refactors runtime contract execution to use an eagerly-resolved RuntimeContractIdentifier instead of passing raw code_hash values through multiple code paths, ensuring all runtime components agree on the effective contract (notably for ETH implicit accounts and global contracts).
Changes:
- Introduce
RuntimeContractIdentifier(newcontract_codemodule) to centralize contract resolution (ETH implicit, global contracts) at construction time. - Thread
RuntimeContractIdentifierthrough pipelining, runtime execution, and state viewer instead ofCryptoHashcode hashes. - Add
LegacyEthWalletinnear-wallet-contractto identify wallet contract variants and support consistent legacy ETH wallet resolution.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| runtime/runtime/src/state_viewer/mod.rs | Switches view function-call preparation to resolve and use RuntimeContractIdentifier. |
| runtime/runtime/src/pipelining.rs | Updates pipelining to resolve/pass RuntimeContractIdentifier into contract preparation instead of a raw code_hash. |
| runtime/runtime/src/lib.rs | Resolves contract identifier once per function call and passes it through execution + recording. |
| runtime/runtime/src/global_contracts.rs | Removes contract-access extension traits (moved into contract_code). |
| runtime/runtime/src/function_call.rs | Records contract calls using RuntimeContractIdentifier::old_hash() to preserve historical recording behavior. |
| runtime/runtime/src/ext.rs | Simplifies RuntimeContractExt to rely on storage + identifier for Contract trait behavior. |
| runtime/runtime/src/contract_code.rs | New module: defines RuntimeContractIdentifier and moved contract/global-contract access helpers. |
| runtime/near-wallet-contract/src/lib.rs | Introduces LegacyEthWallet enum and reimplements wallet_contract via that resolver. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if let Some(legacy) = LegacyEthWallet::resolve(local_hash) { | ||
| let identifier = if config.eth_implicit_global_contract { | ||
| RuntimeContractIdentifier::GlobalEthWallet { | ||
| account_stored_hash: local_hash, |
There was a problem hiding this comment.
it's a bit confusing that LegacyEthWallet resolution leads to GlobalEthWallet
There was a problem hiding this comment.
just to make sure I understand correctly, is the following correct?
There are three types of contracts that can be stored in an eth implicit account:
custom - for old accounts created before eth implicit were introduced
magic bytes - for accounts created after eth implict but before migration to global
global
it would be nice to assign clear names to those in some central doc and reference when handling each
There was a problem hiding this comment.
yes, your understanding is correct
| // Preserves existing behavior of using `CryptoHash::default()` value for | ||
| // `code_hash` to indicate contract code absence in `AccountV1`. | ||
| Self::None => CryptoHash::default(), |
There was a problem hiding this comment.
I'm not a fan but I'm guessing this is legacy behaviour that we must preserve?
There was a problem hiding this comment.
exactly, unfortunately fixing this would be a protocol change
| Self::None => CryptoHash::default(), | ||
| Self::AccountLocal(h) | Self::Global(h) => *h, | ||
| Self::GlobalEthWallet { global_contract_hash, .. } => *global_contract_hash, | ||
| Self::LegacyEthWallet(legacy) => *legacy.contract().hash(), |
There was a problem hiding this comment.
likely premature perf improvement suggestion: contract() reads the contract, is that heavy? Could we bypass it somehow or is that not possible here?
There was a problem hiding this comment.
it's a caching read: self.contract.get_or_init(|| Arc::new(ContractCode::new(self.code.to_vec(), None))).clone()
| pub(crate) account_id: &'a AccountId, | ||
| pub(crate) code_hash: CryptoHash, | ||
| pub(crate) config: Arc<near_parameters::vm::Config>, | ||
| pub(crate) chain_id: String, |
| &self.chain_id, | ||
| AccessOptions::NO_SIDE_EFFECTS, | ||
| ) else { | ||
| continue; |
There was a problem hiding this comment.
Is it expected that we ignore the error here?
There was a problem hiding this comment.
yep, contract preparation pipeline is best-effort, that same error will be surfaced when prepared contract isn't found in the cache
| } | ||
| let key = TrieKey::GlobalContractCode { identifier: self.into() }; | ||
| let value_ref = store | ||
| .get_ref(&key, KeyLookupMode::MemOrFlatOrTrie, AccessOptions::DEFAULT)? |
There was a problem hiding this comment.
from claude review: the pipelining code now passes no side effects in this place. claude says it's probably for the best though.
There was a problem hiding this comment.
🤔 old pipelining code used to pass no side effects as well:
let Ok(Some(value_ref)) = state_update.get_ref(
&key,
KeyLookupMode::MemOrFlatOrTrie,
AccessOptions::NO_SIDE_EFFECTS,
) else {
Co-Authored-By: Claude Opus 4.6 <[email protected]>
… account handling Co-Authored-By: Claude Opus 4.6 <[email protected]>
… call site Co-Authored-By: Claude Opus 4.6 <[email protected]>
…ctIdentifier Co-Authored-By: Claude Opus 4.6 <[email protected]>
38afb3a to
24473fa
Compare
…fier` (#15430) `record_contract_call` was using AccountContract (the on-chain stored value) to determine which contract code hash to record for distribution to validators. This is incorrect for the old ETH wallet accounts: the stored hash is the magic bytes hash, not the actual contract code hash, and for accounts using global contracts the wrong trie key was used. This is a better alternative to #15415. - Move record_contract_call from TrieUpdate method to a standalone function in function_call.rs that dispatches on RuntimeContractIdentifier, which already has the correctly resolved code hash and contract type - Remove GlobalEthWallet variant and old_hash() — no longer needed since the standalone function records the correct code hash directly from the resolved identifier - Remove LegacyEthWallet::magic_bytes_hash() as it has no remaining callers - Carry GlobalContractIdentifier in the Global variant and AccountId in the AccountLocal variant so record_contract_call can construct the correct trie key without extra arguments - Convert AccountLocal and Global to named-field struct variants - Drop Copy from RuntimeContractIdentifier since GlobalContractIdentifier contains AccountId - Add a regression test: extend test_eth_implicit_global_contract_mainnet_upgrade with a stateless validator that has its compiled contract cache cleared, verifying the global contract is properly distributed via the witness Stacked on #15429. --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
…ract resolution (#15429) Follow-up to the `record_contract_call` issue introduced in #15132. The root cause was that `AccountContract` (as stored on-chain) doesn't always represent the effective contract being executed — ETH implicit accounts with magic bytes actually run a different contract than what their `code_hash` field says. The resolution logic lived only inside `RuntimeContractExt` (used by the VM), so other runtime code paths like `record_contract_call` received raw `code_hash` values that didn't match the actually-executed contract. While #15415 patched the immediate testnet issue, the fundamental problem remained: contract resolution was duplicated, incomplete, and error-prone. This PR introduces `RuntimeContractIdentifier` — a lightweight enum that eagerly resolves the effective contract at a single point, then gets passed through the entire runtime instead of raw `code_hash: CryptoHash`. This makes it impossible for different code paths to disagree about which contract is being executed. - Move contract resolution logic (ETH implicit accounts, global contracts, legacy wallet contracts) from lazy evaluation inside `RuntimeContractExt` to eager resolution at `RuntimeContractIdentifier::resolve()` construction time - Introduce `LegacyEthWallet` enum in `near-wallet-contract` to identify wallet contract variants by chain - Simplify `RuntimeContractExt` to just `storage + identifier`, removing `account_id`, `config`, `chain_id` fields - Centralize all special-case contract handling in `RuntimeContractIdentifier::resolve()` in the new `contract_code` module - Move `AccountContractAccessExt` and `GlobalContractAccessExt` traits from `global_contracts` to `contract_code` module - Pass `RuntimeContractIdentifier` through the runtime instead of raw `code_hash: CryptoHash` <details> <summary>Proof that this is a pure refactor (no behavior changes)</summary> Traced every `AccountContract` variant through every code path (lib.rs main execution, pipelining, state_viewer, function_call) comparing old vs new behavior: **`AccountContract::None`**: - Old: `AccountContractAccessExt::hash()` → `NotDeployed` → `CryptoHash::default()`. `RuntimeContractExt::get_code()` → `storage.get(default)` → None. `record_contract_call` gets `default`. - New: `RuntimeContractIdentifier::None`. `hash()` → default, `get_code()` → None. `old_hash()` → default. - **Identical.** **`AccountContract::Local(h)` — non-eth-implicit**: - Old: `code_hash = h`. `hash()` = h, `get_code()` = `storage.get(h)`. `record_contract_call` gets h. - New: `AccountLocal(h)`. `hash()` = h, `get_code()` = `storage.get(h)`. `old_hash()` = h. - **Identical.** **`AccountContract::Local(h)` — eth implicit, wallet match, `eth_implicit_global_contract` = false**: - Old: `code_hash = h` (magic bytes hash). `RuntimeContractExt::hash()` → wallet match → `*wc.hash()` (actual contract hash). `get_code()` → `Some(wc)`. `record_contract_call` gets h. - New: `LegacyEthWallet(legacy)`. `hash()` = `*legacy.contract().hash()`. `get_code()` = `Some(legacy.contract())`. `old_hash()` = `legacy.magic_bytes_hash()` = h. - **Identical.** **`AccountContract::Local(h)` — eth implicit, wallet match, `eth_implicit_global_contract` = true**: - Old: `code_hash = h` (magic bytes). `RuntimeContractExt::hash()` → wallet match → `eth_wallet_global_contract_hash(chain_id)`. `get_code()` → `storage.get(global_hash)`. `record_contract_call` gets h. - New: `GlobalEthWallet { account_stored_hash: h, global_contract_hash: global_hash }`. `hash()` = `global_hash`. `get_code()` = `storage.get(global_hash)`. `old_hash()` = h. - **Identical.** (The `GlobalEthWallet` variant exists specifically to preserve this: `old_hash()` returns the account-stored magic bytes hash for `record_contract_call`, while `hash()` returns the global contract hash for VM execution.) **`AccountContract::Local(h)` — eth implicit, NO wallet match**: - Old: `wallet_contract(h)` returns None → falls through → `hash()` = h, `get_code()` = `storage.get(h)`. `record_contract_call` gets h. - New: `LegacyEthWallet::resolve(h)` returns None → `AccountLocal(h)`. Same behavior. - **Identical.** **`AccountContract::Global(h)`**: - Old: `AccountContractAccessExt::hash()` → `CodeHash(h)` → returns h. `hash()` = h, `get_code()` = `storage.get(h)`. `record_contract_call` gets h. - New: `resolve()` → `CodeHash(h)` → `Global(h)`. Same behavior. - **Identical.** **`AccountContract::GlobalByAccount(id)`**: - Old: `AccountContractAccessExt::hash()` → `AccountId(id)` → `gci.hash(store)` with `DEFAULT` access → resolved hash. Error message: `"Global contract identifier not found"`. `hash()` = resolved, `get_code()` = `storage.get(resolved)`. `record_contract_call` gets resolved. - New: `resolve()` → `AccountId(id)` → `gci.hash(store, DEFAULT)` → resolved hash → `Global(resolved)`. Error message: `"Global contract identifier not found"`. Same behavior. - **Identical.** **Pipelining**: Old code matched `account.contract()` to extract `code_hash` and passed it to `RuntimeContractExt { code_hash, account_id, config, chain_id }`. New code does block checks first, then calls `RuntimeContractIdentifier::resolve()` with `NO_SIDE_EFFECTS`. The `GlobalByAccount` trie lookup uses the same `get_ref` with `NO_SIDE_EFFECTS`. All `Contract` trait method results are identical. **State viewer**: Same analysis as lib.rs — uses `DEFAULT` access, identical results. </details> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
Follow-up to the
record_contract_callissue introduced in #15132. The root cause was thatAccountContract(as stored on-chain) doesn't always represent the effective contract being executed — ETH implicit accounts with magic bytes actually run a different contract than what theircode_hashfield says. The resolution logic lived only insideRuntimeContractExt(used by the VM), so other runtime code paths likerecord_contract_callreceived rawcode_hashvalues that didn't match the actually-executed contract. While #15415 patched the immediate testnet issue, the fundamental problem remained: contract resolution was duplicated, incomplete, and error-prone.This PR introduces
RuntimeContractIdentifier— a lightweight enum that eagerly resolves the effective contract at a single point, then gets passed through the entire runtime instead of rawcode_hash: CryptoHash. This makes it impossible for different code paths to disagree about which contract is being executed.RuntimeContractExtto eager resolution atRuntimeContractIdentifier::resolve()construction timeLegacyEthWalletenum innear-wallet-contractto identify wallet contract variants by chainRuntimeContractExtto juststorage + identifier, removingaccount_id,config,chain_idfieldsRuntimeContractIdentifier::resolve()in the newcontract_codemoduleAccountContractAccessExtandGlobalContractAccessExttraits fromglobal_contractstocontract_codemoduleRuntimeContractIdentifierthrough the runtime instead of rawcode_hash: CryptoHashProof that this is a pure refactor (no behavior changes)
Traced every
AccountContractvariant through every code path (lib.rs main execution, pipelining, state_viewer, function_call) comparing old vs new behavior:AccountContract::None:AccountContractAccessExt::hash()→NotDeployed→CryptoHash::default().RuntimeContractExt::get_code()→storage.get(default)→ None.record_contract_callgetsdefault.RuntimeContractIdentifier::None.hash()→ default,get_code()→ None.old_hash()→ default.AccountContract::Local(h)— non-eth-implicit:code_hash = h.hash()= h,get_code()=storage.get(h).record_contract_callgets h.AccountLocal(h).hash()= h,get_code()=storage.get(h).old_hash()= h.AccountContract::Local(h)— eth implicit, wallet match,eth_implicit_global_contract= false:code_hash = h(magic bytes hash).RuntimeContractExt::hash()→ wallet match →*wc.hash()(actual contract hash).get_code()→Some(wc).record_contract_callgets h.LegacyEthWallet(legacy).hash()=*legacy.contract().hash().get_code()=Some(legacy.contract()).old_hash()=legacy.magic_bytes_hash()= h.AccountContract::Local(h)— eth implicit, wallet match,eth_implicit_global_contract= true:code_hash = h(magic bytes).RuntimeContractExt::hash()→ wallet match →eth_wallet_global_contract_hash(chain_id).get_code()→storage.get(global_hash).record_contract_callgets h.GlobalEthWallet { account_stored_hash: h, global_contract_hash: global_hash }.hash()=global_hash.get_code()=storage.get(global_hash).old_hash()= h.GlobalEthWalletvariant exists specifically to preserve this:old_hash()returns the account-stored magic bytes hash forrecord_contract_call, whilehash()returns the global contract hash for VM execution.)AccountContract::Local(h)— eth implicit, NO wallet match:wallet_contract(h)returns None → falls through →hash()= h,get_code()=storage.get(h).record_contract_callgets h.LegacyEthWallet::resolve(h)returns None →AccountLocal(h). Same behavior.AccountContract::Global(h):AccountContractAccessExt::hash()→CodeHash(h)→ returns h.hash()= h,get_code()=storage.get(h).record_contract_callgets h.resolve()→CodeHash(h)→Global(h). Same behavior.AccountContract::GlobalByAccount(id):AccountContractAccessExt::hash()→AccountId(id)→gci.hash(store)withDEFAULTaccess → resolved hash. Error message:"Global contract identifier not found".hash()= resolved,get_code()=storage.get(resolved).record_contract_callgets resolved.resolve()→AccountId(id)→gci.hash(store, DEFAULT)→ resolved hash →Global(resolved). Error message:"Global contract identifier not found". Same behavior.Pipelining: Old code matched
account.contract()to extractcode_hashand passed it toRuntimeContractExt { code_hash, account_id, config, chain_id }. New code does block checks first, then callsRuntimeContractIdentifier::resolve()withNO_SIDE_EFFECTS. TheGlobalByAccounttrie lookup uses the sameget_refwithNO_SIDE_EFFECTS. AllContracttrait method results are identical.State viewer: Same analysis as lib.rs — uses
DEFAULTaccess, identical results.