diff --git a/.changeset/red-actors-shop.md b/.changeset/red-actors-shop.md new file mode 100644 index 000000000..0ee301e90 --- /dev/null +++ b/.changeset/red-actors-shop.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/core': patch +--- + +Added nonce to HypERC4626 diff --git a/solidity/contracts/mock/MockMailbox.sol b/solidity/contracts/mock/MockMailbox.sol index ad212dcef..c4b4b63e9 100644 --- a/solidity/contracts/mock/MockMailbox.sol +++ b/solidity/contracts/mock/MockMailbox.sol @@ -77,4 +77,9 @@ contract MockMailbox is Mailbox { Mailbox(address(this)).process{value: msg.value}("", _message); inboundProcessedNonce++; } + + function processInboundMessage(uint32 _nonce) public { + bytes memory _message = inboundMessages[_nonce]; + Mailbox(address(this)).process("", _message); + } } diff --git a/solidity/contracts/token/extensions/HypERC4626.sol b/solidity/contracts/token/extensions/HypERC4626.sol index 2252696fa..141b081ba 100644 --- a/solidity/contracts/token/extensions/HypERC4626.sol +++ b/solidity/contracts/token/extensions/HypERC4626.sol @@ -17,9 +17,12 @@ contract HypERC4626 is HypERC20 { using Message for bytes; using TokenMessage for bytes; + event ExchangeRateUpdated(uint256 newExchangeRate, uint32 rateUpdateNonce); + uint256 public constant PRECISION = 1e10; uint32 public immutable collateralDomain; uint256 public exchangeRate; // 1e10 + uint32 public previousNonce; constructor( uint8 _decimals, @@ -66,7 +69,16 @@ contract HypERC4626 is HypERC20 { bytes calldata _message ) internal virtual override { if (_origin == collateralDomain) { - exchangeRate = abi.decode(_message.metadata(), (uint256)); + (uint256 newExchangeRate, uint32 rateUpdateNonce) = abi.decode( + _message.metadata(), + (uint256, uint32) + ); + // only update if the nonce is greater than the previous nonce + if (rateUpdateNonce > previousNonce) { + exchangeRate = newExchangeRate; + previousNonce = rateUpdateNonce; + emit ExchangeRateUpdated(exchangeRate, rateUpdateNonce); + } } super._handle(_origin, _sender, _message); } diff --git a/solidity/contracts/token/extensions/HypERC4626Collateral.sol b/solidity/contracts/token/extensions/HypERC4626Collateral.sol index 8a084134c..0ae32ab2f 100644 --- a/solidity/contracts/token/extensions/HypERC4626Collateral.sol +++ b/solidity/contracts/token/extensions/HypERC4626Collateral.sol @@ -20,6 +20,8 @@ contract HypERC4626Collateral is HypERC20Collateral { uint256 public constant PRECISION = 1e10; bytes32 public constant NULL_RECIPIENT = 0x0000000000000000000000000000000000000000000000000000000000000001; + // Nonce for the rate update, to ensure sequential updates + uint32 public rateUpdateNonce; constructor( ERC4626 _vault, @@ -52,7 +54,12 @@ contract HypERC4626Collateral is HypERC20Collateral { vault.totalSupply(), Math.Rounding.Down ); - bytes memory _tokenMetadata = abi.encode(_exchangeRate); + + rateUpdateNonce++; + bytes memory _tokenMetadata = abi.encode( + _exchangeRate, + rateUpdateNonce + ); bytes memory _tokenMessage = TokenMessage.format( _recipient, diff --git a/solidity/test/token/HypERC4626Test.t.sol b/solidity/test/token/HypERC4626Test.t.sol index d09e0aae6..338d39b75 100644 --- a/solidity/test/token/HypERC4626Test.t.sol +++ b/solidity/test/token/HypERC4626Test.t.sol @@ -43,6 +43,8 @@ contract HypERC4626CollateralTest is HypTokenTest { HypERC4626 remoteRebasingToken; HypERC4626 peerRebasingToken; + event ExchangeRateUpdated(uint256 newExchangeRate, uint32 rateUpdateNonce); + function setUp() public override { super.setUp(); @@ -95,6 +97,7 @@ contract HypERC4626CollateralTest is HypTokenTest { peerRebasingToken = HypERC4626(address(peerToken)); primaryToken.transfer(ALICE, 1000e18); + primaryToken.transfer(BOB, 1000e18); uint32[] memory domains = new uint32[](3); domains[0] = ORIGIN; @@ -146,6 +149,47 @@ contract HypERC4626CollateralTest is HypTokenTest { ); } + function testRebase_exchangeRateUpdateInSequence() public { + _performRemoteTransferWithoutExpectation(0, transferAmount); + _accrueYield(); + + uint256 exchangeRateInitially = remoteRebasingToken.exchangeRate(); + + vm.startPrank(BOB); + primaryToken.approve(address(localToken), transferAmount); + localToken.transferRemote( + DESTINATION, + BOB.addressToBytes32(), + transferAmount + ); + vm.stopPrank(); + + _accrueYield(); + + vm.startPrank(ALICE); + primaryToken.approve(address(localToken), transferAmount); + localToken.transferRemote( + DESTINATION, + BOB.addressToBytes32(), + transferAmount + ); + vm.stopPrank(); + + // process ALICE's transfer + + vm.expectEmit(true, true, true, true); + emit ExchangeRateUpdated(10721400472, 3); + remoteMailbox.processInboundMessage(2); + uint256 exchangeRateBefore = remoteRebasingToken.exchangeRate(); + + // process BOB's transfer + remoteMailbox.processInboundMessage(1); + uint256 exchangeRateAfter = remoteRebasingToken.exchangeRate(); + + assertLt(exchangeRateInitially, exchangeRateBefore); // updates bc nonce=2 is after nonce=0 + assertEq(exchangeRateBefore, exchangeRateAfter); // doesn't update bc nonce=1 is before nonce=0 + } + function testSyntheticTransfers_withRebase() public { _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -173,6 +217,7 @@ contract HypERC4626CollateralTest is HypTokenTest { } function testWithdrawalWithoutYield() public { + uint256 bobPrimaryBefore = primaryToken.balanceOf(BOB); _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -183,10 +228,14 @@ contract HypERC4626CollateralTest is HypTokenTest { transferAmount ); localMailbox.processNextInboundMessage(); - assertEq(primaryToken.balanceOf(BOB), transferAmount); + assertEq( + primaryToken.balanceOf(BOB) - bobPrimaryBefore, + transferAmount + ); } function testWithdrawalWithYield() public { + uint256 bobPrimaryBefore = primaryToken.balanceOf(BOB); _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -205,13 +254,22 @@ contract HypERC4626CollateralTest is HypTokenTest { uint256 _expectedBal = transferAmount + _discountedYield(); // BOB gets the yield even though it didn't rebase - assertApproxEqRelDecimal(_bobBal, _expectedBal, 1e14, 0); - assertTrue(_bobBal < _expectedBal, "Transfer remote should round down"); + assertApproxEqRelDecimal( + _bobBal - bobPrimaryBefore, + _expectedBal, + 1e14, + 0 + ); + assertTrue( + _bobBal - bobPrimaryBefore < _expectedBal, + "Transfer remote should round down" + ); assertEq(vault.accumulatedFees(), YIELD / 10); } function testWithdrawalAfterYield() public { + uint256 bobPrimaryBefore = primaryToken.balanceOf(BOB); _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -230,7 +288,7 @@ contract HypERC4626CollateralTest is HypTokenTest { ); localMailbox.processNextInboundMessage(); assertApproxEqRelDecimal( - primaryToken.balanceOf(BOB), + primaryToken.balanceOf(BOB) - bobPrimaryBefore, transferAmount + _discountedYield(), 1e14, 0 @@ -287,6 +345,7 @@ contract HypERC4626CollateralTest is HypTokenTest { } function testWithdrawalAfterDrawdown() public { + uint256 bobPrimaryBefore = primaryToken.balanceOf(BOB); _performRemoteTransferWithoutExpectation(0, transferAmount); assertEq(remoteToken.balanceOf(BOB), transferAmount); @@ -306,7 +365,7 @@ contract HypERC4626CollateralTest is HypTokenTest { ); localMailbox.processNextInboundMessage(); assertApproxEqRelDecimal( - primaryToken.balanceOf(BOB), + primaryToken.balanceOf(BOB) - bobPrimaryBefore, transferAmount - drawdown, 1e14, 0