-
Notifications
You must be signed in to change notification settings - Fork 11.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
transferFrom calls public virtual allowance function breaking overridden behaviour #3211
Comments
Hello @NeoXtreem This is something we used to have, and that we specifically changed to allow creative use of overrides. IMO, the value returned by I'm concerned by the fact that, in the code the give, if Maybe you would override the
I'm still worried by the inconsistency you'd get between the Approval events and the Transfer events, but tat is another matter IMO, no function but |
Hi @Amxx, I omitted Your solution also does not work as it doesn't change the behaviour of the concrete implementation which is already calling function _foo(address from, address to, uint256 amount) internal {
_approve(from, _msgSender(), amount); // Would need to be super._approve if your override is used
super.transferFrom(from, to, amount);
} By mandating that
This is why I made the changes I did in in #3212. It complies with pattern 2. If you want to achieve the possibility to do what the example contract you referenced does, then that is possible with design pattern 1, and very easy to support in the base class by simply adding an So, I don't think there's room for opinion here. The design should remain consistent, otherwise you are potentially breaking any smart contract that transforms amounts between the base contract and the public interface on the derived contract. And you are also limiting the ability for concrete implementations to choose how to override behaviour unlike all the other virtual functions in the base class that follow design pattern 1 above. Your change basically breaks SoC design principle. If it helps, my contract is this one which is the base class for this one. You can see the relevant code in
In short, if this statement were true, then you should also say the same for I sincerely hope this clarifies the change, or leans you towards me adding Thanks, PS: Re the inconsistency on |
I'll read the rest of your message and try when my mind is clear, because I think answering your question first requires me to fully understand your usecase. Still I wanted to point that, on ERC777, you could override
which would be the equivalent to overriding |
I don't get the purpose of this function, regardless of the super call ... This is bypassing any missing approval to do a transfer ... so why not just do |
I had a quick look at the In particular, you override But then you also override Maybe you missed that transfer is internally calling I still think that Unrelated but still wanted to share:
|
@Amxx I think you've completely missed the mark here. Neither I gather from your comments that you may not be familiar with the EIP-777 token standard, so please allow me to explain. It essentially adds separate functionality that prevents transfers to contract addresses unless they're registered on the ERC-1820 registry on the respective chain. That involves the So, my concern here is that you are sidestepping the real issue which is that you have introduced a breaking change that I have offered a fix for, and you are opposing it with incorrect assertions that are in many ways unrelated anyway. Please could you revise this again based on the correction of your misunderstanding of I would also like @frangio to look at this as he reviewed your change. In response to your other comments:
|
Are we looking at the same code?
When you point out the change to the allowance read/write, it seems you also missed a lot of the other recent changes we made to ERC777. They were made hoping it would help design contract like yours, but since you already did that for a previous variation of the contracts, the migration is indeed non-trivial. Our main objective is to provide external consistency in the behavior of interfaces, but we still change some internal code to achieve what we consider improvement ... and we do that by following community requests. It as always been clear that overriding function is a complex thing that require a good understanding of the code being overridden. (particularly) In that case it is encouraged to freeze your dependency, and to check the changelog for changes, and to run your unit tests when upgrading. There hasn't been any security issues with ERC777 over the past year, so if the changes me made to it are not of your liking, you should probably stick with an old version of it. Your seem knowledgeable to cherry-pick the changes that you like, and not take the one you don't like. At that point you include so many customization that it might make sens to have your own version anyway. |
I want to assure you that we are very mindful of breaking changes. We strongly avoid them, except for situations where after a lot of consideration we decide to do them because we feel the benefits outweigh the costs. When it comes to virtual functions, however, we are forced to have weaker stability guarantees. It is extremely hard to make any change without introducing potential breakage for users who define overrides. We could have a very restricted set of virtual functions as extension points for users who wish to customize and ensure their backwards compatibility, but experience has shown us that these are never enough, developers want to customize things we have no way of foreseeing, and after a long time we decided that simply every function would be virtual. This has the side effect that what would otherwise be implementation details are exposed as a kind of interface. But if we want to continue improving the code when we find ways to do so, we need to be able to change implementation details, otherwise the code becomes ossified and can never be improved (or we do major versions every month, which is not good for users anyway). This is why ever since we introduced virtual everywhere we have explained the following (#2154 (comment)):
Admittedly, we should feature this warning in the documentation, and we should additionally make it explicit that we cannot guarantee lack of breaking changes when functions are overriden. When a developers updates the dependency from one version to the next, they need to look at the changes to understand if they will have an impact on the custom overrides. This is where we are now. You have detected that the new implementation breaks your previous assumptions, and it's good that you reported it, because it lets us know of the creative ways in which users are extending the contracts, and we can consider the use case and see if there is something we should be doing differently. We disagree with the premise that this is a breaking change that we have to roll back. What you're trying to achieve may be doable with the latest version, maybe not in the same way you were doing it. If it's not, and if we want to enable your use case, we should try to do so in a way that also supports the use case we had in mind when we made this change in the first place. |
I liked the approach suggested by @Amxx but the issue you pointed out in function allowance(address holder, address spender) public view override returns (uint256) {
return f(super.allowance(holder, spender));
}
function _approve(address holder, address spender, uint256 value) internal override {
return super._approve(holder, spender, f_(value));
}
function _spendAllowance(address owner, address spender, uint256 amount) internal override {
super._spendAllowance(owner, spender, f(amount));
}
function _send(
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData,
bool requireReceptionAck
) internal override {
return super._send(from, to, f_(amount), userData, operatorData, requireReceptionAck);
} Alternatively, you can copy the implementation of function _spendAllowance(
address owner,
address spender,
uint256 amount
) internal virtual {
uint256 currentAllowance = super.allowance(owner, spender);
if (currentAllowance != type(uint256).max) {
require(currentAllowance >= amount, "ERC777: insufficient allowance");
unchecked {
super._approve(owner, spender, currentAllowance - amount);
}
}
} |
@frangio Thanks for your replies. Much appreciated. I'm happy to say your patch works. Couple of things, though:
I still find it odd that the public virtual By following a different design pattern for I still think it would've been far better to add an internal virtual
Extending a class is normal practice for users of your library. If you have virtual functions, then you should expect they will be overridden, and breaking changes should not be made on a non-major upgrade unless absolutely necessary (to fix a bug or security hole). If you wish to encapsulate behaviour that should not be overridden while still allowing some functionality to be overridden, then provide interfaces for this (ISP). Generally, code should follow OCP, which means code will not change short of a refactor. I understand this is more difficult in Solidity, but then at least adhere to LSP so that base contract behaviour doesn't change if a user decides to override a public function. At least give them a choice to change the behaviour or not by offering them public virtual and internal virtual functions like you do with many of the other functions in the base contract. In any case, thank you both for responding and assisting on this. I hope we all got something out of it. 🙂 |
I was suggesting to override
We agree on this in principle, but as I tried to explain earlier, following this discipline strictly would leave us with an ossified codebase, never improved, and this is not something we think would benefit users. There are some virtual functions that are meant to be overriden (e.g. All of this said, we will consider the topics mentioned in this discussion going forward. Perhaps we should establish a guideline that internal functions should not call public functions. |
@frangio Your earlier reply indicated your suggested code was aggregate to @Amxx's suggested code which included a
I understand what you're saying here, but that's why I suggested adding
I think that guideline would be perfect, and that would then force you to add the |
PR #3170 introduced a breaking change which is also a bug in that
transferFrom()
now calls (via_spendAllowance()
) the overridable virtual functionallowance()
instead of accessing_allowances
directly.This is problematic where concrete implementations of ERC20 or ERC777 override virtual functions to adjust amounts so that the amounts exposed in the derived contract are a function of the amounts stored in the base contracts, and vice versa.
💻 Environment
@openzeppelin/contracts-upgradeable 4.5.1
Truffle
📝 Details
The general design of the ERC20 and ERC777 base contracts follows the pattern that public functions are virtual, with the default implementation that they call the corresponding internal virtual function. For example,
approve()
calls_approve()
, andburn()
calls_burn()
. Other functions in the base contract use the internal implementations of these functions so that the behaviour is deterministic. Concrete implementations may choose to override the internal function to change the internal behaviour. But they may also choose to override the public function so that internal behaviour is unaffected.This design is now broken by calling the public function
allowance()
in thetransformFrom()
function.Given the thin nature of the
allowance()
function, the proper approach would be to follow a similar pattern to the public virtualbalance()
function and its underlying_balances
variable. All reads of_balances
are done directly. No function callsbalance()
. Similarly, no function should callallowance()
. All reads of_allowances
should be done directly.🔢 Code to reproduce bug
f()
is a function that modifies the amount passed to it and returns the result.f_()
is the complement of that function.The text was updated successfully, but these errors were encountered: