From e93a9b2b69d82dd78b6076606260b464a0da6267 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 7 Aug 2026 14:03:48 +0100 Subject: [PATCH] fix: prevent BREAD from being credited to the token contract address --- README.md | 6 +++++ src/Bread.sol | 6 +++++ test/Bread.t.sol | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/README.md b/README.md index b539637..8fd3f77 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ The BREAD token v1 was deployed on Polygon PoS and the underlying yield generati - Since xDAI is native gas token on Gnosis Chain, users don't need both the gas token and DAI in order to onboard. - Since sDAI is the native yield source for xDAI, it's less variable yield and carries less risk than the Aave market. Yields are also generally much higher! +## Recipient restrictions + +BREAD cannot be credited to the token contract's own address. `transfer`, `transferFrom`, both `mint` overloads and `claimYield` revert with `InvalidRecipient()` when the recipient is the token contract, so tokens can't be stranded there by a mistyped or copy-pasted recipient. The check lives in `_update`, so it covers every balance-changing path. + +Native xDAI is a different case and is deliberately left alone. BREAD is deployed behind `EIP173ProxyWithReceive`, whose `receive()` accepts plain xDAI and never delegates to the implementation — that is a property of the already-deployed proxy and cannot be changed by upgrading the implementation. Rejecting native transfers outright would also break `burn()`, which is paid out by `wxDai.withdraw()` sending xDAI to the contract with empty calldata. So xDAI sent directly to the token contract is accepted and stays there; `rescueToken` only moves ERC20s. If it ever needs sweeping, a native rescue function can be added in a later implementation upgrade. + ## Setup clone and enter this repo then run: diff --git a/src/Bread.sol b/src/Bread.sol index 80d6f98..f53e0bd 100644 --- a/src/Bread.sol +++ b/src/Bread.sol @@ -37,6 +37,7 @@ contract Bread is error OnlyClaimers(); error MismatchArray(); error MismatchAmount(); + error InvalidRecipient(); IWXDAI public immutable wxDai; ISXDAI public immutable sexyDai; @@ -141,6 +142,11 @@ contract Bread is if (!success) revert NativeTransferFailed(); } + function _update(address from, address to, uint256 value) internal override { + if (to == address(this)) revert InvalidRecipient(); + super._update(from, to, value); + } + function transfer(address recipient, uint256 amount) public override returns (bool) { super.transfer(recipient, amount); if (this.delegates(recipient) == address(0)) _delegate(recipient, recipient); diff --git a/test/Bread.t.sol b/test/Bread.t.sol index e49cd10..e219101 100644 --- a/test/Bread.t.sol +++ b/test/Bread.t.sol @@ -476,5 +476,74 @@ contract BreadTest is Test { assertEq(breadToken.delegates(newUser), newUser); } + /// @dev BREAD credited to the token contract itself is rejected on every path: + /// transfer, transferFrom, both mints and claimYield. + function test_cannot_transfer_to_token_contract() public { + breadToken.mint{value: 1 ether}(address(this)); + + vm.expectRevert(Bread.InvalidRecipient.selector); + breadToken.transfer(address(breadToken), 1 ether); + + assertEq(breadToken.balanceOf(address(this)), 1 ether); + assertEq(breadToken.balanceOf(address(breadToken)), 0); + } + + function test_cannot_transferFrom_to_token_contract() public { + breadToken.mint{value: 1 ether}(address(this)); + breadToken.approve(address(0x42), 1 ether); + + vm.prank(address(0x42)); + vm.expectRevert(Bread.InvalidRecipient.selector); + breadToken.transferFrom(address(this), address(breadToken), 1 ether); + + assertEq(breadToken.balanceOf(address(this)), 1 ether); + assertEq(breadToken.balanceOf(address(breadToken)), 0); + } + + function test_cannot_mint_native_to_token_contract() public { + vm.expectRevert(Bread.InvalidRecipient.selector); + breadToken.mint{value: 1 ether}(address(breadToken)); + + assertEq(breadToken.balanceOf(address(breadToken)), 0); + } + + function test_cannot_mint_wxdai_to_token_contract() public { + vm.startPrank(randomHolder); + wxDai.approve(address(breadToken), 1 ether); + vm.expectRevert(Bread.InvalidRecipient.selector); + breadToken.mint(address(breadToken), 1 ether); + vm.stopPrank(); + + assertEq(breadToken.balanceOf(address(breadToken)), 0); + } + + function test_cannot_claim_yield_to_token_contract() public { + breadToken.mint{value: 1 ether}(address(this)); + vm.roll(32661497); + vm.prank(randomHolder); + wxDai.transfer(address(sexyDai), 10000 ether); + vm.roll(32661498); + assertGt(breadToken.yieldAccrued(), 0); + + vm.expectRevert(Bread.InvalidRecipient.selector); + breadToken.claimYield(1, address(breadToken)); + + assertEq(breadToken.balanceOf(address(breadToken)), 0); + } + + /// @dev burn() is paid out by wxDai.withdraw() sending xDAI to this contract, + /// so redemptions must keep working alongside the recipient check. + function test_burn_receives_native_after_recipient_check() public { + vm.deal(randomEOA, 1 ether); + vm.startPrank(randomEOA); + breadToken.mint{value: 1 ether}(randomEOA); + uint256 balBefore = randomEOA.balance; + breadToken.burn(1 ether, randomEOA); + vm.stopPrank(); + + assertEq(randomEOA.balance, balBefore + 1 ether); + assertEq(address(breadToken).balance, 0); + } + receive() external payable {} }