diff --git a/packages/contracts-periphery/contracts/foundry-tests/OptimistInviter.t.sol b/packages/contracts-periphery/contracts/foundry-tests/OptimistInviter.t.sol index 52e33c63b111f5bd5f84ea9b7ec10eed38a78ce7..c591053c560305569992995f24a8d757eb84721e 100644 --- a/packages/contracts-periphery/contracts/foundry-tests/OptimistInviter.t.sol +++ b/packages/contracts-periphery/contracts/foundry-tests/OptimistInviter.t.sol @@ -83,6 +83,10 @@ contract OptimistInviter_Initializer is Test { optimistInviterHelper = new OptimistInviterHelper(optimistInviter, "OptimistInviter"); } + function _passMinCommitmentPeriod() internal { + vm.warp(optimistInviter.MIN_COMMITMENT_PERIOD() + block.timestamp); + } + /** * @notice Returns a user's current invite count, as stored in the AttestationStation. */ @@ -174,7 +178,7 @@ contract OptimistInviter_Initializer is Test { optimistInviter.commitInvite(hashedSignature); // Check that the commitment was stored correctly - assertTrue(optimistInviter.commitments(hashedSignature)); + assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp); } /** @@ -196,7 +200,12 @@ contract OptimistInviter_Initializer is Test { _commitInviteAs(_claimer, signature); // The hash(claimer ++ signature) should be committed - assertEq(optimistInviter.commitments(keccak256(abi.encode(_claimer, signature))), true); + assertEq( + optimistInviter.commitmentTimestamps(keccak256(abi.encode(_claimer, signature))), + block.timestamp + ); + + _passMinCommitmentPeriod(); // OptimistInviter should issue a new attestation allowing claimer to mint vm.expectEmit(true, true, true, true, address(attestationStation)); @@ -324,7 +333,7 @@ contract OptimistInviterTest is OptimistInviter_Initializer { bytes32 hashedSignature = keccak256(abi.encode(sally, signature)); optimistInviter.commitInvite(hashedSignature); - assertTrue(optimistInviter.commitments(hashedSignature)); + assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp); } /** @@ -338,7 +347,24 @@ contract OptimistInviterTest is OptimistInviter_Initializer { bytes32 hashedSignature = keccak256(abi.encode(eve, signature)); optimistInviter.commitInvite(hashedSignature); - assertTrue(optimistInviter.commitments(hashedSignature)); + assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp); + } + + /** + * @notice Attempting to commit the same hash twice should revert. This prevents griefing. + */ + function test_commitInvite_committingSameHashTwice_reverts() external { + _grantInvitesTo(bob); + (, bytes memory signature) = _issueInviteAs(bobPrivateKey); + + vm.prank(sally); + bytes32 hashedSignature = keccak256(abi.encode(eve, signature)); + optimistInviter.commitInvite(hashedSignature); + + assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp); + + vm.expectRevert("OptimistInviter: commitment already made"); + optimistInviter.commitInvite(hashedSignature); } /** @@ -360,10 +386,9 @@ contract OptimistInviterTest is OptimistInviter_Initializer { bytes memory signature ) = _issueInviteAs(bobPrivateKey); - _commitInviteAs(sally, signature); - vm.prank(ted); optimistInviter.commitInvite(keccak256(abi.encode(sally, signature))); + _passMinCommitmentPeriod(); vm.expectEmit(true, true, true, true, address(attestationStation)); emit AttestationCreated( @@ -385,6 +410,23 @@ contract OptimistInviterTest is OptimistInviter_Initializer { assertFalse(_hasMintAttestation(eve)); } + function test_claimInvite_claimBeforeMinCommitmentPeriod_reverts() external { + _grantInvitesTo(bob); + ( + OptimistInviter.ClaimableInvite memory claimableInvite, + bytes memory signature + ) = _issueInviteAs(bobPrivateKey); + + _commitInviteAs(sally, signature); + + // Some time passes, but not enough to meet the minimum commitment period + vm.warp(block.timestamp + 10); + + vm.expectRevert("OptimistInviter: minimum commitment period has not elapsed yet"); + vm.prank(sally); + optimistInviter.claimInvite(sally, claimableInvite, signature); + } + /** * @notice Signature issued for previous versions of the contract should fail. */ @@ -402,6 +444,7 @@ contract OptimistInviterTest is OptimistInviter_Initializer { ); _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); vm.expectRevert("OptimistInviter: invalid signature"); vm.prank(sally); @@ -426,6 +469,7 @@ contract OptimistInviterTest is OptimistInviter_Initializer { ); _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); vm.expectRevert("OptimistInviter: invalid signature"); vm.prank(sally); @@ -450,6 +494,7 @@ contract OptimistInviterTest is OptimistInviter_Initializer { ); _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); vm.expectRevert("OptimistInviter: invalid signature"); vm.prank(sally); @@ -468,13 +513,14 @@ contract OptimistInviterTest is OptimistInviter_Initializer { ) = _issueThenClaimShouldSucceed(bobPrivateKey, sally); // Sally tries to claim the invite using the same signature - _commitInviteAs(sally, signature); vm.expectRevert("OptimistInviter: nonce has already been used"); vm.prank(sally); optimistInviter.claimInvite(sally, claimableInvite, signature); // Carol tries to claim the invite using the same signature _commitInviteAs(carol, signature); + _passMinCommitmentPeriod(); + vm.expectRevert("OptimistInviter: nonce has already been used"); vm.prank(carol); optimistInviter.claimInvite(carol, claimableInvite, signature); @@ -498,6 +544,7 @@ contract OptimistInviterTest is OptimistInviter_Initializer { // Sally tries to claim the invite _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); vm.expectEmit(true, true, true, true, address(attestationStation)); emit AttestationCreated( @@ -543,6 +590,8 @@ contract OptimistInviterTest is OptimistInviter_Initializer { _commitInviteAs(sally, bobSignature); _commitInviteAs(sally, carolSignature); + _passMinCommitmentPeriod(); + vm.expectRevert("OptimistInviter: invalid signature"); vm.prank(sally); optimistInviter.claimInvite(sally, bobClaimableInvite, carolSignature); @@ -560,6 +609,7 @@ contract OptimistInviterTest is OptimistInviter_Initializer { ) = _issueInviteAs(bobPrivateKey); _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); vm.expectRevert("OptimistInviter: issuer has no invites"); vm.prank(sally); @@ -588,6 +638,7 @@ contract OptimistInviterTest is OptimistInviter_Initializer { ) = _issueInviteAs(bobPrivateKey); _commitInviteAs(eve, signature4); + _passMinCommitmentPeriod(); vm.expectRevert("OptimistInviter: issuer has no invites"); vm.prank(eve); diff --git a/packages/contracts-periphery/contracts/testing/helpers/OptimistInviterHelper.sol b/packages/contracts-periphery/contracts/testing/helpers/OptimistInviterHelper.sol index d8b9a39ce59f7a0f014c58dc9b9971fd00034ce5..20dabb60944f3d1f852438071c3e8c0436bcb878 100644 --- a/packages/contracts-periphery/contracts/testing/helpers/OptimistInviterHelper.sol +++ b/packages/contracts-periphery/contracts/testing/helpers/OptimistInviterHelper.sol @@ -10,16 +10,33 @@ import { OptimistInviter } from "../../universal/op-nft/OptimistInviter.sol"; * Made this a separate contract instead of including in OptimistInviter.t.sol for reusability. */ contract OptimistInviterHelper { + /** + * @notice EIP712 typehash for the ClaimableInvite type. + */ bytes32 public constant CLAIMABLE_INVITE_TYPEHASH = keccak256("ClaimableInvite(address issuer,bytes32 nonce)"); + /** + * @notice EIP712 typehash for the EIP712Domain type that is included as part of the signature. + */ bytes32 public constant EIP712_DOMAIN_TYPEHASH = keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ); + /** + * @notice Address of OptimistInviter contract we are testing. + */ OptimistInviter public optimistInviter; + + /** + * @notice OptimistInviter contract name. Used to construct the EIP-712 domain. + */ string public name; + + /** + * @notice Keeps track of current nonce to generate new nonces for each invite. + */ uint256 public currentNonce; constructor(OptimistInviter _optimistInviter, string memory _name) { @@ -29,7 +46,11 @@ contract OptimistInviterHelper { } /** - * @notice Returns the hash of the struct ClaimableInvite + * @notice Returns the hash of the struct ClaimableInvite. + * + * @param _claimableInvite ClaimableInvite struct to hash. + * + * @return EIP-712 typed struct hash. */ function getClaimableInviteStructHash(OptimistInviter.ClaimableInvite memory _claimableInvite) public @@ -49,13 +70,19 @@ contract OptimistInviterHelper { /** * @notice Returns a bytes32 nonce that should change everytime. In practice, people should use * pseudorandom nonces. + * + * @return Nonce that should be used as part of ClaimableInvite. */ function consumeNonce() public returns (bytes32) { return bytes32(keccak256(abi.encode(currentNonce++))); } /** - * @notice Returns a ClaimableInvite with the issuer and current nonce + * @notice Returns a ClaimableInvite with the issuer and current nonce. + * + * @param _issuer Issuer to include in the ClaimableInvite. + * + * @return ClaimableInvite that can be hashed & signed. */ function getClaimableInviteWithNewNonce(address _issuer) public @@ -66,6 +93,10 @@ contract OptimistInviterHelper { /** * @notice Computes the EIP712 digest with default correct parameters. + * + * @param _claimableInvite ClaimableInvite struct to hash. + * + * @return EIP-712 compatible digest. */ function getDigest(OptimistInviter.ClaimableInvite calldata _claimableInvite) public @@ -85,6 +116,14 @@ contract OptimistInviterHelper { /** * @notice Computes the EIP712 digest with the given domain parameters. * Used for testing that different domain parameters fail. + * + * @param _claimableInvite ClaimableInvite struct to hash. + * @param _name Contract name to use in the EIP712 domain. + * @param _version Contract version to use in the EIP712 domain. + * @param _chainid Chain ID to use in the EIP712 domain. + * @param _verifyingContract Address to use in the EIP712 domain. + * + * @return EIP-712 compatible digest. */ function getDigestWithEIP712Domain( OptimistInviter.ClaimableInvite calldata _claimableInvite, diff --git a/packages/contracts-periphery/contracts/universal/op-nft/OptimistInviter.sol b/packages/contracts-periphery/contracts/universal/op-nft/OptimistInviter.sol index 55547cce1160fe3ce86fdf087af6888023e7b236..a67c131e23a90c25c9100782cc1446216adfe7d2 100644 --- a/packages/contracts-periphery/contracts/universal/op-nft/OptimistInviter.sol +++ b/packages/contracts-periphery/contracts/universal/op-nft/OptimistInviter.sol @@ -31,14 +31,15 @@ import { * 5) claimer commits the hash of the address they want to receive the invite on and the * received signature keccak256(abi.encode(addressToReceiveTo, receivedSignature)) * using the commitInvite function - * 6) claimer reveals the plaintext ClaimableInvite and the signature using the + * 6) claimer waits for the MIN_COMMITMENT_PERIOD to pass. + * 7) claimer reveals the plaintext ClaimableInvite and the signature using the * claimInvite function, receiving the "optimist.can-mint-from-invite" attestation */ contract OptimistInviter is Semver, EIP712Upgradeable { /** * @notice Emitted when an invite is claimed. * - * @param issuer Address that issued the signature. + * @param issuer Address that issued the signature. * @param claimer Address that claimed the invite. */ event InviteClaimed(address indexed issuer, address indexed claimer); @@ -78,6 +79,18 @@ contract OptimistInviter is Semver, EIP712Upgradeable { */ AttestationStation public immutable ATTESTATION_STATION; + /** + * @notice Minimum age of a commitment (in seconds) before it can be revealed using claimInvite. + * Currently set to 60 seconds. + * + * Prevents an attacker from front-running a commitment by taking the signature in the + * claimInvite call and quickly committing and claiming it before the the claimer's + * transaction succeeds. With this, frontrunning a commitment requires that an attacker + * be able to prevent the honest claimer's claimInvite transaction from being included + * for this long. + */ + uint256 public constant MIN_COMMITMENT_PERIOD = 60; + /** * @notice Struct that represents a claimable invite that will be signed by the issuer. * @@ -94,9 +107,9 @@ contract OptimistInviter is Semver, EIP712Upgradeable { } /** - * @notice Maps from hashes to whether or not they have been committed. + * @notice Maps from hashes to the timestamp when they were committed. */ - mapping(bytes32 => bool) public commitments; + mapping(bytes32 => uint256) public commitmentTimestamps; /** * @notice Maps from addresses to nonces to whether or not they have been used. @@ -127,7 +140,7 @@ contract OptimistInviter is Semver, EIP712Upgradeable { * claimed yet will no longer be accepted by the claimInvite function. Please make * sure to notify the issuers that they must re-issue their invite signatures. * - * @param _name Contract name + * @param _name Contract name. */ function initialize(string memory _name) public initializer { __EIP712_init(_name, EIP712_VERSION); @@ -181,12 +194,21 @@ contract OptimistInviter is Semver, EIP712Upgradeable { * scheme, anyone who is watching the mempool can take the signature being submitted * and front run the transaction to claim the invite to their own address. * + * The same commitment can only be made once, and the function reverts if the + * commitment has already been made. This prevents griefing where a malicious party can + * prevent the original claimer from being able to claimInvite. + * * * @param _commitment A hash of the claimer and signature concatenated. * keccak256(abi.encode(_claimer, _signature)) */ function commitInvite(bytes32 _commitment) public { - commitments[_commitment] = true; + // Check that the commitment hasn't already been made. This prevents griefing where + // a malicious party continuously re-submits the same commitment, preventing the original + // claimer from claiming their invite by resetting the minimum commitment period. + require(commitmentTimestamps[_commitment] == 0, "OptimistInviter: commitment already made"); + + commitmentTimestamps[_commitment] = block.timestamp; } /** @@ -196,9 +218,10 @@ contract OptimistInviter is Semver, EIP712Upgradeable { * committed using commitInvite. Before issuing the "optimist.can-mint-from-invite" * attestation, this function checks that * 1) the hash corresponding to the _claimer and the _signature was committed - * 2) the _signature is signed correctly by the issuer - * 3) the _signature hasn't already been used to claim an invite before - * 4) the _signature issuer has not used up all of their invites + * 2) MIN_COMMITMENT_PERIOD has passed since the commitment was made. + * 3) the _signature is signed correctly by the issuer + * 4) the _signature hasn't already been used to claim an invite before + * 5) the _signature issuer has not used up all of their invites * This function doesn't require that the _claimer is calling this function. * * @param _claimer Address that will be granted the invite. @@ -210,12 +233,22 @@ contract OptimistInviter is Semver, EIP712Upgradeable { ClaimableInvite calldata _claimableInvite, bytes memory _signature ) public { + uint256 commitmentTimestamp = commitmentTimestamps[ + keccak256(abi.encode(_claimer, _signature)) + ]; + // Make sure the claimer and signature have been committed. require( - commitments[keccak256(abi.encode(_claimer, _signature))], + commitmentTimestamp > 0, "OptimistInviter: claimer and signature have not been committed yet" ); + // Check that MIN_COMMITMENT_PERIOD has passed since the commitment was made. + require( + commitmentTimestamp + MIN_COMMITMENT_PERIOD <= block.timestamp, + "OptimistInviter: minimum commitment period has not elapsed yet" + ); + // Generate a EIP712 typed data hash to compare against the signature. bytes32 digest = _hashTypedDataV4( keccak256(