refactor!: unify the two _transfer implementations - #347
Draft
zguesmi wants to merge 1 commit into
Draft
Conversation
`_transfer` existed twice over the same `$.m_balances` storage with different
failure behavior: the escrow one in `contracts/abstract/IexecEscrow.sol`
reverted with a reason string, the ERC-20 one in
`contracts/facets/IexecEscrowTokenFacet.sol` (`_transferUnchecked`) reverted
with no reason at all. An over-balance transfer therefore reverted with a
message on one path and silently on the other.
The escrow implementation wins and becomes the single one. It is now
`internal` and `IexecEscrowTokenFacet` inherits it, so `transfer` and
`transferFrom` route through it. `_transferUnchecked` and its `_transfer`
wrapper are deleted, along with their `TEMPORARY MIGRATION FIX` /
`TODO: Remove this in the next major version` comment.
BREAKING CHANGE: the revert reasons of the ERC-20 entry points change.
- `transfer` / `transferFrom` over balance: reverted with no reason, now
reverts with `IexecEscrow: Transfer amount exceeds balance`.
- `transfer` / `transferFrom` from the zero address:
`ERC20: transfer from the zero address` becomes
`IexecEscrow: Transfer from empty address`.
- `transfer` / `transferFrom` to the zero address:
`ERC20: transfer to the zero address` becomes
`IexecEscrow: Transfer to empty address`.
Callers must expect a reason string on every failed balance move. No
selector, no event and no ABI entry changes.
The remaining `TEMPORARY MIGRATION FIX` markers on the allowance checks in
`transferFrom` and `decreaseAllowance`, and on `_burn`, are out of scope and
left untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## pr/a-dedupe-events #347 +/- ##
======================================================
- Coverage 99.54% 99.53% -0.01%
======================================================
Files 31 31
Lines 1095 1083 -12
Branches 212 220 +8
======================================================
- Hits 1090 1078 -12
Misses 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Aug 31, 2026
zguesmi
marked this pull request as draft
August 31, 2026 10:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #346.
The problem
_transferexisted twice over the same$.m_balancesstorage, with different failure behavior:contracts/abstract/IexecEscrow.solcontracts/facets/IexecEscrowTokenFacet.sol(_transferUnchecked)require(value <= fromBalance, "IexecEscrow: Transfer amount exceeds balance")if (senderBalance < amount) { revert(); }— no reason"IexecEscrow: Transfer from/to empty address""ERC20: transfer from/to the zero address"uncheckedTEMPORARY MIGRATION FIX ... TODO: Remove this in the next major versionSo the same over-balance mistake reverted with a message through
matchOrders/contributeand silently throughtransfer/transferFrom.Which behavior won, and why
The escrow implementation wins: reason strings on all three checks,
uncheckedarithmetic. It is nowinternal, andIexecEscrowTokenFacetinheritsIexecEscrowinstead of carrying its own copy.Reasoning:
revert()was explicitly temporary. The comment says "remove in the next major version" and this train is the next major (feat!: remove native mode contracts #343 and feat!: removesetNamefunction and related tests for reverse registration #344 are alreadyfeat!, release-please has 7.0.0 drafted). Keeping it means carrying it another whole major.IexecEscrow: Transfer amount exceeds balancemessage is the high-traffic one — it is what an under-funded requester or scheduler sees onmatchOrders,contributeandfinalize— and it does not move. What changes is the ERC-20 zero-address wording, which is close to unreachable in practice (transferfrom the zero address requiresmsg.sender == address(0)), and the bare revert, which carried no information to break.uncheckedis free here. The balance is checked immediately above, and the total supply is capped with no minting in this path, so neither underflow nor overflow is reachable.Cost paid: the
IexecEscrow:prefix now appears on ERC-20 revert reasons. That is cosmetic — preserving the exact strings that production and 9 test assertions already depend on beat re-prefixing them, which would have broken both sides at once.Host contract: not
FacetBaseThe issue suggested
contracts/abstract/FacetBase.solas the host. I did not use it, for one measurable reason:FacetBaseis inherited by all 12 facets, and hosting an event-emitting balance mover there requires theTransferevent declaration to sit inFacetBasetoo. 8 of the 12 facets have noTransferevent in their published ABI today (IexecAccessorsABILegacyFacet,IexecCategoryManagerFacet,IexecConfigurationFacet,IexecConfigurationExtraFacet,IexecOrderManagementFacet,IexecPocoAccessorsFacet,IexecPocoBoostAccessorsFacet,IexecRelayFacet) and would have gained one — 8 published-ABI changes, plus the ability to move balances handed to every facet.Keeping the implementation in
IexecEscrowgives the same single implementation with zero ABI change anywhere:IexecEscrowTokenFacetalready carries all six events (Approval,Lock,Reward,Seize,Transfer,Unlock) in its ABI.The facet's base list needed reordering (
FacetBase, IexecERC20, IexecTokenSpender, IexecEscrowToken, IexecEscrow) to keep C3 linearization solvable after #346 introduced the shared event interfaces.Migration note
Callers must expect a reason string on every failed balance move:
transfer/transferFromover balanceIexecEscrow: Transfer amount exceeds balancetransfer/transferFromfrom the zero addressERC20: transfer from the zero addressIexecEscrow: Transfer from empty addresstransfer/transferFromto the zero addressERC20: transfer to the zero addressIexecEscrow: Transfer to empty addressUnchanged: every function selector, every event, every ABI entry, and the
ERC20: approve ...messages (_approvewas not touched).Anything matching on the old strings — SDK error handling, monitoring, integration tests — needs updating. The subgraph is unaffected: no event and no topic0 moved.
Tests
Six assertions in
test/byContract/IexecERC20/IexecERC20.test.tswere updated to the new strings, deliberately and not by loosening them: tworevertedWithoutReason()calls becamerevertedWith('IexecEscrow: Transfer amount exceeds balance'), four zero-address messages were re-pointed. The nine existingIexecEscrow: Transfer amount exceeds balanceassertions in the PoCo, Boost and escrow suites pass untouched.Left as
revertedWithoutReason()on purpose, since they are allowance and burn paths and out of this PR's scope:transferFromwith too low an allowance,decreaseAllowancebelow zero, andwithdrawover balance (_burn). TheirTEMPORARY MIGRATION FIXmarkers remain.Verification
npm run buildnpm run check-storage-layoutnpm run docdocs/solidity/index.mdnpm run sol-to-umlnpx tsc --noEmitnpm testnpm run format:checkabis/diff vs baseIexecEscrowTokenFacetruntime bytecode: 6608 -> 6695 bytes (+87). That is the third revert string being added; the inheritedlock/unlock/reward/seizeinternals are unreachable from this facet and were eliminated by the optimizer, which would otherwise have cost several hundred bytes.