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