diff --git a/src/contracts/SavingCircles.sol b/src/contracts/SavingCircles.sol index 27257ae..b8b03f7 100644 --- a/src/contracts/SavingCircles.sol +++ b/src/contracts/SavingCircles.sol @@ -31,7 +31,7 @@ contract SavingCircles is ISavingCircles, ReentrancyGuardUpgradeable, OwnableUpg uint256 public constant MINIMUM_MEMBERS = 2; string private constant _EIP712_NAME = 'StacksInvite'; string private constant _EIP712_VERSION = '1'; - bytes32 private constant _INVITE_TYPEHASH = keccak256('Invite(uint256 id,uint256 nonce)'); + bytes32 private constant _INVITE_TYPEHASH = keccak256('Invite(uint256 id,uint256 nonce,address recipient)'); uint256 public nextId; mapping(uint256 id => Circle circle) public circles; @@ -184,7 +184,7 @@ contract SavingCircles is ISavingCircles, ReentrancyGuardUpgradeable, OwnableUpg if (isMember[_id][msg.sender]) revert AlreadyMember(); if (isActive[_id]) revert AlreadyActive(); - bytes32 _digest = _hashInvite(_id, _nonce); + bytes32 _digest = _hashInvite(_id, _nonce, msg.sender); address _signer = ECDSA.recover(_digest, _signature); if (_signer != _circle.owner) revert InvalidSigner(); @@ -530,11 +530,13 @@ contract SavingCircles is ISavingCircles, ReentrancyGuardUpgradeable, OwnableUpg } /** - * @dev Computes the EIP-712 hash for an invite - * @notice _INVITE_TYPEHASH is keccak256('Invite(uint256 id,uint256 nonce)') + * @dev Computes the EIP-712 hash for an address-bound invite + * @notice _INVITE_TYPEHASH is keccak256('Invite(uint256 id,uint256 nonce,address recipient)') + * The recipient field binds the invite to a specific address, preventing front-running + * or interception by a third party who obtains the signed invite. */ - function _hashInvite(uint256 _id, uint256 _nonce) private view returns (bytes32) { - bytes32 _structHash = keccak256(abi.encode(_INVITE_TYPEHASH, _id, _nonce)); + function _hashInvite(uint256 _id, uint256 _nonce, address _recipient) private view returns (bytes32) { + bytes32 _structHash = keccak256(abi.encode(_INVITE_TYPEHASH, _id, _nonce, _recipient)); return _hashTypedDataV4(_structHash); } } diff --git a/test/unit/SavingCirclesUnit.t.sol b/test/unit/SavingCirclesUnit.t.sol index f582ed8..21cda13 100644 --- a/test/unit/SavingCirclesUnit.t.sol +++ b/test/unit/SavingCirclesUnit.t.sol @@ -802,7 +802,7 @@ contract SavingCirclesUnit is SavingCirclesTestBase { uint256 circleId = _createInviteCircle(); uint256 nonce = 1; address invitee = STRANGER; - bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey); + bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey, invitee); vm.prank(invitee); vm.expectEmit(true, true, true, true); @@ -823,7 +823,8 @@ contract SavingCirclesUnit is SavingCirclesTestBase { uint256 circleId = _createInviteCircle(); uint256 otherCircleId = _createInviteCircle(); uint256 nonce = 1; - bytes memory signatureForOtherCircle = _signInvite(address(savingCircles), otherCircleId, nonce, _ownerPrivateKey); + bytes memory signatureForOtherCircle = + _signInvite(address(savingCircles), otherCircleId, nonce, _ownerPrivateKey, STRANGER); vm.prank(STRANGER); vm.expectRevert(abi.encodeWithSelector(ISavingCircles.InvalidSigner.selector)); @@ -833,7 +834,7 @@ contract SavingCirclesUnit is SavingCirclesTestBase { function test_RedeemInvitePreventsNonceReplay() external { uint256 circleId = _createInviteCircle(); uint256 nonce = 1; - bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey); + bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey, STRANGER); vm.prank(STRANGER); savingCircles.redeemInvite(circleId, nonce, signature); @@ -847,7 +848,7 @@ contract SavingCirclesUnit is SavingCirclesTestBase { function test_RedeemInviteRejectsExistingMember() external { uint256 circleId = _createInviteCircle(); uint256 nonce = 1; - bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey); + bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey, alice); vm.prank(alice); savingCircles.redeemInvite(circleId, nonce, signature); @@ -855,7 +856,7 @@ contract SavingCirclesUnit is SavingCirclesTestBase { vm.prank(alice); vm.expectRevert(abi.encodeWithSelector(ISavingCircles.AlreadyMember.selector)); savingCircles.redeemInvite( - circleId, nonce + 1, _signInvite(address(savingCircles), circleId, nonce + 1, _ownerPrivateKey) + circleId, nonce + 1, _signInvite(address(savingCircles), circleId, nonce + 1, _ownerPrivateKey, alice) ); } @@ -867,7 +868,7 @@ contract SavingCirclesUnit is SavingCirclesTestBase { function test_RedeemInviteRejectsActiveCircle() external { uint256 circleId = _createInviteCircle(); uint256 nonce = 1; - bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey); + bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey, alice); vm.prank(alice); savingCircles.redeemInvite(circleId, nonce, signature); @@ -878,20 +879,51 @@ contract SavingCirclesUnit is SavingCirclesTestBase { vm.prank(STRANGER); vm.expectRevert(abi.encodeWithSelector(ISavingCircles.AlreadyActive.selector)); savingCircles.redeemInvite( - circleId, nonce + 1, _signInvite(address(savingCircles), circleId, nonce + 1, _ownerPrivateKey) + circleId, nonce + 1, _signInvite(address(savingCircles), circleId, nonce + 1, _ownerPrivateKey, STRANGER) ); } function test_RedeemInviteRejectsNonOwnerSignature() external { uint256 circleId = _createInviteCircle(); uint256 nonce = 1; - bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _nonOwnerPrivateKey); + bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _nonOwnerPrivateKey, STRANGER); vm.prank(STRANGER); vm.expectRevert(abi.encodeWithSelector(ISavingCircles.InvalidSigner.selector)); savingCircles.redeemInvite(circleId, nonce, signature); } + /// @dev Address-bound invite: a signature issued for `alice` cannot be redeemed by a + /// different address (STRANGER). The signature digest encodes the recipient, so + /// recovering the signer from a mismatched sender yields a different address, + /// triggering InvalidSigner. + function test_RedeemInviteRejectsWrongSender() external { + uint256 circleId = _createInviteCircle(); + uint256 nonce = 1; + // Sign the invite specifically for alice + bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey, alice); + + // STRANGER attempts to redeem an invite that was bound to alice — must revert + vm.prank(STRANGER); + vm.expectRevert(abi.encodeWithSelector(ISavingCircles.InvalidSigner.selector)); + savingCircles.redeemInvite(circleId, nonce, signature); + } + + /// @dev Address-bound invite: the correct recipient can always redeem their own invite. + function test_RedeemInviteAcceptsCorrectRecipient() external { + uint256 circleId = _createInviteCircle(); + uint256 nonce = 1; + // Sign the invite specifically for alice + bytes memory signature = _signInvite(address(savingCircles), circleId, nonce, _ownerPrivateKey, alice); + + vm.prank(alice); + vm.expectEmit(true, true, true, true); + emit ISavingCircles.InviteRedeemed(circleId, alice); + savingCircles.redeemInvite(circleId, nonce, signature); + + assertTrue(savingCircles.isMember(circleId, alice)); + } + function test_StartWhenMembersCountIsLessThanTwo() external { address[] memory _oneMember = new address[](1); _oneMember[0] = owner; diff --git a/test/utils/SavingCirclesTestBase.t.sol b/test/utils/SavingCirclesTestBase.t.sol index 40eb955..57b27ee 100644 --- a/test/utils/SavingCirclesTestBase.t.sol +++ b/test/utils/SavingCirclesTestBase.t.sol @@ -27,10 +27,12 @@ abstract contract SavingCirclesTestBase is Test { address _savingCircles, uint256 _circleId, uint256 _nonce, - uint256 _signerKey + uint256 _signerKey, + address _recipient ) internal view returns (bytes memory) { - bytes32 inviteTypehash = 0xd86e498a74dbfe863d870d4811dddab9c7f3922d6c0d6656504984bd9a8607a3; - bytes32 structHash = keccak256(abi.encode(inviteTypehash, _circleId, _nonce)); + // keccak256('Invite(uint256 id,uint256 nonce,address recipient)') + bytes32 inviteTypehash = keccak256('Invite(uint256 id,uint256 nonce,address recipient)'); + bytes32 structHash = keccak256(abi.encode(inviteTypehash, _circleId, _nonce, _recipient)); bytes32 eip712DomainTypehash = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f; bytes32 inviteDomainNameHash = 0xf50d3e48fa87e894899f86eba14c57c836bc6ffddd68251a158269ffdadc0cb1; bytes32 inviteDomainVersionHash = 0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6; @@ -56,7 +58,7 @@ abstract contract SavingCirclesTestBase is Test { address member = _members[i]; if (member == _owner) continue; - bytes memory signature = _signInvite(address(_savingCircles), _circleId, nonce, _ownerKey); + bytes memory signature = _signInvite(address(_savingCircles), _circleId, nonce, _ownerKey, member); vm.prank(member); _savingCircles.redeemInvite(_circleId, nonce, signature); nonce++;