From 2da450cfeb4e72bd64ce500353d2e7b5d0198a3e Mon Sep 17 00:00:00 2001 From: Daniel Helm Date: Wed, 10 Jun 2026 20:45:48 -0500 Subject: [PATCH] feat: add INativeDoge token-duality interface and test shim Celo-style "token duality" - an ERC20-shaped token whose balances ARE native account balances (balanceOf == address.balance, transfers move native value without executing recipient code) - cannot be implemented in pure EVM: moving another account's native balance requires a protocol-level native-transfer precompile, plus a predeploy token and prover support. That protocol work does not exist on DogeOS yet. This PR ships what contracts development needs in the meantime: - src/dogeos/INativeDoge.sol: the interface payment/intent contracts should code against, with the production semantics specified (native balances, no recipient-code execution on transfer, storage allowances) and the protocol prerequisites documented. - src/test/mocks/DualityDogeShim.sol: TEST-ONLY functional shim implementing the interface inside Foundry via vm.deal (the in-test stand-in for the future precompile). Faithful to production semantics including the no-recipient-hooks property; explicitly NOT faithful for gas. Lives under src/test/mocks so hardhat (which cannot resolve forge-std) never compiles it and slither/coverage exclude it. - src/test/dogeos/DualityDogeShim.t.sol: 11 tests covering native balance mirroring, transfer/transferFrom/allowance semantics (incl. infinite-allowance no-decrement), insufficient balance and allowance reverts, the reverting-receive()-cannot-block-transfer property, and a conservation fuzz. Co-Authored-By: Claude Fable 5 --- src/dogeos/INativeDoge.sol | 63 +++++++++++++ src/test/dogeos/DualityDogeShim.t.sol | 130 ++++++++++++++++++++++++++ src/test/mocks/DualityDogeShim.sol | 110 ++++++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 src/dogeos/INativeDoge.sol create mode 100644 src/test/dogeos/DualityDogeShim.t.sol create mode 100644 src/test/mocks/DualityDogeShim.sol diff --git a/src/dogeos/INativeDoge.sol b/src/dogeos/INativeDoge.sol new file mode 100644 index 00000000..8962df98 --- /dev/null +++ b/src/dogeos/INativeDoge.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT + +pragma solidity =0.8.24; + +/** + * @title INativeDoge + * @notice ERC-20-shaped interface for "token duality" on the native DOGE asset: + * a token contract whose balances ARE native account balances, following the + * Celo model (CELO/GoldToken), rather than a WETH-style wrapped token with + * deposit/withdraw and storage balances. + * + * @dev Production semantics (what an implementation must guarantee): + * - `balanceOf(a)` equals `a.balance` at all times; there are no storage balances. + * - `transfer`/`transferFrom` move NATIVE balance from `from` to `to` WITHOUT + * executing code on the recipient: no `receive()`/`fallback()` is triggered + * (Celo's transfer precompile behaves the same way). Contracts that rely on + * value-call hooks must not assume duality transfers trigger them. + * - Allowances are ordinary contract storage. + * + * A real implementation CANNOT be written in pure EVM: moving another account's + * native balance requires protocol support. Enabling this on DogeOS needs: + * 1. a native-transfer precompile in l2geth/revm (callable only by the duality + * token contract), with prover/stateless-verifier support; + * 2. a predeploy token contract at a canonical DogeOSPredeploy address that + * calls that precompile; + * 3. genesis integration for the predeploy. + * None of that exists yet - it is protocol work tracked separately. This + * interface and the cheatcode-backed `DualityDogeShim` (src/test/mocks/) exist + * so contracts can be developed and functionally tested against duality + * semantics today. Do NOT use the shim's gas numbers; cheatcodes distort them. + */ +interface INativeDoge { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function name() external view returns (string memory); + + function symbol() external view returns (string memory); + + function decimals() external view returns (uint8); + + /// @notice Total native supply. Implementation-defined; the test shim returns 0. + function totalSupply() external view returns (uint256); + + /// @notice The native balance of `account` (identical to `account.balance`). + function balanceOf(address account) external view returns (uint256); + + /// @notice Moves `amount` of native DOGE from the caller to `to` without + /// executing recipient code. + function transfer(address to, uint256 amount) external returns (bool); + + function allowance(address owner, address spender) external view returns (uint256); + + function approve(address spender, uint256 amount) external returns (bool); + + /// @notice Moves `amount` of native DOGE from `from` to `to` using the caller's + /// allowance, without executing recipient code. + function transferFrom( + address from, + address to, + uint256 amount + ) external returns (bool); +} diff --git a/src/test/dogeos/DualityDogeShim.t.sol b/src/test/dogeos/DualityDogeShim.t.sol new file mode 100644 index 00000000..8adaa975 --- /dev/null +++ b/src/test/dogeos/DualityDogeShim.t.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT + +pragma solidity =0.8.24; + +import {Test} from "forge-std/Test.sol"; + +import {INativeDoge} from "../../dogeos/INativeDoge.sol"; +import {DualityDogeShim} from "../mocks/DualityDogeShim.sol"; + +/// @dev Recipient with a reverting receive(): duality transfers must NOT execute +/// recipient code, so sending to this contract must still succeed. +contract RevertingReceiver { + receive() external payable { + revert("no thanks"); + } +} + +contract DualityDogeShimTest is Test { + INativeDoge internal _doge; + + address internal _alice = makeAddr("alice"); + address internal _bob = makeAddr("bob"); + + function setUp() public { + _doge = new DualityDogeShim(); + vm.deal(_alice, 100 ether); + } + + function testBalanceOfIsNativeBalance() external { + assertEq(_doge.balanceOf(_alice), _alice.balance); + assertEq(_doge.balanceOf(_alice), 100 ether); + vm.deal(_bob, 7 ether); + assertEq(_doge.balanceOf(_bob), 7 ether); + } + + function testTransferMovesNativeBalance() external { + vm.prank(_alice); + vm.expectEmit(true, true, false, true); + emit INativeDoge.Transfer(_alice, _bob, 30 ether); + assertTrue(_doge.transfer(_bob, 30 ether)); + + assertEq(_alice.balance, 70 ether); + assertEq(_bob.balance, 30 ether); + } + + function testTransferToSelfIsNoop() external { + vm.prank(_alice); + _doge.transfer(_alice, 40 ether); + assertEq(_alice.balance, 100 ether); + } + + function testTransferZeroAmount() external { + vm.prank(_alice); + assertTrue(_doge.transfer(_bob, 0)); + assertEq(_alice.balance, 100 ether); + assertEq(_bob.balance, 0); + } + + function testTransferInsufficientBalanceReverts() external { + vm.prank(_alice); + vm.expectRevert( + abi.encodeWithSelector(DualityDogeShim.ErrorInsufficientBalance.selector, _alice, 100 ether, 101 ether) + ); + _doge.transfer(_bob, 101 ether); + } + + /// @dev Production duality moves balance without executing recipient code; the + /// shim must match - a reverting receive() cannot block the transfer. + function testTransferDoesNotExecuteRecipientCode() external { + RevertingReceiver receiver = new RevertingReceiver(); + vm.prank(_alice); + assertTrue(_doge.transfer(address(receiver), 5 ether)); + assertEq(address(receiver).balance, 5 ether); + } + + function testApproveAndTransferFrom() external { + vm.prank(_alice); + _doge.approve(_bob, 25 ether); + assertEq(_doge.allowance(_alice, _bob), 25 ether); + + vm.prank(_bob); + assertTrue(_doge.transferFrom(_alice, _bob, 10 ether)); + + assertEq(_alice.balance, 90 ether); + assertEq(_bob.balance, 10 ether); + assertEq(_doge.allowance(_alice, _bob), 15 ether); + } + + function testTransferFromInfiniteAllowanceNotDecremented() external { + vm.prank(_alice); + _doge.approve(_bob, type(uint256).max); + + vm.prank(_bob); + _doge.transferFrom(_alice, _bob, 10 ether); + assertEq(_doge.allowance(_alice, _bob), type(uint256).max); + } + + function testTransferFromInsufficientAllowanceReverts() external { + vm.prank(_alice); + _doge.approve(_bob, 5 ether); + + vm.prank(_bob); + vm.expectRevert( + abi.encodeWithSelector(DualityDogeShim.ErrorInsufficientAllowance.selector, _alice, _bob, 5 ether, 6 ether) + ); + _doge.transferFrom(_alice, _bob, 6 ether); + } + + function testTransferFromInsufficientBalanceReverts() external { + vm.prank(_alice); + _doge.approve(_bob, type(uint256).max); + + vm.prank(_bob); + vm.expectRevert( + abi.encodeWithSelector(DualityDogeShim.ErrorInsufficientBalance.selector, _alice, 100 ether, 101 ether) + ); + _doge.transferFrom(_alice, _bob, 101 ether); + } + + function testFuzz_TransferConservesTotal(uint256 amount) external { + amount = bound(amount, 0, 100 ether); + uint256 totalBefore = _alice.balance + _bob.balance; + + vm.prank(_alice); + _doge.transfer(_bob, amount); + + assertEq(_alice.balance + _bob.balance, totalBefore); + assertEq(_bob.balance, amount); + } +} diff --git a/src/test/mocks/DualityDogeShim.sol b/src/test/mocks/DualityDogeShim.sol new file mode 100644 index 00000000..e81060a8 --- /dev/null +++ b/src/test/mocks/DualityDogeShim.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT + +pragma solidity =0.8.24; + +import {Vm} from "forge-std/Vm.sol"; + +import {INativeDoge} from "../../dogeos/INativeDoge.sol"; + +/** + * @title DualityDogeShim + * @notice TEST-ONLY functional shim for Celo-style token duality on native DOGE. + * @dev Implements {INativeDoge} inside Foundry tests by moving native balances with + * the `vm.deal` cheatcode - the in-EVM stand-in for the native-transfer + * precompile a real implementation requires (see INativeDoge for the protocol + * work involved). + * + * Faithful to production semantics: + * - `balanceOf` IS the native balance; there are no storage balances. + * - transfers do NOT execute recipient code (no receive()/fallback hooks), + * matching the precompile behavior. + * - allowances are ordinary storage; infinite allowance is not decremented. + * + * NOT faithful: gas. Cheatcode calls have arbitrary gas behavior - never use + * this shim for gas measurements. + * + * Lives under src/test/mocks/ so hardhat (which cannot resolve forge-std) never + * compiles it; it is also excluded from slither/coverage. It cannot work outside + * a Foundry test VM: the cheatcode address only answers there. + */ +contract DualityDogeShim is INativeDoge { + /// @dev Foundry cheatcode VM (`address(uint160(uint256(keccak256("hevm cheat code"))))`). + Vm private constant VM = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + + error ErrorInsufficientBalance(address from, uint256 balance, uint256 amount); + error ErrorInsufficientAllowance(address owner, address spender, uint256 allowance, uint256 amount); + + mapping(address => mapping(address => uint256)) private _allowances; + + function name() external pure returns (string memory) { + return "Dogecoin"; + } + + function symbol() external pure returns (string memory) { + return "DOGE"; + } + + function decimals() external pure returns (uint8) { + return 18; + } + + /// @inheritdoc INativeDoge + function totalSupply() external pure returns (uint256) { + return 0; + } + + /// @inheritdoc INativeDoge + function balanceOf(address account) external view returns (uint256) { + return account.balance; + } + + /// @inheritdoc INativeDoge + function transfer(address to, uint256 amount) external returns (bool) { + _move(msg.sender, to, amount); + return true; + } + + /// @inheritdoc INativeDoge + function allowance(address owner, address spender) external view returns (uint256) { + return _allowances[owner][spender]; + } + + /// @inheritdoc INativeDoge + function approve(address spender, uint256 amount) external returns (bool) { + _allowances[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + /// @inheritdoc INativeDoge + function transferFrom( + address from, + address to, + uint256 amount + ) external returns (bool) { + uint256 allowed = _allowances[from][msg.sender]; + if (allowed != type(uint256).max) { + if (allowed < amount) { + revert ErrorInsufficientAllowance(from, msg.sender, allowed, amount); + } + _allowances[from][msg.sender] = allowed - amount; + } + _move(from, to, amount); + return true; + } + + /// @dev Native balance move via cheatcode; order handles to == from correctly. + function _move( + address from, + address to, + uint256 amount + ) private { + uint256 fromBalance = from.balance; + if (fromBalance < amount) { + revert ErrorInsufficientBalance(from, fromBalance, amount); + } + VM.deal(from, fromBalance - amount); + VM.deal(to, to.balance + amount); + emit Transfer(from, to, amount); + } +}