EIP-5920: PAY opcode

What stood out as new was the gas=0 use case, which wasn’t included in the rationale you pulled from the EIP.

It’s the first item in the rationale:

Not sure what to say here. My objection was not the fact of the EIP being declined, more that the EIP was declined for opaque reasons (apparently, due to backroom discussions) – rather than given in the EIP thread, where concerns or misunderstandings could have been discussed+addressed.

Can we add that its invalid to use the opcode in a static call?

Otherwise ambiguity as to whether is fine if the amount transferred is 0

3 Likes

Also what is the situation/result if there isn’t enough balance in sender?

Is it a success/fail value to stack; or is it a contract fail exception (=> outofgas)?

Assuming is 0 to stack on fail and 1 to stack on success (as per CALL et al)

1 Like

Implementation in Nethermind Implement EIP-5920: PAY by benaadams · Pull Request #8382 · NethermindEth/nethermind · GitHub

2 Likes

I spent a bit of time thinking about this, and from my perspective, I think both are reasonable. Unlike CALL, you can’t get a failure for a reason outside of your control, since you can check how much balance you have before issuing the opcode. The only thing I can think of is users may want to do this to avoid calling SELFBALANCE.

Overall, I would consider exceptional halting as slightly safer for users, but as you said, pushing 1 or 0 to stack is more consistent with the implementation for CALL.

Another, third option, would be to send min(selfbalance, <specified value>). Not sure how useful it is, but it allows you to send full balance with pay(uint256_max).

Is generally the more expensive option though (consume all gas); rather than cheaper check return in code and revert?

Safer in the sense that it’s harder to screw up - there is no way to “forget” to handle the invalid input case, the EVM handles it for you.

Also I don’t really think the amount of gas consumed is an issue. Generally if the argument to PAY is higher than SELFBALANCE, it indicates a user bug (same as stack underflow or other invalid opcode). I think that’s the point I was trying to make about “can’t get a failure for a reason outside of your control”. Like even with the exceptional-halting scheme, you can avoid the exceptional halt with DUP1<value> SELFBALANCE LT PUSH2 [join] JUMPI PAY [join] JUMPDEST.

I still think that consistency with CALL of the 1/0 scheme (and let’s face it, consistency with most of the rest of the EVM) is a pretty strong argument. I’ll let others chime in here.

I would go for the CALL scheme also, as you mentioned these exceptional halts are easy to prevent. In CALL if you try to send more than current balance, it exceptionally halts (consumes all gas in current frame) and puts 0 on the stack of the caller. This is consistent, and I like it.

The EIP reads:

Does addr exist or is val zero?
* If yes to either, zero;
* Otherwise, GAS_NEW_ACCOUNT.

This reads to me as if the case that val is nonzero always GAS_NEW_ACCOUNT must be charged which seems wrong. It should only charge GAS_NEW_ACCOUNT if value is nonzero and the addr is non-existent. NVM, I now understand the word “either” :sweat_smile:

Also brings the question for the val=0 case:

  • Is this allowed in STATICCALL? (I’d argue: just ban the entire opcode in static context)
  • Does this perform the warm/cold account check? (should not do this and not charge for it)
  • Does this put the target in warm (should not do this)

I like this EIP a lot because this is extremely good UX in the case a smart contract developer wants to send ETH to an account without having to worry about any code which might be executed on that account. Also for starting smart contract developers it does not make much sense that you have to worry about executing code if you want to send someone ETH. This is like the “safe way” to transfer ETH. I like this a lot, this should have been introduced to EVM much earlier.

Without PAY it is not possible to reach this behavior (well, techncially there is: CREATE a contract with X value and then selfdestruct X to pay A, but this is extremely expensive and will likely also not work at some fork in the future) because all CALL types will execute code, and in order to succesfully transfer ETH you must succeed the call frame.

There is a proposal to add logs for ether transfers, even if the amount is zero. Logs should not be created in a readonly context like STATICCALL.

Hey all, I just realized a super nice feature of this opcode, and this is to mark addresses as warm without running any code on them (PAY the address-to-warm 0!).

I opened 2 PRs:

Clarify behavior in STATIC mode
Push transfer status number (0 or 1) to stack like CALL (CC @charles-cooper)

Would love to get some feedback on those, thanks :slight_smile:

1 Like

BALANCE also warms the target address and is not considered a “writing” operation, and presents no STATICCALL interaction issues.

1 Like

Ah right, good point, forgot about BALANCE :slight_smile:

The EIP execution spec says

  • Pops two values from the stack: addr then val.
  • Charges the gas cost detailed below.
  • Exceptionally halts if addr has any of the high 12 bytes set to a non-zero value (i.e. it does not contain a 20-byte address).

I am curious about the order of operations between charging the gas and exceptional halting when address has non-zero high 12 bytes.

One of the components of the gas cost is if the to_address is warm or not and if the address has high bytes set to non-zero values, performing this check does not make sense. Would it more sense to provide more clarification on this?

The halting because of ASE should come first for performance reasons.

The cost wouldn’t matter as an exceptional halt consumes all available gas.

If the cache kept 32-byte addresses it wouldn’t matter because the only way to access them currently is via opcodes that only revert when they see them, so warm/cold state is not observable.

2 Likes

Could someone review my two open PRs?

1 Like

What if the addr is the address(this)? Should pay be a no-op charging only WARM_STORAGE_READ_COST in this case (like sstoring the same storage value)?

This would make sense (from an implementation/optimization perspective), but we also dont do this for CALL instructions where you CALL into address(this) and send nonzero value: you still pay the GAS_CALL_VALUE. It also does not make sense to PAY the current address (this can be checked in the contract to avoid “wasting” gas on this PAY).


I want to get this SFId for Fusaka, here is a draft PR for execution-specs Implement EIP-7873 and EIP-5920 by gurukamath · Pull Request #1180 · ethereum/execution-specs · GitHub

For execution-spec-tests there is a draft PR here: feat(tests): eip-5920 pay opcode cases by spencer-tb · Pull Request #1574 · ethereum/execution-spec-tests · GitHub

I’d love more feedback on the spec updates to the EIP: Update EIP-5920: add status stack return item / improve gas schedule by jochem-brouwer · Pull Request #9590 · ethereum/EIPs · GitHub. In particular this bans the opcode in a STATICCALL context, and will push a stack item: 0 for success and a nonzero value for failure (currently: 1 in case if contract balance is insufficient. Other nonzero values are reserved for possible different failure modes in the future)

Yes, otherwise it would have been a security issue since call needs to create an execution context and actually perform the call. I mean, what if address(this) had a fallback function?

In regard to pay, I would still recommend considering a different cost for a no-op situation.

That gas parameter is unrelated to creating an execution context. It is only charged when there is non-zero value transfer.

The cost scheme should match CALL. If an ether transfer log was added, the operation would not be a no-op. In fact it’s still likely to be recorded as an “internal transaction” in several systems. I also don’t think this case should be bikeshedded because it should never happen. Additional gas branching is not free. The system is simpler if it doesn’t check that case. The developer should check that case if they are concerned about it.

1 Like

One concern that I have with PAY is that it is enshrining a behavior that makes formal verification of contracts more difficult. Currently force sending ether with self destruct is niche and something we can either increase the cost of substantially or remove altogether (eventually). If we lean into PAY we will not have the this ability.

The issue PAY poses is that it is impossible for contracts to accurately track its balance and there will be downstream effects of this. I think it would be better for us to go towards a system that is more amenable to FV, rather than less.

The Move VM is a good inspiration here as their environment is built from day 1 to have excellent support for SA and FV. See section 4.1 of Formal verification in Solidity and Move: insights from a comparative analysis, and note that with PAY ether will act like ERC-20 tokens in the analysis.

Since PAY is not adding new behavior that isn’t possible today, we don’t need to stop pursuing just due to the above. But I want to make sure we are explicitly acknowledging that we are making FV harder in some cases, but that the benefits outweigh the cost.