feat(runtime): emit ExecutionMetadata::V4 with per-action contract list#15822
Conversation
ab2c123 to
01bf85f
Compare
Codecov Report❌ Patch coverage is 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
49e7f8b to
adf731b
Compare
There was a problem hiding this comment.
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 byProtocolFeature::ExecutionMetadataV4) carryingprofile+ a per-actioncontracts: 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 optionalcontractsfield (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.
b428928 to
979d5c0
Compare
| assert_eq!(metadata.version, 4); | ||
| assert_eq!( | ||
| metadata.contracts.as_deref(), | ||
| Some(&[Some(AccountContractView::GlobalHash(code_hash))][..]), |
There was a problem hiding this comment.
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.
| /// 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>, |
There was a problem hiding this comment.
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:
- 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?
- What's the motivation for using
AccountContract::Nonefor non-function-calls actions? Aren't we simply loosing some bits of information that might be useful in some use-cases?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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::Nonefor 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?
There was a problem hiding this comment.
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
…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]>
44bc802 to
9c6a490
Compare
# Conflicts: # core/primitives-core/src/version.rs # tools/protocol-schema-check/res/protocol_schema.toml
ExecutionMetadata::V4carrying a per-actionVec<AccountContract>(one entry per action in the receipt; the executed contract forFunctionCallactions,AccountContract::Nonefor everything else, ordered to matchreceipt.actions)ProtocolFeature::ExecutionMetadataV4; pre-feature outcomes still emit V3 with the same bytes as beforeUseGlobalContractflows - so indexers / light clients can correlate each action with its actual contractExecutionOutcomeV1variant 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,ExecutionMetadatais already a borsh-native discriminated enum, so addingV4there is dramatically simpler — no custom serde tricks, no boundary constants, no struct migration across ~50 call sites.ActionResultcarries a singleexecuted_contract: AccountContract(defaults toAccountContract::None, set byapply_action'sFunctionCallarm); receipt-levelActionReceiptResultownsexecuted_contracts: Vec<AccountContract>and itsmergeappends one contract per action, so the per-action invariant lives in one placeActionReceiptResult::set_errorcentralizes the failure path (record the error, drop queued receipts / proposals / burnt balances); called from bothmerge'sErrarm and theLackBalanceForStorageStakingpost-action storage check (which previously faked it by merging a syntheticActionResult— under the new merge that would push a strayAccountContract::Noneontoexecuted_contracts)ExecutionMetadataViewgainscontracts: Option<Vec<AccountContract>>,serde(skip_serializing_if = "Option::is_none")so V1..V3 RPC bytes are byte-identical to beforeGlobalContractIdentifiertoExecutionOutcomeView#14622