From 5d2ad7f2167f2ec589656c0fc6c08383057b3d3c Mon Sep 17 00:00:00 2001 From: Eric Shenghsiung Liu Date: Wed, 20 May 2026 15:52:51 -0400 Subject: [PATCH] IB20: add from/to/amount/nonce to Memo event for self-contained identity The previous Memo(bytes32 memo) required indexers to join to the preceding Transfer log via logIndex - 1, which breaks if any log is interleaved. The new signature is fully self-contained: Memo(address indexed from, address indexed to, uint256 amount, bytes32 indexed memo, uint256 nonce) nonce is a per-token monotonic counter (stored in MockB20Storage at MEMO_NONCE_OFFSET = 15) that increments on every *WithMemo call, guaranteeing uniqueness even when (from, to, amount, memo) repeats. No ERC is broken: Transfer (ERC-20) is unchanged; Memo is a custom extension not defined by any standard. --- src/interfaces/IB20.sol | 15 +++++++++----- test/lib/mocks/MockB20.sol | 20 +++++++++++++++---- test/lib/mocks/MockB20Storage.sol | 6 ++++++ test/unit/B20/memo/burnWithMemo.t.sol | 9 +++++---- test/unit/B20/memo/mintWithMemo.t.sol | 9 +++++---- test/unit/B20/memo/transferFromWithMemo.t.sol | 7 ++++--- test/unit/B20/memo/transferWithMemo.t.sol | 7 ++++--- 7 files changed, 50 insertions(+), 23 deletions(-) diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 257ded1..5cbeb0f 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -233,13 +233,18 @@ interface IB20 { /// @notice Emitted by `transferWithMemo`, `transferFromWithMemo`, /// `mintWithMemo`, and `burnWithMemo` immediately AFTER the - /// underlying ERC-20 `Transfer` event. The memo carries no - /// from/to/amount fields; indexers join it to the preceding - /// `Transfer` log via `(transactionHash, logIndex - 1)`. + /// underlying ERC-20 `Transfer` event. Carries `from`, `to`, + /// `amount`, and a contract-global `nonce` so it is fully + /// self-contained — no join to the preceding `Transfer` log + /// is required. `nonce` is a monotonically incrementing + /// counter scoped to this token; it uniquely identifies each + /// memo'd operation even when `(from, to, amount, memo)` repeats + /// (e.g. repeated payments of the same invoice). /// @dev Variants may emit this event from additional functions /// (e.g. `redeem` on a Security token); the event signature - /// is shared. - event Memo(bytes32 indexed memo); + /// is shared. For mint variants `from == address(0)`; for + /// burn variants `to == address(0)`. + event Memo(address indexed from, address indexed to, uint256 amount, bytes32 indexed memo, uint256 nonce); /// @notice Emitted by `burnBlocked` in addition to the standard /// `Transfer(from, address(0), amount)`. Distinguishes diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index db86c77..412caef 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -174,7 +174,10 @@ contract MockB20 is IB20 { function transferWithMemo(address to, uint256 amount, bytes32 memo) external returns (bool) { _transfer(msg.sender, to, amount); - emit Memo(memo); + MockB20Storage.Layout storage $ = MockB20Storage.layout(); + uint256 nonce = $.memoNonce; + unchecked { $.memoNonce = nonce + 1; } + emit Memo(msg.sender, to, amount, memo, nonce); return true; } @@ -187,7 +190,10 @@ contract MockB20 is IB20 { } } _transfer(from, to, amount); - emit Memo(memo); + MockB20Storage.Layout storage $ = MockB20Storage.layout(); + uint256 nonce = $.memoNonce; + unchecked { $.memoNonce = nonce + 1; } + emit Memo(from, to, amount, memo, nonce); return true; } @@ -217,7 +223,10 @@ contract MockB20 is IB20 { function mintWithMemo(address to, uint256 amount, bytes32 memo) external { _mint(to, amount); - emit Memo(memo); + MockB20Storage.Layout storage $ = MockB20Storage.layout(); + uint256 nonce = $.memoNonce; + unchecked { $.memoNonce = nonce + 1; } + emit Memo(address(0), to, amount, memo, nonce); } function burn(uint256 amount) external { @@ -226,7 +235,10 @@ contract MockB20 is IB20 { function burnWithMemo(uint256 amount, bytes32 memo) external { _burnSelf(msg.sender, amount); - emit Memo(memo); + MockB20Storage.Layout storage $ = MockB20Storage.layout(); + uint256 nonce = $.memoNonce; + unchecked { $.memoNonce = nonce + 1; } + emit Memo(msg.sender, address(0), amount, memo, nonce); } function burnBlocked(address from, uint256 amount) external { diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index 5e627d1..0aafc3a 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -89,6 +89,11 @@ library MockB20Storage { // gates (role / policy / pause checks). Token invariants (supply-cap // math, balance accounting) are NOT bypassed. bool initialized; + // ---------- Memo nonce ---------- + // Monotonically incrementing counter for memo'd transfers, mints, and + // burns. Incremented on each *WithMemo call so the Memo event carries + // a globally unique nonce even when (from, to, amount, memo) repeats. + uint256 memoNonce; } // keccak256(abi.encode(uint256(keccak256("base.b20")) - 1)) & ~bytes32(uint256(0xff)) @@ -127,6 +132,7 @@ library MockB20Storage { uint256 internal constant SUPPLY_CAP_OFFSET = 12; uint256 internal constant NONCES_OFFSET = 13; uint256 internal constant INITIALIZED_OFFSET = 14; + uint256 internal constant MEMO_NONCE_OFFSET = 15; /// @notice Absolute slot for a top-level field of `Layout`. /// @dev `STORAGE_LOCATION + offset`. The struct never crosses the diff --git a/test/unit/B20/memo/burnWithMemo.t.sol b/test/unit/B20/memo/burnWithMemo.t.sol index 5d23549..ccc0861 100644 --- a/test/unit/B20/memo/burnWithMemo.t.sol +++ b/test/unit/B20/memo/burnWithMemo.t.sol @@ -34,16 +34,17 @@ contract B20BurnWithMemoTest is B20Test { assertEq(token.totalSupply(), supplyBefore - amount, "supply decreased"); } - /// @notice Verifies burnWithMemo emits Transfer(caller, address(0), amount) then Memo(memo) - /// @dev Event ordering: Memo follows Transfer; canonical Memo test for the burn path + /// @notice Verifies burnWithMemo emits Transfer(caller, address(0), amount) then Memo + /// @dev Memo is self-contained: carries from, to, amount, memo, and a unique nonce. + /// nonce is 0 for the first memo'd operation on a freshly-etched token. function test_burnWithMemo_success_emitsTransferThenMemo(uint256 amount, bytes32 memo) public { _grantRole(B20Constants.BURN_ROLE, burner); _mint(burner, amount); vm.expectEmit(true, true, false, true, address(token)); emit IB20.Transfer(burner, address(0), amount); - vm.expectEmit(true, false, false, false, address(token)); - emit IB20.Memo(memo); + vm.expectEmit(address(token)); + emit IB20.Memo(burner, address(0), amount, memo, 0); vm.prank(burner); token.burnWithMemo(amount, memo); } diff --git a/test/unit/B20/memo/mintWithMemo.t.sol b/test/unit/B20/memo/mintWithMemo.t.sol index 9b66e26..5141e3b 100644 --- a/test/unit/B20/memo/mintWithMemo.t.sol +++ b/test/unit/B20/memo/mintWithMemo.t.sol @@ -37,16 +37,17 @@ contract B20MintWithMemoTest is B20Test { assertEq(token.totalSupply(), supplyBefore + amount, "supply increased"); } - /// @notice Verifies mintWithMemo emits Transfer(address(0), to, amount) then Memo(memo) - /// @dev Event ordering: Memo follows Transfer; canonical Memo test for the mint path + /// @notice Verifies mintWithMemo emits Transfer(address(0), to, amount) then Memo + /// @dev Memo is self-contained: carries from, to, amount, memo, and a unique nonce. + /// nonce is 0 for the first memo'd operation on a freshly-etched token. function test_mintWithMemo_success_emitsTransferThenMemo(address to, uint256 amount, bytes32 memo) public { _assumeValidActor(to); _grantRole(B20Constants.MINT_ROLE, minter); vm.expectEmit(true, true, false, true, address(token)); emit IB20.Transfer(address(0), to, amount); - vm.expectEmit(true, false, false, false, address(token)); - emit IB20.Memo(memo); + vm.expectEmit(address(token)); + emit IB20.Memo(address(0), to, amount, memo, 0); vm.prank(minter); token.mintWithMemo(to, amount, memo); } diff --git a/test/unit/B20/memo/transferFromWithMemo.t.sol b/test/unit/B20/memo/transferFromWithMemo.t.sol index a0313e8..c7a870c 100644 --- a/test/unit/B20/memo/transferFromWithMemo.t.sol +++ b/test/unit/B20/memo/transferFromWithMemo.t.sol @@ -57,7 +57,8 @@ contract B20TransferFromWithMemoTest is B20Test { } /// @notice Verifies transferFromWithMemo emits Transfer then Memo, in that order - /// @dev Memo is the second log; canonical Memo test for the transferFrom path + /// @dev Memo is self-contained: carries from, to, amount, memo, and a unique nonce. + /// nonce is 0 for the first memo'd operation on a freshly-etched token. function test_transferFromWithMemo_success_emitsTransferThenMemo( address caller, address from, @@ -76,8 +77,8 @@ contract B20TransferFromWithMemoTest is B20Test { vm.expectEmit(true, true, false, true, address(token)); emit IB20.Transfer(from, to, amount); - vm.expectEmit(true, false, false, false, address(token)); - emit IB20.Memo(memo); + vm.expectEmit(address(token)); + emit IB20.Memo(from, to, amount, memo, 0); vm.prank(caller); token.transferFromWithMemo(from, to, amount, memo); } diff --git a/test/unit/B20/memo/transferWithMemo.t.sol b/test/unit/B20/memo/transferWithMemo.t.sol index be80c45..488054d 100644 --- a/test/unit/B20/memo/transferWithMemo.t.sol +++ b/test/unit/B20/memo/transferWithMemo.t.sol @@ -43,7 +43,8 @@ contract B20TransferWithMemoTest is B20Test { } /// @notice Verifies transferWithMemo emits Transfer then Memo, in that order - /// @dev Memo is the second log; canonical Memo emission test for the transfer path + /// @dev Memo is self-contained: carries from, to, amount, memo, and a unique nonce. + /// nonce is 0 for the first memo'd operation on a freshly-etched token. function test_transferWithMemo_success_emitsTransferThenMemo( address from, address to, @@ -57,8 +58,8 @@ contract B20TransferWithMemoTest is B20Test { vm.expectEmit(true, true, false, true, address(token)); emit IB20.Transfer(from, to, amount); - vm.expectEmit(true, false, false, false, address(token)); - emit IB20.Memo(memo); + vm.expectEmit(address(token)); + emit IB20.Memo(from, to, amount, memo, 0); vm.prank(from); token.transferWithMemo(to, amount, memo); }