Skip to content

refactor(runtime): introduce RuntimeContractIdentifier for eager contract resolution#15429

Merged
pugachAG merged 5 commits into
masterfrom
runtime-contract-identifier-refactor
Mar 23, 2026
Merged

refactor(runtime): introduce RuntimeContractIdentifier for eager contract resolution#15429
pugachAG merged 5 commits into
masterfrom
runtime-contract-identifier-refactor

Conversation

@pugachAG

@pugachAG pugachAG commented Mar 21, 2026

Copy link
Copy Markdown
Contributor

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
Proof that this is a pure refactor (no behavior changes)

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()NotDeployedCryptoHash::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.

@pugachAG pugachAG force-pushed the runtime-contract-identifier-refactor branch from a00706c to f7c6a55 Compare March 21, 2026 19:10
…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]>
@pugachAG pugachAG force-pushed the runtime-contract-identifier-refactor branch from f7c6a55 to 6a6b174 Compare March 21, 2026 19:21
@codecov

codecov Bot commented Mar 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.13953% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.14%. Comparing base (6982d52) to head (24473fa).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
runtime/runtime/src/contract_code.rs 82.50% 9 Missing and 5 partials ⚠️
runtime/near-wallet-contract/src/lib.rs 65.51% 10 Missing ⚠️
runtime/runtime/src/ext.rs 75.00% 2 Missing ⚠️
runtime/runtime/src/lib.rs 93.33% 0 Missing and 1 partial ⚠️
runtime/runtime/src/pipelining.rs 95.83% 1 Missing ⚠️
runtime/runtime/src/state_viewer/mod.rs 90.00% 0 Missing and 1 partial ⚠️
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     
Flag Coverage Δ
pytests-nightly 1.16% <0.00%> (-0.01%) ⬇️
unittests 68.42% <83.13%> (+0.04%) ⬆️
unittests-nightly 68.65% <82.55%> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pugachAG pugachAG requested review from wacban March 23, 2026 09:44
@pugachAG pugachAG marked this pull request as ready for review March 23, 2026 09:45
@pugachAG pugachAG requested a review from a team as a code owner March 23, 2026 09:45
Copilot AI review requested due to automatic review settings March 23, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (new contract_code module) to centralize contract resolution (ETH implicit, global contracts) at construction time.
  • Thread RuntimeContractIdentifier through pipelining, runtime execution, and state viewer instead of CryptoHash code hashes.
  • Add LegacyEthWallet in near-wallet-contract to 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.

Comment thread runtime/runtime/src/ext.rs

@wacban wacban left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, great stuff!

Comment thread runtime/near-wallet-contract/src/lib.rs Outdated
Comment thread runtime/near-wallet-contract/src/lib.rs
Comment on lines +67 to +70
if let Some(legacy) = LegacyEthWallet::resolve(local_hash) {
let identifier = if config.eth_implicit_global_contract {
RuntimeContractIdentifier::GlobalEthWallet {
account_stored_hash: local_hash,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a bit confusing that LegacyEthWallet resolution leads to GlobalEthWallet

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, your understanding is correct

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added comment in e3efd3a

Comment on lines +86 to +88
// Preserves existing behavior of using `CryptoHash::default()` value for
// `code_hash` to indicate contract code absence in `AccountV1`.
Self::None => CryptoHash::default(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan but I'm guessing this is legacy behaviour that we must preserve?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

likely premature perf improvement suggestion: contract() reads the contract, is that heavy? Could we bypass it somehow or is that not possible here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a caching read: self.contract.get_or_init(|| Arc::new(ContractCode::new(self.code.to_vec(), None))).clone()

Comment thread runtime/runtime/src/contract_code.rs Outdated
Comment on lines -586 to -589
pub(crate) account_id: &'a AccountId,
pub(crate) code_hash: CryptoHash,
pub(crate) config: Arc<near_parameters::vm::Config>,
pub(crate) chain_id: String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice!

Comment thread runtime/runtime/src/function_call.rs Outdated
&self.chain_id,
AccessOptions::NO_SIDE_EFFECTS,
) else {
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it expected that we ignore the error here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from claude review: the pipelining code now passes no side effects in this place. claude says it's probably for the best though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 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 {

@pugachAG pugachAG force-pushed the runtime-contract-identifier-refactor branch from 38afb3a to 24473fa Compare March 23, 2026 13:53
@pugachAG pugachAG enabled auto-merge March 23, 2026 13:58
@pugachAG pugachAG added this pull request to the merge queue Mar 23, 2026
Merged via the queue into master with commit 3e9dfad Mar 23, 2026
32 checks passed
@pugachAG pugachAG deleted the runtime-contract-identifier-refactor branch March 23, 2026 14:22
github-merge-queue Bot pushed a commit that referenced this pull request Mar 23, 2026
…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]>
pugachAG added a commit that referenced this pull request Mar 24, 2026
…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]>
@VanBarbascu VanBarbascu mentioned this pull request Apr 15, 2026
25 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants