Skip to content

feat(runtime): emit ExecutionMetadata::V4 with per-action contract list#15822

Merged
pugachAG merged 11 commits into
masterfrom
feat/execution-metadata-v4
Jun 10, 2026
Merged

feat(runtime): emit ExecutionMetadata::V4 with per-action contract list#15822
pugachAG merged 11 commits into
masterfrom
feat/execution-metadata-v4

Conversation

@pugachAG

@pugachAG pugachAG commented May 29, 2026

Copy link
Copy Markdown
Contributor
  • Adds ExecutionMetadata::V4 carrying a per-action Vec<AccountContract> (one entry per action in the receipt; the executed contract for FunctionCall actions, AccountContract::None for everything else, ordered to match receipt.actions)
  • Gated behind ProtocolFeature::ExecutionMetadataV4; pre-feature outcomes still emit V3 with the same bytes as before
  • Motivated by the need to surface the contract source for receipts where the receiver account and the executed contract diverge — e.g. global contracts and UseGlobalContract flows - so indexers / light clients can correlate each action with its actual contract
  • The first exploration of this added a ExecutionOutcomeV1 variant on the outcome struct itself (refactor: turn ExecutionOutcome struct into a versioned enum #15814 introduced the versioned-enum scaffolding, feat(primitives): add ExecutionOutcome V1 with persisted compute_usage #15816 added V1 with the byte-discriminant scheme). After looking at it, ExecutionMetadata is already a borsh-native discriminated enum, so adding V4 there is dramatically simpler — no custom serde tricks, no boundary constants, no struct migration across ~50 call sites.
  • Splits the result types: per-action ActionResult carries a single executed_contract: AccountContract (defaults to AccountContract::None, set by apply_action's FunctionCall arm); receipt-level ActionReceiptResult owns executed_contracts: Vec<AccountContract> and its merge appends one contract per action, so the per-action invariant lives in one place
  • ActionReceiptResult::set_error centralizes the failure path (record the error, drop queued receipts / proposals / burnt balances); called from both merge's Err arm and the LackBalanceForStorageStaking post-action storage check (which previously faked it by merging a synthetic ActionResult — under the new merge that would push a stray AccountContract::None onto executed_contracts)
  • ExecutionMetadataView gains contracts: Option<Vec<AccountContract>>, serde(skip_serializing_if = "Option::is_none") so V1..V3 RPC bytes are byte-identical to before
  • Closes Feature: add GlobalContractIdentifier to ExecutionOutcomeView #14622

@pugachAG pugachAG force-pushed the feat/execution-metadata-v4 branch from ab2c123 to 01bf85f Compare May 29, 2026 16:27
@codecov

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.58824% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.84%. Comparing base (43e592b) to head (9c05aac).

Files with missing lines Patch % Lines
core/primitives/src/views.rs 96.36% 2 Missing ⚠️
runtime/runtime/src/lib.rs 97.43% 0 Missing and 2 partials ⚠️
chain/jsonrpc/openapi/src/main.rs 0.00% 1 Missing ⚠️
core/primitives/src/transaction.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #15822      +/-   ##
==========================================
- Coverage   69.85%   69.84%   -0.01%     
==========================================
  Files         945      945              
  Lines      202794   202848      +54     
  Branches   202794   202848      +54     
==========================================
+ Hits       141652   141679      +27     
- Misses      56378    56409      +31     
+ Partials     4764     4760       -4     
Flag Coverage Δ
pytests-nightly 1.11% <0.00%> (-0.01%) ⬇️
unittests 69.57% <95.58%> (-0.02%) ⬇️
unittests-nightly 69.56% <95.23%> (-0.02%) ⬇️

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

☔ View full report in Codecov by Harness.
📢 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 force-pushed the feat/execution-metadata-v4 branch from 49e7f8b to adf731b Compare June 3, 2026 12:54
@pugachAG pugachAG requested review from darioush and wacban June 3, 2026 13:14
@pugachAG pugachAG marked this pull request as ready for review June 3, 2026 13:15
@pugachAG pugachAG requested a review from a team as a code owner June 3, 2026 13:15
Copilot AI review requested due to automatic review settings June 3, 2026 13:15

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

This PR introduces a new ExecutionMetadata::V4 variant to surface, per receipt action, the actual executed contract source (not just the receiver account), primarily to support global-contract / UseGlobalContract flows in indexers and RPC consumers.

Changes:

  • Adds ExecutionMetadata::V4 (gated by ProtocolFeature::ExecutionMetadataV4) carrying profile + a per-action contracts: Vec<AccountContract>.
  • Updates runtime action receipt aggregation to collect per-action executed contract info and emit V4 metadata when the feature is enabled.
  • Extends RPC view (ExecutionMetadataView) with an optional contracts field (skipped for V1–V3 for byte-identical JSON), plus adds tests/snapshots.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test-loop-tests/src/tests/indexer.rs Adds a test asserting V4 metadata exposes global-contract resolution for a FunctionCall receipt.
runtime/runtime/src/lib.rs Tracks per-action executed contract and emits ExecutionMetadata::V4 when feature-gated.
integration-tests/src/tests/features/chunk_nodes_cache.rs Adjusts metadata matching to handle V4 while still extracting V3-like profile data.
core/primitives/src/views.rs Adds AccountContractView, adds contracts to ExecutionMetadataView, and refactors profile-to-costs conversion.
core/primitives/src/transaction.rs Defines ExecutionMetadata::V4 + ExecutionMetadataV4 payload struct.
core/primitives/src/snapshots/near_primitives__views__tests__exec_metadata_v4_view.snap Adds snapshot coverage for the new V4 view JSON shape.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/primitives/src/views.rs
Comment thread runtime/runtime/src/lib.rs
@pugachAG pugachAG force-pushed the feat/execution-metadata-v4 branch from b428928 to 979d5c0 Compare June 3, 2026 15:14
Comment thread core/primitives/src/views.rs
assert_eq!(metadata.version, 4);
assert_eq!(
metadata.contracts.as_deref(),
Some(&[Some(AccountContractView::GlobalHash(code_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.

Should we add testing for the case of error and None as well? It doesn't need to go all the way through indexer but checking the outcome would be nice.

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 in 6a0b364

Comment thread core/primitives/src/transaction.rs Outdated
Comment on lines +647 to +651
/// One entry per action in the receipt. For `FunctionCall` actions this
/// is the contract that ran; for every other action it is
/// `AccountContract::None`. Order matches the receipt's `actions` vector,
/// so consumers can correlate a contract with the action that invoked it.
pub contracts: Vec<AccountContract>,

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.

Good catch! Contract code can indeed differ between different actions within a single receipt.
Note that in the future we might want to introduce host functions for corresponding DeployContract and UseGlobalContract actions, so that the code can be changed within a FunctionCall action (the effect should be applied before the next action is executed).

However, there might be couple concerns for this particular structure:

  1. How indexers can distinguish which of these entries relate to which action? If I'm not mistaken, there is no information about invoked actions on the current receipt, no?
  2. What's the motivation for using AccountContract::None for non-function-calls actions? Aren't we simply loosing some bits of information that might be useful in some use-cases?

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.

How indexers can distinguish which of these entries relate to which action?

Each action in the receipt will have exactly one entry in contracts vec and the order of contracts corresponds 1:1 to the order of actions in the receipt. This also answers your next question: using AccountContract::None for non-function-calls is needed to preserve that relation.

@mitinarseny mitinarseny Jun 5, 2026

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.

the order of contracts corresponds 1:1 to the order of actions in the receipt

I mean, where can an indexer see this list of ordered actions in the receipt to be able to correlate between corresponding entries in contracts? Can't see it anywhere in the ExecutionOutcome. Btw, logs: Vec<String> also puts all logs in a single list, indistinguishable from their corresponding actions.

This also answers your next question: using AccountContract::None for non-function-calls is needed to preserve that relation.

I honestly don't understand why does it have to be this way. Wouldn't real AccountContract::Local/Global/GlobalByAccountId work if it was the case? E.g. what if an instance of some global contract receives a native transfer or adds an access key to itself? Why would we ignore its current AccountContract in this case?

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.

Indexer exposes IndexerShard struct containing receipt_execution_outcomes: Vec<IndexerExecutionOutcomeWithReceipt>:

pub struct IndexerExecutionOutcomeWithReceipt {
    pub execution_outcome: views::ExecutionOutcomeWithIdView,
    pub receipt: views::ReceiptView,
}

I've updated the implementation to record the current contract on the account regardless of action type: 9c6a490

pugachAG and others added 9 commits June 9, 2026 13:18
…tract

V4 mirrors V3's profile data and additionally stores the AccountContract
that was executed by the receipt. This is the field the issue is really
after: for global contracts the receiver account and the contract source
diverge, and consumers (indexers, light clients, debuggers) currently
have no way to recover which contract actually ran.

ExecutionMetadata is already a borsh-native discriminated enum
(#[borsh(use_discriminant = true)]), so adding a variant requires no
custom serde scheme — V4 simply takes discriminant 3.

ExecutionMetadataView gains a contract: Option<AccountContract> field,
skipped when None so V1..V3 RPC output stays byte-identical. AccountContract
picks up #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
so the view continues to derive the OpenAPI schema.

No production code emits V4 yet — gating that switchover behind a
ProtocolFeature is a follow-up, since V4 on the wire would fail to
deserialize on binaries that don't recognize the new discriminant.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
… gate

Adds ProtocolFeature::ExecutionMetadataV4 (gated at the current stable
protocol version 85, alongside ContinuousEpochSync and friends) and wires
the runtime to emit V4 outcomes when it is active.

V4 now carries a per-action Vec<AccountContract>: one entry per action in
the receipt, set to the contract that ran for FunctionCall actions and to
AccountContract::None otherwise. This preserves contract attribution for
multi-call receipts (which ProfileDataV3 already aggregates), so indexers
and light clients can correlate each action with its contract.

Mechanics:
- ActionResult drops its standalone `profile` and `executed_contract`
  fields in favor of a single `metadata: Box<ExecutionMetadataV4>` that
  owns both the gas profile and the per-action contract list.
- apply_action tracks `executed_contract` locally and pushes exactly one
  entry to result.metadata.contracts at the end (and on every early-return
  through validation failures), so the per-action invariant holds.
- ActionResult::merge sums profiles and concatenates contracts, building
  up one entry per action across the receipt.
- function_call.rs converts the near_vm_runner ProfileDataV3 to the
  near_primitives one before merging into the receipt-level profile.
- apply_action_receipt picks V4 (move result.metadata) when the feature
  is enabled and V3 (extract profile only) otherwise.

ExecutionMetadataView's contract: Option<AccountContract> field becomes
contracts: Option<Vec<AccountContract>>, still skip_serializing_if = None
so V1..V3 RPC bytes are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
apply_action returns a per-action ActionResult carrying a single
executed_contract: AccountContract. apply_action_receipt folds those
into ActionReceiptResult, whose executed_contracts: Vec<AccountContract>
grows by one entry per merged action — the invariant ExecutionMetadataV4
relies on. AccountContract::None is the default and covers non-FunctionCall
actions, so no Option wrapper is needed.

Receipt-scoped fields (new_receipts, validator_proposals, tokens_burnt,
subsidized_amount) only make sense on the aggregate, so they no longer
need to round-trip through every per-action ActionResult before being
cleared on error. The clear-on-error logic moves into
ActionReceiptResult::set_error, called from both the Err arm of merge
and the LackBalanceForStorageStaking post-action storage check — the
latter previously faked it by merging a synthetic ActionResult, which
would now push a stray AccountContract::None onto executed_contracts.

ActionReceiptResult uses an explicit new() rather than impl Default
since it is only constructed at one site; ActionResult keeps Default
because the action helpers in actions.rs / access_keys.rs / etc. rely
on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…nCall

Deploys a global contract under a CodeHash identifier, points the user
account at it via UseGlobalContract, then calls a function on that
account. The resulting receipt's ExecutionMetadataView is asserted to
be V4 with contracts == [Some(GlobalHash(code_hash))] — the case the
new metadata variant exists to surface, where the receiver (user_account)
and the executed code (global hash) diverge.

Uses the TestLoopNode tx_deploy_global_contract / tx_use_global_contract
/ tx_call helpers so nonce + tx block hash are taken from the node.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
V4 metadata's `contracts` vector previously tracked only the executed
contract for `FunctionCall` actions, leaving every other action slot as
`AccountContract::None`. That made it impossible to tell, from the
outcome alone, whether a non-FunctionCall action ran against an account
with a contract attached.

Capture `account.contract()` at the top of `apply_action`, before any
validation runs, for every action kind. `AccountContract::None` is now
only emitted when the account did not exist (e.g. a `CreateAccount`
slot) or when the resize pad fills in unexecuted trailing slots after a
mid-receipt failure.

Mechanics:
- `ActionResult::executed_contract` → `current_contract`; populated
  unconditionally at the start of `apply_action` instead of inside the
  `FunctionCall` arm.
- `ActionReceiptResult::executed_contracts` → `current_contracts`;
  merge/concatenation logic is unchanged.
- `ExecutionMetadataV4.contracts` keeps its name (the field is the
  per-action contract list either way) but the docstring now describes
  pre-action capture semantics.

Updated `test_deploy_and_call_local_receipts` so the middle
`DeployContract` slot expects `Local(rs_hash)` (the contract present
before the replace) instead of `None`, and restructured
`test_apply_v4_metadata_pads_unexecuted_actions` to deploy a contract
before the failing action so the resize pad remains distinguishable
from a real per-action capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@pugachAG pugachAG force-pushed the feat/execution-metadata-v4 branch from 44bc802 to 9c6a490 Compare June 9, 2026 11:36
@pugachAG pugachAG requested a review from frol as a code owner June 9, 2026 13:36
# Conflicts:
#	core/primitives-core/src/version.rs
#	tools/protocol-schema-check/res/protocol_schema.toml
Merged via the queue into master with commit cf771d1 Jun 10, 2026
32 checks passed
@pugachAG pugachAG deleted the feat/execution-metadata-v4 branch June 10, 2026 12:25
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.

Feature: add GlobalContractIdentifier to ExecutionOutcomeView

4 participants