Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions src/contracts/SavingCircles.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}
48 changes: 40 additions & 8 deletions test/unit/SavingCirclesUnit.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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));
Expand All @@ -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);
Expand All @@ -847,15 +848,15 @@ 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);

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)
);
}

Expand All @@ -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);
Expand All @@ -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;
Expand Down
10 changes: 6 additions & 4 deletions test/utils/SavingCirclesTestBase.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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++;
Expand Down
Loading