Skip to content

Conversation

@jakmeier
Copy link
Contributor

With promise_set_refund_to, an outgoing action receipt can set where the balance refund should go, in case of a balance refund. Balance refunds happen after a failed receipt with attached deposits.

Adding the host function is part of of NEP-616. (near/NEPs#616).

It helps with cases where the signing account calls a deterministic account, which then calls another deterministic account. If the second call needs a balance attached, the signing account may have provided it for forwarding. In that case, if the second calls fails, we want the funds to go back to the signer, not the caller.

The implementation is involved. It requires a new field somewhere on the receipt. I think putting it on the ActionReceipt makes the most sense. This is the first time we modify ActionReceipt in NEAR history, as far as I know.

I propose to make ActionReceipt versioned, by adding more variants to ReceiptEnum. The variant implicitly defines the version. Note that this needs to be done for ReceiptEnum::Action and ReceiptEnum::PromiseYield since both contain an action receipt.

To make changes around the code base easier, I then added pub enum VersionedActionReceipt<'a> which can be constructed from either ActionReceipt or ActionReceiptV2 and has access methods to all fields.

I also considered making ActionReceipt itself versioned instead of expanding ReceiptEnum. But for backwards compatibility with borsh encoding, I believe it would still need new variants in ReceiptEnum.

We could only go to ReceiptEnum::VersionedAction instead of ReceiptEnum::ActionV2. But I don't see how it would be better than my proposed solution.

Testing strategy

Tests check that the new host function can be called from a contract and correctly redirects the refund, if the feature is enabled.

Adding the host function `promise_refund_to`  is part of the deterministic
account id proposal. This requires a new field somewhere on the receipt.
I think putting it on the ActionReceipt makes the most sense.

Adding a new version of the `ActionReceipt` is a bit cumbersome.
In this commit, I'm trying to do just the minimal required change to get
things working, to keep the PR size from exploding.

- Most places that don't need to set the new field keep using the old version.
- The old version is not renamed. (`ActionReceipt` rather than `ActionReceiptV1`)

These two points can be cleaned up in future refactor-only PRs.
this is necessary to fix some tests
On the latest master HEAD, the new host function needs to be defined twice,
to work with wasmtime, too.
@jakmeier jakmeier requested a review from pugachAG September 19, 2025 18:22
@jakmeier jakmeier requested a review from a team as a code owner September 19, 2025 18:22
@jakmeier
Copy link
Contributor Author

notes for reviewers:

  1. It's probably easier to review by commit.
  2. I added two somewhat redundant tests, trying to find out what the best framework for this is. One is with the old runtime-specific test setup. The other is integration testing with the test loop setup. I would suggest we delete one of the two tests, as they really test pretty much the same thing. Please let me know which type of testing is preferred.

@codecov
Copy link

codecov bot commented Sep 19, 2025

Codecov Report

❌ Patch coverage is 60.37736% with 231 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.59%. Comparing base (5244f37) to head (56debf8).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
core/store/src/genesis/state_applier.rs 0.00% 48 Missing ⚠️
core/primitives/src/views.rs 0.00% 37 Missing ⚠️
core/primitives/src/receipt.rs 65.38% 34 Missing and 2 partials ⚠️
...untime/near-vm-runner/src/wasmtime_runner/logic.rs 0.00% 34 Missing ⚠️
runtime/runtime/src/lib.rs 80.64% 13 Missing and 11 partials ⚠️
tools/state-viewer/src/contract_accounts.rs 47.61% 11 Missing ⚠️
tools/mirror/src/genesis.rs 66.66% 9 Missing and 1 partial ⚠️
runtime/near-vm-runner/src/logic/logic.rs 64.00% 6 Missing and 3 partials ⚠️
runtime/runtime/src/actions.rs 88.52% 2 Missing and 5 partials ⚠️
runtime/runtime/src/congestion_control.rs 70.00% 0 Missing and 6 partials ⚠️
... and 5 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #14285      +/-   ##
==========================================
- Coverage   69.61%   69.59%   -0.03%     
==========================================
  Files         982      982              
  Lines      208883   209209     +326     
  Branches   208883   209209     +326     
==========================================
+ Hits       145413   145595     +182     
- Misses      57219    57346     +127     
- Partials     6251     6268      +17     
Flag Coverage Δ
pytests 0.40% <0.00%> (-0.01%) ⬇️
pytests-nightly 1.29% <0.00%> (-0.01%) ⬇️
unittests 68.03% <48.71%> (-0.06%) ⬇️
unittests-nightly 67.91% <58.83%> (-0.04%) ⬇️
unittests-spice 14.73% <17.66%> (-0.01%) ⬇️

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.

};
set(state_update, key, receipt);
}
ReceiptEnum::PromiseYieldV2(action_receipt) => {
Copy link
Contributor

@pugachAG pugachAG Sep 22, 2025

Choose a reason for hiding this comment

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

There are quite a few places where such code duplication happens as part of match statement which would be nice to avoid. Maybe lets add fn from_receipt(receipt: &Receipt) -> Self as part VersionedActionReceipt which asserts the right receipt type internally. Then we can used binding as part of the match statement: receipt @ ReceiptEnum::PromiseYield(_) | ReceiptEnum::PromiseYieldV2(_) => { action_receipt = VersionedActionReceipt::from_receipt(receipt); .... That this is not ideal because we replace compile-time check with runtime assert on receipt type, but might be worth it to avoid code duplication.
I think a better idea would be to introduce transient VersionedReceiptEnum data structure similar to VersionedActionReceipt. It will have Action and PromiseYield variants containing VersionedActionReceipt and other variants are the same as in ReceiptEnum. Then we can eliminate code duplication while keeping type safety. The downside of that approach is that it is more involved to implement.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Then we can used binding as part of the match statement: receipt @ ReceiptEnum::PromiseYield() | ReceiptEnum::PromiseYieldV2() => { action_receipt = VersionedActionReceipt::from_receipt(receipt); .... That this is not ideal because we replace compile-time check with runtime assert on receipt type, but might be worth it to avoid code duplication.

Personally, I don't hate code duplication (of "simple" code) enough to see this as a benefit. :) But I can see your argument.

I think a better idea would be to introduce transient VersionedReceiptEnum data structure similar to VersionedActionReceipt. It will have Action and PromiseYield variants containing VersionedActionReceipt and other variants are the same as in ReceiptEnum. Then we can eliminate code duplication while keeping type safety. The downside of that approach is that it is more involved to implement.

This sounds good! Implementation shouldn't be too bad. I would be more worried about code readability with all the indirections. But I think it's worth it. Will give it a try.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've implemented this in the latest commit
fb524f3

It allowed me to remove some annoying code duplication. E.g. how we process receipt in runtime/runtime/src/lib.rs

@@ -1149,7 +1146,7 @@ impl Runtime {
                     }
                 }
             }
-            ReceiptEnum::Action(action_receipt) => {
+            VersionedReceiptEnum::Action(action_receipt) => {
                 // Received a new action receipt. We'll first check how many input data items
                 // were already received before and saved in the state.
                 // And if we have all input data, then we can immediately execute the receipt.
@@ -1164,41 +1161,19 @@ impl Runtime {
                     pipeline_manager,
                     stats,
                     account_id,
-                    action_receipt.into(),
-                )?;
-
-                if executed.is_some() {
-                    return Ok(executed);
-                }
-            }
-            ReceiptEnum::ActionV2(action_receipt) => {
-                // Received a new action receipt. We'll first check how many input data items
-                // were already received before and saved in the state.
-                // And if we have all input data, then we can immediately execute the receipt.
-                // If not, then we will postpone this receipt for later.
-                let executed = self.process_action_receipt(
-                    receipt,
-                    receipt_sink,
-                    validator_proposals,
-                    state_update,
-                    apply_state,
-                    epoch_info_provider,
-                    pipeline_manager,
-                    stats,
-                    account_id,
-                    action_receipt.into(),
+                    action_receipt,
                 )?;

I couldn't make it work for mutable access, which still needs to explicitly match all versions. So code like this still has duplication.

// runtime/runtime/src/lib.rs
                // Modifying a new receipt instead of sending data
                match result
                    .new_receipts
                    .get_mut(receipt_index as usize)
                    .expect("the receipt for the given receipt index should exist")
                    .receipt_mut()
                {
                    ReceiptEnum::Action(new_action_receipt)
                    | ReceiptEnum::PromiseYield(new_action_receipt) => new_action_receipt
                        .output_data_receivers
                        .extend_from_slice(&action_receipt.output_data_receivers()),
                    ReceiptEnum::ActionV2(new_action_receipt)
                    | ReceiptEnum::PromiseYieldV2(new_action_receipt) => new_action_receipt
                        .output_data_receivers
                        .extend_from_slice(&action_receipt.output_data_receivers()),
                    _ => unreachable!("the receipt should be an action receipt"),
                }

Also, the runtime sync calls tests with assert_receipts! still needs to list all versions, too, as we need to extract actions from different versions.

// runtime/runtime/tests/test_async_calls.rs
    let receipts = &*assert_receipts!(group, "near_1" => r1 @ "near_2",
        ReceiptEnum::Action(ActionReceipt{actions, ..}) | ReceiptEnum::ActionV2(ActionReceiptV2{actions, ..}),
        {},
        actions,
        a0, Action::FunctionCall(function_call_action), {
        assert_eq!(function_call_action.gas, GAS_2);
        assert_eq!(function_call_action.deposit, 0);
    });

Overall, I think it's probably worth the extra boiler-plate and indirection, to avoid code duplication in a few key places. It probably also makes future changes easier. (Though it could also make it harder, I'm bad at predicting these things)

@pugachAG Is this what you had in mind?

Copy link
Contributor

Choose a reason for hiding this comment

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

That is exactly what I meant, thanks!

Regarding mutable access in my understanding the only way around it is introducing dedicated wrappers VersionedReceiptEnumMut and VersionedReceiptEnumMut which probably isn't worth it at this point.

As suggested in the review process, this avoids code duplication
by adding another versioned convenience wrapper,
this time around ReceiptEnum.
Copy link
Contributor

@pugachAG pugachAG left a comment

Choose a reason for hiding this comment

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

I suggest keeping only the test_async_calls test as it is on the lower level (testing smaller unit) which is generally preferred

Comment on lines 1150 to 1153
// Received a new action receipt. We'll first check how many input data items
// were already received before and saved in the state.
// And if we have all input data, then we can immediately execute the receipt.
// If not, then we will postpone this receipt for later.
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: it seems like this comment belongs as part of the extracted method

if self.block_accounts.contains(account_id) {
return false;
}
let actions = match receipt.receipt() {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: would be good to used versioned receipt here as well

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe I'm missing a trick but the Rust borrow checker doesn't allow borrowing VersionedReceiptEnum::Action(a) => a.actions() beyond the lifetime of a as things are written now. So I'd have to refactor the code to make it work.

removing the test loop test, keeping the async calls test
New struct: ActionReceiptV2 with hash 182527260

This is used in two new variants added to the end of `ReceiptEnum`.

Many structs through out the protocol contain this enum.
54 hashes need to be updated accordingly.
@jakmeier jakmeier requested a review from frol as a code owner September 23, 2025 13:41
@jakmeier
Copy link
Contributor Author

@pugachAG I have now also updated the protocol schema. I assume this is just here to ensure we don't unexpectedly change borsh serialized types? In this case, I add new variants to ReceiptEnum, which leads to many changes. Please confirm it's okay to just update the schema as I've done in 0f1ffc3

@jakmeier
Copy link
Contributor Author

@frol Sorry GitHub pulled you in as reviewer because an intermediate merge used an older chain/jsonrpc/openapi/openapi.json somehow. This PR should not (and does not) change chain/jsonrpc/openapi/openapi.json

@jakmeier jakmeier removed the request for review from frol September 23, 2025 13:53
@jakmeier jakmeier added this pull request to the merge queue Sep 23, 2025
Merged via the queue into near:master with commit 67a8f16 Sep 23, 2025
28 of 30 checks passed
@jakmeier jakmeier deleted the nep616-refund-to branch September 23, 2025 14:51
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.

2 participants