diff --git a/op-node/rollup/derive/engine_queue.go b/op-node/rollup/derive/engine_queue.go index 8b63518e8ac9903be3023bed67491c3dbdf927bf..283476db9bbbcabf835ab5bbabb38a546f0920bc 100644 --- a/op-node/rollup/derive/engine_queue.go +++ b/op-node/rollup/derive/engine_queue.go @@ -469,7 +469,7 @@ func (eq *EngineQueue) consolidateNextSafeAttributes(ctx context.Context) error return NewTemporaryError(fmt.Errorf("failed to get existing unsafe payload to compare against derived attributes from L1: %w", err)) } if err := AttributesMatchBlock(eq.safeAttributes, eq.safeHead.Hash, payload, eq.log); err != nil { - eq.log.Warn("L2 reorg: existing unsafe block does not match derived attributes from L1", "err", err) + eq.log.Warn("L2 reorg: existing unsafe block does not match derived attributes from L1", "err", err, "unsafe", eq.unsafeHead, "safe", eq.safeHead) // geth cannot wind back a chain without reorging to a new, previously non-canonical, block return eq.forceNextSafeAttributes(ctx) } diff --git a/packages/contracts-periphery/contracts/foundry-tests/OptimistInviter.t.sol b/packages/contracts-periphery/contracts/foundry-tests/OptimistInviter.t.sol new file mode 100644 index 0000000000000000000000000000000000000000..4b47e4a1c1e4c3817cc8839813ce41aad631c544 --- /dev/null +++ b/packages/contracts-periphery/contracts/foundry-tests/OptimistInviter.t.sol @@ -0,0 +1,650 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +/* Testing utilities */ +import { Test } from "forge-std/Test.sol"; +import { AttestationStation } from "../universal/op-nft/AttestationStation.sol"; +import { OptimistInviter } from "../universal/op-nft/OptimistInviter.sol"; +import { Optimist } from "../universal/op-nft/Optimist.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { TestERC1271Wallet } from "../testing/helpers/TestERC1271Wallet.sol"; +import { OptimistInviterHelper } from "../testing/helpers/OptimistInviterHelper.sol"; +import { OptimistConstants } from "../universal/op-nft/libraries/OptimistConstants.sol"; + +contract OptimistInviter_Initializer is Test { + event InviteClaimed(address indexed issuer, address indexed claimer); + event Initialized(uint8 version); + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event AttestationCreated( + address indexed creator, + address indexed about, + bytes32 indexed key, + bytes val + ); + + bytes32 EIP712_DOMAIN_TYPEHASH; + + address internal alice_inviteGranter; + address internal sally; + address internal ted; + address internal eve; + + address internal bob; + uint256 internal bobPrivateKey; + address internal carol; + uint256 internal carolPrivateKey; + + TestERC1271Wallet carolERC1271Wallet; + + AttestationStation attestationStation; + OptimistInviter optimistInviter; + + OptimistInviterHelper optimistInviterHelper; + + function setUp() public { + alice_inviteGranter = makeAddr("alice_inviteGranter"); + sally = makeAddr("sally"); + ted = makeAddr("ted"); + eve = makeAddr("eve"); + + bobPrivateKey = 0xB0B0B0B0; + bob = vm.addr(bobPrivateKey); + + carolPrivateKey = 0xC0C0C0C0; + carol = vm.addr(carolPrivateKey); + + carolERC1271Wallet = new TestERC1271Wallet(carol); + + // Give alice and bob and sally some ETH + vm.deal(alice_inviteGranter, 1 ether); + vm.deal(bob, 1 ether); + vm.deal(sally, 1 ether); + vm.deal(ted, 1 ether); + vm.deal(eve, 1 ether); + + EIP712_DOMAIN_TYPEHASH = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + + _initializeContracts(); + } + + /** + * @notice Instantiates an AttestationStation, and an OptimistInviter. + */ + function _initializeContracts() internal { + attestationStation = new AttestationStation(); + + optimistInviter = new OptimistInviter(alice_inviteGranter, attestationStation); + + vm.expectEmit(true, true, true, true, address(optimistInviter)); + emit Initialized(1); + optimistInviter.initialize("OptimistInviter"); + + 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. + */ + function _getInviteCount(address _issuer) internal view returns (uint256) { + return optimistInviter.inviteCounts(_issuer); + } + + /** + * @notice Returns true if claimer has the proper attestation from OptimistInviter to mint. + */ + function _hasMintAttestation(address _claimer) internal view returns (bool) { + bytes memory attestation = attestationStation.attestations( + address(optimistInviter), + _claimer, + OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY + ); + return attestation.length > 0; + } + + /** + * @notice Get signature as a bytes blob, since SignatureChecker takes arbitrary signature blobs. + * + */ + function _getSignature(uint256 _signingPrivateKey, bytes32 _digest) + internal + pure + returns (bytes memory) + { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signingPrivateKey, _digest); + + bytes memory signature = abi.encodePacked(r, s, v); + return signature; + } + + /** + * @notice Signs a claimable invite with the given private key and returns the signature using + * correct EIP712 domain separator. + */ + function _issueInviteAs(uint256 _privateKey) + internal + returns (OptimistInviter.ClaimableInvite memory, bytes memory) + { + return + _issueInviteWithEIP712Domain( + _privateKey, + bytes("OptimistInviter"), + bytes(optimistInviter.EIP712_VERSION()), + block.chainid, + address(optimistInviter) + ); + } + + /** + * @notice Signs a claimable invite with the given private key and returns the signature using + * the given EIP712 domain separator. This assumes that the issuer's address is the + * corresponding public key to _issuerPrivateKey. + */ + function _issueInviteWithEIP712Domain( + uint256 _issuerPrivateKey, + bytes memory _eip712Name, + bytes memory _eip712Version, + uint256 _eip712Chainid, + address _eip712VerifyingContract + ) internal returns (OptimistInviter.ClaimableInvite memory, bytes memory) { + address issuer = vm.addr(_issuerPrivateKey); + OptimistInviter.ClaimableInvite memory claimableInvite = optimistInviterHelper + .getClaimableInviteWithNewNonce(issuer); + return ( + claimableInvite, + _getSignature( + _issuerPrivateKey, + optimistInviterHelper.getDigestWithEIP712Domain( + claimableInvite, + _eip712Name, + _eip712Version, + _eip712Chainid, + _eip712VerifyingContract + ) + ) + ); + } + + /** + * @notice Commits a signature and claimer address to the OptimistInviter contract. + */ + function _commitInviteAs(address _as, bytes memory _signature) internal { + vm.prank(_as); + bytes32 hashedSignature = keccak256(abi.encode(_as, _signature)); + optimistInviter.commitInvite(hashedSignature); + + // Check that the commitment was stored correctly + assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp); + } + + /** + * @notice Signs a claimable invite with the given private key. The claimer commits then claims + * the invite. Checks that all expected events are emitted and that state is updated + * correctly. Returns the signature and invite for use in tests. + */ + function _issueThenClaimShouldSucceed(uint256 _issuerPrivateKey, address _claimer) + internal + returns (OptimistInviter.ClaimableInvite memory, bytes memory) + { + address issuer = vm.addr(_issuerPrivateKey); + uint256 prevInviteCount = _getInviteCount(issuer); + ( + OptimistInviter.ClaimableInvite memory claimableInvite, + bytes memory signature + ) = _issueInviteAs(_issuerPrivateKey); + + _commitInviteAs(_claimer, signature); + + // The hash(claimer ++ signature) should be committed + 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)); + emit AttestationCreated( + address(optimistInviter), + _claimer, + OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY, + abi.encode(issuer) + ); + + // Should emit an event indicating that the invite was claimed + vm.expectEmit(true, false, false, false, address(optimistInviter)); + emit InviteClaimed(issuer, _claimer); + + vm.prank(_claimer); + optimistInviter.claimInvite(_claimer, claimableInvite, signature); + + // The nonce that issuer used should be marked as used + assertTrue(optimistInviter.usedNonces(issuer, claimableInvite.nonce)); + + // Issuer should have one less invite + assertEq(prevInviteCount - 1, _getInviteCount(issuer)); + + // Claimer should have the mint attestation from the OptimistInviter contract + assertTrue(_hasMintAttestation(_claimer)); + + return (claimableInvite, signature); + } + + /** + * @notice Issues 3 invites to the given address. Checks that all expected events are emitted + * and that state is updated correctly. + */ + function _grantInvitesTo(address _to) internal { + address[] memory addresses = new address[](1); + addresses[0] = _to; + + vm.expectEmit(true, true, true, true, address(attestationStation)); + emit AttestationCreated( + address(optimistInviter), + _to, + optimistInviter.CAN_INVITE_ATTESTATION_KEY(), + bytes("true") + ); + + vm.prank(alice_inviteGranter); + optimistInviter.setInviteCounts(addresses, 3); + + assertEq(_getInviteCount(_to), 3); + } +} + +contract OptimistInviterTest is OptimistInviter_Initializer { + function test_initialize() external { + // expect attestationStation to be set + assertEq(address(optimistInviter.ATTESTATION_STATION()), address(attestationStation)); + assertEq(optimistInviter.INVITE_GRANTER(), alice_inviteGranter); + assertEq(optimistInviter.version(), "1.0.0"); + } + + /** + * @notice Alice the admin should be able to give Bob, Sally, and Carol 3 invites, and the + * OptimistInviter contract should increment invite counts on inviteCounts and issue + * 'optimist.can-invite' attestations. + */ + function test_grantInvites_adminAddingInvites_succeeds() external { + address[] memory addresses = new address[](3); + addresses[0] = bob; + addresses[1] = sally; + addresses[2] = address(carolERC1271Wallet); + + vm.expectEmit(true, true, true, true, address(attestationStation)); + emit AttestationCreated( + address(optimistInviter), + bob, + optimistInviter.CAN_INVITE_ATTESTATION_KEY(), + bytes("true") + ); + + vm.expectEmit(true, true, true, true, address(attestationStation)); + emit AttestationCreated( + address(optimistInviter), + sally, + optimistInviter.CAN_INVITE_ATTESTATION_KEY(), + bytes("true") + ); + + vm.expectEmit(true, true, true, true, address(attestationStation)); + emit AttestationCreated( + address(optimistInviter), + address(carolERC1271Wallet), + optimistInviter.CAN_INVITE_ATTESTATION_KEY(), + bytes("true") + ); + + vm.prank(alice_inviteGranter); + optimistInviter.setInviteCounts(addresses, 3); + + assertEq(_getInviteCount(bob), 3); + assertEq(_getInviteCount(sally), 3); + assertEq(_getInviteCount(address(carolERC1271Wallet)), 3); + } + + /** + * @notice Bob, who is not the invite granter, should not be able to issue invites. + */ + function test_grantInvites_nonAdminAddingInvites_reverts() external { + address[] memory addresses = new address[](2); + addresses[0] = bob; + addresses[1] = sally; + + vm.expectRevert("OptimistInviter: only invite granter can grant invites"); + vm.prank(bob); + optimistInviter.setInviteCounts(addresses, 3); + } + + /** + * @notice Sally should be able to commit an invite given by by Bob. + */ + function test_commitInvite_committingForYourself_succeeds() external { + _grantInvitesTo(bob); + (, bytes memory signature) = _issueInviteAs(bobPrivateKey); + + vm.prank(sally); + bytes32 hashedSignature = keccak256(abi.encode(sally, signature)); + optimistInviter.commitInvite(hashedSignature); + + assertEq(optimistInviter.commitmentTimestamps(hashedSignature), block.timestamp); + } + + /** + * @notice Sally should be able to Bob's for a different claimer, Eve. + */ + function test_commitInvite_committingForSomeoneElse_succeeds() 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); + } + + /** + * @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); + } + + /** + * @notice Bob issues signature, and Sally claims the invite. Bob's invite count should be + * decremented, and Sally should be able to mint. + */ + function test_claimInvite_succeeds() external { + _grantInvitesTo(bob); + _issueThenClaimShouldSucceed(bobPrivateKey, sally); + } + + /** + * @notice Bob issues signature, and Ted commits the invite for Sally. Eve claims for Sally. + */ + function test_claimInvite_claimForSomeoneElse_succeeds() external { + _grantInvitesTo(bob); + ( + OptimistInviter.ClaimableInvite memory claimableInvite, + bytes memory signature + ) = _issueInviteAs(bobPrivateKey); + + vm.prank(ted); + optimistInviter.commitInvite(keccak256(abi.encode(sally, signature))); + _passMinCommitmentPeriod(); + + vm.expectEmit(true, true, true, true, address(attestationStation)); + emit AttestationCreated( + address(optimistInviter), + sally, + OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY, + abi.encode(bob) + ); + + // Should emit an event indicating that the invite was claimed + vm.expectEmit(true, true, true, true, address(optimistInviter)); + emit InviteClaimed(bob, sally); + + vm.prank(eve); + optimistInviter.claimInvite(sally, claimableInvite, signature); + + assertEq(_getInviteCount(bob), 2); + assertTrue(_hasMintAttestation(sally)); + 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. + */ + function test_claimInvite_usingSignatureIssuedForDifferentVersion_reverts() external { + _grantInvitesTo(bob); + ( + OptimistInviter.ClaimableInvite memory claimableInvite, + bytes memory signature + ) = _issueInviteWithEIP712Domain( + bobPrivateKey, + "OptimismInviter", + "0.9.1", + block.chainid, + address(optimistInviter) + ); + + _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); + + vm.expectRevert("OptimistInviter: invalid signature"); + vm.prank(sally); + optimistInviter.claimInvite(sally, claimableInvite, signature); + } + + /** + * @notice Replay attack for signature issued for contract on different chain (ie. mainnet) + * should fail. + */ + function test_claimInvite_usingSignatureIssuedForDifferentChain_reverts() external { + _grantInvitesTo(bob); + ( + OptimistInviter.ClaimableInvite memory claimableInvite, + bytes memory signature + ) = _issueInviteWithEIP712Domain( + bobPrivateKey, + "OptimismInviter", + bytes(optimistInviter.EIP712_VERSION()), + 1, + address(optimistInviter) + ); + + _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); + + vm.expectRevert("OptimistInviter: invalid signature"); + vm.prank(sally); + optimistInviter.claimInvite(sally, claimableInvite, signature); + } + + /** + * @notice Replay attack for signature issued for instantiation of the OptimistInviter contract + * on a different address should fail. + */ + function test_claimInvite_usingSignatureIssuedForDifferentContract_reverts() external { + _grantInvitesTo(bob); + ( + OptimistInviter.ClaimableInvite memory claimableInvite, + bytes memory signature + ) = _issueInviteWithEIP712Domain( + bobPrivateKey, + "OptimismInviter", + bytes(optimistInviter.EIP712_VERSION()), + block.chainid, + address(0xBEEF) + ); + + _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); + + vm.expectRevert("OptimistInviter: invalid signature"); + vm.prank(sally); + optimistInviter.claimInvite(sally, claimableInvite, signature); + } + + /** + * @notice Attempting to claim again using the same signature again should fail. + */ + function test_claimInvite_replayingUsedNonce_reverts() external { + _grantInvitesTo(bob); + + ( + OptimistInviter.ClaimableInvite memory claimableInvite, + bytes memory signature + ) = _issueThenClaimShouldSucceed(bobPrivateKey, sally); + + // Sally tries to claim the invite using the same 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); + } + + /** + * @notice Issuing signatures through a contract that implements ERC1271 should succeed (ie. + * Gnosis Safe or other smart contract wallets). Carol is using a ERC1271 contract + * wallet that is simply backed by her private key. + */ + function test_claimInvite_usingERC1271Wallet_succeeds() external { + _grantInvitesTo(address(carolERC1271Wallet)); + + OptimistInviter.ClaimableInvite memory claimableInvite = optimistInviterHelper + .getClaimableInviteWithNewNonce(address(carolERC1271Wallet)); + + bytes memory signature = _getSignature( + carolPrivateKey, + optimistInviterHelper.getDigest(claimableInvite) + ); + + // Sally tries to claim the invite + _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); + + vm.expectEmit(true, true, true, true, address(attestationStation)); + emit AttestationCreated( + address(optimistInviter), + sally, + OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY, + abi.encode(address(carolERC1271Wallet)) + ); + + vm.prank(sally); + optimistInviter.claimInvite(sally, claimableInvite, signature); + assertEq(_getInviteCount(address(carolERC1271Wallet)), 2); + } + + /** + * @notice Claimer must commit the signature before claiming the invite. Sally attempts to + * claim the Bob's invite without committing the signature first. + */ + function test_claimInvite_withoutCommittingHash_reverts() external { + _grantInvitesTo(bob); + ( + OptimistInviter.ClaimableInvite memory claimableInvite, + bytes memory signature + ) = _issueInviteAs(bobPrivateKey); + + vm.expectRevert("OptimistInviter: claimer and signature have not been committed yet"); + vm.prank(sally); + optimistInviter.claimInvite(sally, claimableInvite, signature); + } + + /** + * @notice Using a signature that doesn't correspond to the claimable invite should fail. + */ + function test_claimInvite_withIncorrectSignature_reverts() external { + _grantInvitesTo(carol); + _grantInvitesTo(bob); + ( + OptimistInviter.ClaimableInvite memory bobClaimableInvite, + bytes memory bobSignature + ) = _issueInviteAs(bobPrivateKey); + (, bytes memory carolSignature) = _issueInviteAs(carolPrivateKey); + + _commitInviteAs(sally, bobSignature); + _commitInviteAs(sally, carolSignature); + + _passMinCommitmentPeriod(); + + vm.expectRevert("OptimistInviter: invalid signature"); + vm.prank(sally); + optimistInviter.claimInvite(sally, bobClaimableInvite, carolSignature); + } + + /** + * @notice Attempting to use a signature from a issuer who never was granted invites should + * fail. + */ + function test_claimInvite_whenIssuerNeverReceivedInvites_reverts() external { + // Bob was never granted any invites, but issues an invite for Eve + ( + OptimistInviter.ClaimableInvite memory claimableInvite, + bytes memory signature + ) = _issueInviteAs(bobPrivateKey); + + _commitInviteAs(sally, signature); + _passMinCommitmentPeriod(); + + vm.expectRevert("OptimistInviter: issuer has no invites"); + vm.prank(sally); + optimistInviter.claimInvite(sally, claimableInvite, signature); + } + + /** + * @notice Attempting to use a signature from a issuer who has no more invites should fail. + * Bob has 3 invites, but issues 4 invites for Sally, Carol, Ted, and Eve. Only the + * first 3 invites should be claimable. The last claimer, Eve, should not be able to + * claim the invite. + * + */ + function test_claimInvite_whenIssuerHasNoInvitesLeft_reverts() external { + _grantInvitesTo(bob); + + _issueThenClaimShouldSucceed(bobPrivateKey, sally); + _issueThenClaimShouldSucceed(bobPrivateKey, carol); + _issueThenClaimShouldSucceed(bobPrivateKey, ted); + + assertEq(_getInviteCount(bob), 0); + + ( + OptimistInviter.ClaimableInvite memory claimableInvite4, + bytes memory signature4 + ) = _issueInviteAs(bobPrivateKey); + + _commitInviteAs(eve, signature4); + _passMinCommitmentPeriod(); + + vm.expectRevert("OptimistInviter: issuer has no invites"); + vm.prank(eve); + optimistInviter.claimInvite(eve, claimableInvite4, signature4); + + assertEq(_getInviteCount(bob), 0); + } +} diff --git a/packages/contracts-periphery/contracts/testing/helpers/OptimistInviterHelper.sol b/packages/contracts-periphery/contracts/testing/helpers/OptimistInviterHelper.sol new file mode 100644 index 0000000000000000000000000000000000000000..6de1822342c3557057ef4fd1ac300c1af1253001 --- /dev/null +++ b/packages/contracts-periphery/contracts/testing/helpers/OptimistInviterHelper.sol @@ -0,0 +1,146 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +import { OptimistInviter } from "../../universal/op-nft/OptimistInviter.sol"; + +/** + * Simple helper contract that helps with testing flow and signature for OptimistInviter contract. + * 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) { + optimistInviter = _optimistInviter; + name = _name; + } + + /** + * @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 + pure + returns (bytes32) + { + return + keccak256( + abi.encode( + CLAIMABLE_INVITE_TYPEHASH, + _claimableInvite.issuer, + _claimableInvite.nonce + ) + ); + } + + /** + * @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. + * + * @param _issuer Issuer to include in the ClaimableInvite. + * + * @return ClaimableInvite that can be hashed & signed. + */ + function getClaimableInviteWithNewNonce(address _issuer) + public + returns (OptimistInviter.ClaimableInvite memory) + { + return OptimistInviter.ClaimableInvite(_issuer, consumeNonce()); + } + + /** + * @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 + view + returns (bytes32) + { + return + getDigestWithEIP712Domain( + _claimableInvite, + bytes(name), + bytes(optimistInviter.EIP712_VERSION()), + block.chainid, + address(optimistInviter) + ); + } + + /** + * @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, + bytes memory _name, + bytes memory _version, + uint256 _chainid, + address _verifyingContract + ) public pure returns (bytes32) { + bytes32 domainSeparator = keccak256( + abi.encode( + EIP712_DOMAIN_TYPEHASH, + keccak256(_name), + keccak256(_version), + _chainid, + _verifyingContract + ) + ); + return + ECDSA.toTypedDataHash(domainSeparator, getClaimableInviteStructHash(_claimableInvite)); + } +} diff --git a/packages/contracts-periphery/contracts/testing/helpers/TestERC1271Wallet.sol b/packages/contracts-periphery/contracts/testing/helpers/TestERC1271Wallet.sol new file mode 100644 index 0000000000000000000000000000000000000000..bebb1fd7512d47daa7814ce29e4ac262cf4fa00a --- /dev/null +++ b/packages/contracts-periphery/contracts/testing/helpers/TestERC1271Wallet.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +// solhint-disable max-line-length +/** + * Simple ERC1271 wallet that can be used to test the ERC1271 signature checker. + * https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/mocks/ERC1271WalletMock.sol + */ +contract TestERC1271Wallet is Ownable, IERC1271 { + constructor(address originalOwner) { + transferOwnership(originalOwner); + } + + function isValidSignature(bytes32 hash, bytes memory signature) + public + view + override + returns (bytes4 magicValue) + { + return + ECDSA.recover(hash, signature) == owner() ? this.isValidSignature.selector : bytes4(0); + } +} diff --git a/packages/contracts-periphery/contracts/universal/op-nft/OptimistInviter.sol b/packages/contracts-periphery/contracts/universal/op-nft/OptimistInviter.sol new file mode 100644 index 0000000000000000000000000000000000000000..f49ed69583afaabb02de82ffdf0014785efff2bb --- /dev/null +++ b/packages/contracts-periphery/contracts/universal/op-nft/OptimistInviter.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { OptimistConstants } from "./libraries/OptimistConstants.sol"; +import { Semver } from "@eth-optimism/contracts-bedrock/contracts/universal/Semver.sol"; +import { AttestationStation } from "./AttestationStation.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import { + EIP712Upgradeable +} from "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol"; + +/** + * @custom:upgradeable + * @title OptimistInviter + * @notice OptimistInviter issues "optimist.can-invite" and "optimist.can-mint-from-invite" + * attestations. Accounts that have invites can issue signatures that allow other + * accounts to claim an invite. The invitee uses a claim and reveal flow to claim the + * invite to an address of their choosing. + * + * Parties involved: + * 1) INVITE_GRANTER: trusted account that can allow accounts to issue invites + * 2) issuer: account that is allowed to issue invites + * 3) claimer: account that receives the invites + * + * Flow: + * 1) INVITE_GRANTER calls _setInviteCount to allow an issuer to issue a certain number + * of invites, and also creates a "optimist.can-invite" attestation for the issuer + * 2) Off-chain, the issuer signs (EIP-712) a ClaimableInvite to produce a signature + * 3) Off-chain, invite issuer sends the plaintext ClaimableInvite and the signature + * to the recipient + * 4) claimer chooses an address they want to receive the invite on + * 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 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 claimer Address that claimed the invite. + */ + event InviteClaimed(address indexed issuer, address indexed claimer); + + /** + * @notice Version used for the EIP712 domain separator. This version is separated from the + * contract semver because the EIP712 domain separator is used to sign messages, and + * changing the domain separator invalidates all existing signatures. We should only + * bump this version if we make a major change to the signature scheme. + */ + string public constant EIP712_VERSION = "1.0.0"; + + /** + * @notice EIP712 typehash for the ClaimableInvite type. + */ + bytes32 public constant CLAIMABLE_INVITE_TYPEHASH = + keccak256("ClaimableInvite(address issuer,bytes32 nonce)"); + + /** + * @notice Attestation key for that signals that an account was allowed to issue invites + */ + bytes32 public constant CAN_INVITE_ATTESTATION_KEY = bytes32("optimist.can-invite"); + + /** + * @notice Granter who can set accounts' invite counts. + */ + address public immutable INVITE_GRANTER; + + /** + * @notice Address of the AttestationStation contract. + */ + 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. + * + * @custom:field issuer Address that issued the signature. Reason this is explicitly included, + * and not implicitly assumed to be the recovered address from the + * signature is that the issuer may be using a ERC-1271 compatible + * contract wallet, where the recovered address is not the same as the + * issuer, or the signature is not an ECDSA signature at all. + * @custom:field nonce Pseudorandom nonce to prevent replay attacks. + */ + struct ClaimableInvite { + address issuer; + bytes32 nonce; + } + + /** + * @notice Maps from hashes to the timestamp when they were committed. + */ + mapping(bytes32 => uint256) public commitmentTimestamps; + + /** + * @notice Maps from addresses to nonces to whether or not they have been used. + */ + mapping(address => mapping(bytes32 => bool)) public usedNonces; + + /** + * @notice Maps from addresses to number of invites they have. + */ + mapping(address => uint256) public inviteCounts; + + /** + * @custom:semver 1.0.0 + * + * @param _inviteGranter Address of the invite granter. + * @param _attestationStation Address of the AttestationStation contract. + */ + constructor(address _inviteGranter, AttestationStation _attestationStation) Semver(1, 0, 0) { + INVITE_GRANTER = _inviteGranter; + ATTESTATION_STATION = _attestationStation; + } + + /** + * @notice Initializes this contract, setting the EIP712 context. + * + * Only update the EIP712_VERSION when there is a change to the signature scheme. + * After the EIP712 version is changed, any signatures issued off-chain but not + * 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. + */ + function initialize(string memory _name) public initializer { + __EIP712_init(_name, EIP712_VERSION); + } + + /** + * @notice Allows invite granter to set the number of invites an address has. + * + * @param _accounts An array of accounts to update the invite counts of. + * @param _inviteCount Number of invites to set to. + */ + function setInviteCounts(address[] calldata _accounts, uint256 _inviteCount) public { + // Only invite granter can grant invites + require( + msg.sender == INVITE_GRANTER, + "OptimistInviter: only invite granter can grant invites" + ); + + uint256 length = _accounts.length; + + AttestationStation.AttestationData[] + memory attestations = new AttestationStation.AttestationData[](length); + + for (uint256 i; i < length; ) { + // Set invite count for account to _inviteCount + inviteCounts[_accounts[i]] = _inviteCount; + + // Create an attestation for posterity that the account is allowed to create invites + attestations[i] = AttestationStation.AttestationData({ + about: _accounts[i], + key: CAN_INVITE_ATTESTATION_KEY, + val: bytes("true") + }); + + unchecked { + ++i; + } + } + + ATTESTATION_STATION.attest(attestations); + } + + /** + * @notice Allows anyone (but likely the claimer) to commit a received signature along with the + * address to claim to. + * + * Before calling this function, the claimer should have received a signature from the + * issuer off-chain. The claimer then calls this function with the hash of the + * claimer's address and the received signature. This is necessary to prevent + * front-running when the invitee is claiming the invite. Without a commit and reveal + * 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 { + // 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; + } + + /** + * @notice Allows anyone to reveal a commitment and claim an invite. + * + * The hash, keccak256(abi.encode(_claimer, _signature)), should have been already + * 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) 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. + * @param _claimableInvite ClaimableInvite struct containing the issuer and nonce. + * @param _signature Signature signed over the claimable invite. + */ + function claimInvite( + address _claimer, + 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( + 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( + abi.encode( + CLAIMABLE_INVITE_TYPEHASH, + _claimableInvite.issuer, + _claimableInvite.nonce + ) + ) + ); + + // Uses SignatureChecker, which supports both regular ECDSA signatures from EOAs as well as + // ERC-1271 signatures from contract wallets or multi-sigs. This means that if the issuer + // wants to revoke a signature, they can use a smart contract wallet to issue the signature, + // then invalidate the signature after issuing it. + require( + SignatureChecker.isValidSignatureNow(_claimableInvite.issuer, digest, _signature), + "OptimistInviter: invalid signature" + ); + + // The issuer's signature commits to a nonce to prevent replay attacks. + // This checks that the nonce has not been used for this issuer before. The nonces are + // scoped to the issuer address, so the same nonce can be used by different issuers without + // clashing. + require( + usedNonces[_claimableInvite.issuer][_claimableInvite.nonce] == false, + "OptimistInviter: nonce has already been used" + ); + + // Set the nonce as used for the issuer so that it cannot be replayed. + usedNonces[_claimableInvite.issuer][_claimableInvite.nonce] = true; + + // Failing this check means that the issuer has used up all of their existing invites. + require( + inviteCounts[_claimableInvite.issuer] > 0, + "OptimistInviter: issuer has no invites" + ); + + // Reduce the issuer's invite count by 1. Can be unchecked because we check above that + // count is > 0. + unchecked { + --inviteCounts[_claimableInvite.issuer]; + } + + // Create the attestation that the claimer can mint from the issuer's invite. + // The invite issuer is included in the data of the attestation. + ATTESTATION_STATION.attest( + _claimer, + OptimistConstants.OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY, + abi.encode(_claimableInvite.issuer) + ); + + emit InviteClaimed(_claimableInvite.issuer, _claimer); + } +} diff --git a/packages/contracts-periphery/contracts/universal/op-nft/libraries/OptimistConstants.sol b/packages/contracts-periphery/contracts/universal/op-nft/libraries/OptimistConstants.sol new file mode 100644 index 0000000000000000000000000000000000000000..afdd043b604c89fe8768f7b21d0567af80579508 --- /dev/null +++ b/packages/contracts-periphery/contracts/universal/op-nft/libraries/OptimistConstants.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +/** + * @title OptimistConstants + * @notice Library for storing Optimist related constants that are shared in multiple contracts. + */ + +library OptimistConstants { + /** + * @notice Attestation key issued by OptimistInviter allowing the attested account to mint. + */ + bytes32 internal constant OPTIMIST_CAN_MINT_FROM_INVITE_ATTESTATION_KEY = + bytes32("optimist.can-mint-from-invite"); +}