// SPDX-License-Identifier: MIT pragma solidity 0.8.15; import { OptimistConstants } from "src/periphery/op-nft/libraries/OptimistConstants.sol"; import { ISemver } from "src/universal/ISemver.sol"; import { AttestationStation } from "src/periphery/op-nft/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 ISemver, 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; /// @notice Semantic version. /// @custom:semver 1.1.0 string public constant version = "1.1.0"; /// @param _inviteGranter Address of the invite granter. /// @param _attestationStation Address of the AttestationStation contract. constructor(address _inviteGranter, AttestationStation _attestationStation) { 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); } }