You're correct that both approaches accomplish the same goal of letting your contract increase its ETH balance. One is better than the other depending on the type of API you want users of your contract to have.
Below I'll explain where the receive
function is best used, and where having a payable
function is best.
tldr: The difference depends on whether the address interacting with your contract knows how your contract works (i.e. what public functions your contract exposes).
receive
function:
In scenarios where you want a user to be able to send ETH to your contract, without any knowledge of how your contract works, a receive
function is the way to go. The best example of this is a smart contract wallet like Gnosis Safe Wallet. If Alice wants to send funds to Bob's Safe Wallet which implement receive
, all she has to do is call payable(bobs_safe_address).call{value: amount}("")
. No matter what Gnosis does internally, Alice can easily send her funds to Bob in a single transaction and with no knowledge of the contract's internal workings.
If the Gnosis contract had a payable
function instead of receive
, then Alice would need to lookup what the function name and what its arguments are (if any), and then call that function while passing in the amount of ETH value she wants to send. This is more complicated than simply sending ETH in to the contract, and the simplicity of the receive
is why it's preferred here.
payable
function:
When your contract has a function that requires some non-zero ETH value in order to function correctly, this is where you should favor a payable
function over a simple receive
function.
An example of this would be if you were building a Charity
contract, which exposes a function contribute() public payable
function. Alice already knows about the functions exposed by the Charity
contract, and knows she needs to call Charity.contribute
in order to have her contribution correctly recorded by the contract. In this case marking the address as payable
is the way to go. If the contract had the contribute
function AND the receive
function, or just a receive
function that internally executed the contribute
function, this would be a more confusing API because it's less clear to Alice what she needs to do in order to get her contribution correctly recorded.
The most ambiguous situation to decide between the two is when there is only one function that needs to be marked payable
on your contract, and you're trying to decide whether to implement that function's logic as its owned named function (e.g. contribute
) or to just use a receive
function. In that case I think it's still best to use a named contribute
function marked payable
, because then you prevent users from unknowingly sending ETH to your contract when they didn't mean to.