HoodTalkRegistry

0x0e0c35e444e2a24374f46fd2792baf5e81c31d41

Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
4,726 bytes
ABI entries
34
15 read · 3 write
License
none

Contract information

Address
0x0e0c35e444e2a24374f46fd2792baf5e81c31d41
Chain
Robinhood Chain (4663)
Compiler
v0.8.24+commit.e11b9ed9
Optimization
Enabled
Creation tx
0xa7b8718f4c…8e767258e9

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (15)

HOOD_TALK_TYPEHASH()bytes32
MAX_QUOTE_BYTES()uint256
UPDATE_COOLDOWN()uint256
authorizationDigest(uint256, address, string, uint32, uint256)bytes32
authorizedSigner()address
currentQuote(uint256)string
eip712Domain()bytes1, string, string, uint256, address, bytes32, uint256[]
getHoodTalk(uint256)string, address, uint64, uint32
hoodTalkCount(uint256)uint32
hoodies()address
nextUpdateAt(uint256)uint256
owner()address
supportsInterface(bytes4)bool
totalHoodTalks()uint256
uniqueHoodiesSpoken()uint256

Events (5)

AuthorizedSignerUpdatedEIP712DomainChangedHoodTalkUpdatedOwnershipTransferredRegistryInitialized

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "hoodies_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "authorizedSigner_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "owner_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AuthorizationExpired",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "availableAt",
        "type": "uint256"
      }
    ],
    "name": "CooldownActive",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "CountOverflow",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidAuthorization",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidQuoteLength",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidShortString",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotOwner",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotTokenOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "string",
        "name": "str",
        "type": "string"
      }
    ],
    "name": "StringTooLong",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousSigner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newSigner",
        "type": "address"
      }
    ],
    "name": "AuthorizedSignerUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [],
    "name": "EIP712DomainChanged",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "holder",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint32",
        "name": "count",
        "type": "uint32"
      },
      {
        "indexed": false,
        "internalType": "uint64",
        "name": "updatedAt",
        "type": "uint64"
      },
      {
        "indexed": false,
        "internalType": "string",
        "name": "quote",
        "type": "string"
      }
    ],
    "name": "HoodTalkUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "hoodiesContract",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "signer",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "registryOwner",
        "type": "address"
      }
    ],
    "name": "RegistryInitialized",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "HOOD_TALK_TYPEHASH",
    "outputs": [
      {
        "internalType": "bytes32",
        "name": "",
        "type": "bytes32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_QUOTE_BYTES",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "UPDATE_COOLDOWN",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "holder",
        "type": "address"
      },
      {
        "internalType": "string",
        "name": "quote",
        "type": "string"
      },
      {
        "internalType": "uint32",
        "name": "count",
        "type": "uint32"
      },
      {
        "internalType": "uint256",
        "name": "deadline",
        "type": "uint256"
      }
    ],
    "name": "authorizationDigest",
    "outputs": [
      {
        "internalType": "bytes32",
        "name": "",
        "type": "bytes32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "authorizedSigner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "currentQuote",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "eip712Domain",
    "outputs": [
      {
        "internalType": "bytes1",
        "name": "fields",
        "type": "bytes1"
      },
      {
        "internalType": "string",
        "name": "name",
        "type": "string"
      },
      {
        "internalType": "string",
        "name": "version",
        "type": "string"
      },
      {
        "internalType": "uint256",
        "name": "chainId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "verifyingContract",
        "type": "address"
      },
      {
        "internalType": "bytes32",
        "name": "salt",
        "type": "bytes32"
      },
      {
        "internalType": "uint256[]",
        "name": "extensions",
        "type": "uint256[]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "getHoodTalk",
    "outputs": [
      {
        "internalType": "string",
        "name": "quote",
        "type": "string"
      },
      {
        "internalType": "address",
        "name": "author",
        "type": "address"
      },
      {
        "internalType": "uint64",
        "name": "updatedAt",
        "type": "uint64"
      },
      {
        "internalType": "uint32",
        "name": "count",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "hoodTalkCount",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "hoodies",
    "outputs": [
      {
        "internalType": "contract IERC721",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "nextUpdateAt",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newSigner",
        "type": "address"
      }
    ],
    "name": "setAuthorizedSigner",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "string",
        "name": "quote",
        "type": "string"
      },
      {
        "internalType": "uint256",
        "name": "deadline",
        "type": "uint256"
      },
      {
        "internalType": "bytes",
        "name": "signature",
        "type": "bytes"
      }
    ],
    "name": "setHoodTalk",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes4",
        "name": "interfaceId",
        "type": "bytes4"
      }
    ],
    "name": "supportsInterface",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalHoodTalks",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "uniqueHoodiesSpoken",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/introspection/ERC165.sol";

import "./interfaces/IHoodTalkRegistry.sol";

/// @title OnChainHoodies Hood Talk Registry
/// @author OnChainHoodies
/// @notice Stores the latest holder-selected Hood Talk for each
/// OnChainHoodies token.
/// @dev Each token stores only its latest Hood Talk in contract state.
/// The complete historical record remains permanently available through
/// HoodTalkUpdated events.
///
/// A Hood Talk can only be committed when:
/// - The caller currently owns the referenced NFT.
/// - The authorized backend signer approved the exact quote.
/// - The authorization has not expired.
/// - The token has not recorded another Hood Talk in the past 24 hours.
///
/// The cooldown follows the Hoodie token itself and is not reset when the
/// token is transferred to another wallet.
contract HoodTalkRegistry is EIP712, ERC165, IHoodTalkRegistry {
    using ECDSA for bytes32;

    /// @notice The stored state for a token's latest Hood Talk.
    struct HoodTalk {
        string quote;
        address author;
        uint64 updatedAt;
        uint32 count;
    }

    /// @notice Maximum permitted length of a Hood Talk in UTF-8 bytes.
    uint256 public constant MAX_QUOTE_BYTES = 1024;

    /// @notice Minimum delay between Hood Talks for the same token.
    uint256 public constant UPDATE_COOLDOWN = 1 days;

    /// @notice EIP-712 type hash used to authorize a Hood Talk.
    bytes32 public constant HOOD_TALK_TYPEHASH = keccak256(
        "HoodTalk(uint256 tokenId,address holder,bytes32 quoteHash,uint32 count,uint256 deadline)"
    );

    /// @notice The OnChainHoodies ERC-721 collection.
    IERC721 public immutable hoodies;

    /// @notice Address authorized to administer this registry.
    address public owner;

    /// @notice Address authorized to approve Hood Talk submissions.
    address public authorizedSigner;

    /// @notice Total number of Hood Talks ever committed.
    uint256 public totalHoodTalks;

    /// @notice Number of unique Hoodie tokens that have spoken at least once.
    uint256 public uniqueHoodiesSpoken;

    mapping(uint256 => HoodTalk) private _hoodTalks;

    /// @notice Thrown when a caller is not the registry owner.
    error NotOwner();

    /// @notice Thrown when a required address is the zero address.
    error ZeroAddress();

    /// @notice Thrown when the caller does not currently own the token.
    error NotTokenOwner();

    /// @notice Thrown when the quote is empty or exceeds MAX_QUOTE_BYTES.
    error InvalidQuoteLength();

    /// @notice Thrown when the token attempts to update too early.
    /// @param availableAt Timestamp when the token may speak again.
    error CooldownActive(uint256 availableAt);

    /// @notice Thrown when the backend authorization has expired.
    error AuthorizationExpired();

    /// @notice Thrown when the supplied signature is not valid.
    error InvalidAuthorization();

    /// @notice Thrown if the token's Hood Talk count reaches uint32 maximum.
    error CountOverflow();

    /// @notice Emitted when registry ownership changes.
    /// @param previousOwner The previous registry owner.
    /// @param newOwner The new registry owner.
    event OwnershipTransferred(
        address indexed previousOwner,
        address indexed newOwner
    );

    /// @notice Emitted when the authorized Hood Talk signer changes.
    /// @param previousSigner The previous signer.
    /// @param newSigner The new signer.
    event AuthorizedSignerUpdated(
        address indexed previousSigner,
        address indexed newSigner
    );

    /// @notice Emitted once when the registry is deployed.
    /// @param hoodiesContract The associated ERC-721 collection.
    /// @param signer The initial authorized signer.
    /// @param registryOwner The initial registry owner.
    event RegistryInitialized(
        address indexed hoodiesContract,
        address indexed signer,
        address indexed registryOwner
    );

    /// @notice Emitted whenever a holder commits a new Hood Talk.
    /// @dev These events form the permanent history for each token.
    /// @param tokenId The Hoodie token ID.
    /// @param holder The holder who selected the Hood Talk.
    /// @param count The token's new cumulative Hood Talk count.
    /// @param updatedAt The timestamp when the Hood Talk was committed.
    /// @param quote The exact holder-selected Hood Talk.

    /// @dev Restricts a function to the registry owner.
    modifier onlyOwner() {
        if (msg.sender != owner) {
            revert NotOwner();
        }

        _;
    }

    /// @notice Deploys the Hood Talk Registry.
    /// @param hoodies_ Address of the OnChainHoodies ERC-721 contract.
    /// @param authorizedSigner_ Address that approves valid Hood Talks.
    /// @param owner_ Address that administers the registry.
    constructor(
        address hoodies_,
        address authorizedSigner_,
        address owner_
    ) EIP712("OnChainHoodies Hood Talk", "1") {
        if (
            hoodies_ == address(0) ||
            authorizedSigner_ == address(0) ||
            owner_ == address(0)
        ) {
            revert ZeroAddress();
        }

        hoodies = IERC721(hoodies_);
        authorizedSigner = authorizedSigner_;
        owner = owner_;

        emit OwnershipTransferred(address(0), owner_);
        emit AuthorizedSignerUpdated(address(0), authorizedSigner_);

        emit RegistryInitialized(
            hoodies_,
            authorizedSigner_,
            owner_
        );
    }

    /// @notice Commits a new holder-selected Hood Talk for a token.
    /// @dev The signature must authorize the exact token, current holder,
    /// quote hash, next count and deadline.
    ///
    /// The token's existing Hood Talk is replaced in contract state, while
    /// the previous Hood Talk remains permanently available in event logs.
    ///
    /// @param tokenId The OnChainHoodies token ID.
    /// @param quote The exact Hood Talk approved by the authorized signer.
    /// @param deadline Unix timestamp after which the signature expires.
    /// @param signature EIP-712 signature created by authorizedSigner.
    function setHoodTalk(
        uint256 tokenId,
        string calldata quote,
        uint256 deadline,
        bytes calldata signature
    ) external override {
        address holder = hoodies.ownerOf(tokenId);

        if (holder != msg.sender) {
            revert NotTokenOwner();
        }

        uint256 quoteLength = bytes(quote).length;

        if (
            quoteLength == 0 ||
            quoteLength > MAX_QUOTE_BYTES
        ) {
            revert InvalidQuoteLength();
        }

        if (block.timestamp > deadline) {
            revert AuthorizationExpired();
        }

        HoodTalk storage talk = _hoodTalks[tokenId];

        _enforceCooldown(talk);

        if (talk.count == type(uint32).max) {
            revert CountOverflow();
        }

        uint32 nextCount = talk.count + 1;

        _verifySignature(
            tokenId,
            holder,
            keccak256(bytes(quote)),
            nextCount,
            deadline,
            signature
        );

        if (talk.count == 0) {
            unchecked {
                ++uniqueHoodiesSpoken;
            }
        }

        uint64 updatedAt = uint64(block.timestamp);

        talk.quote = quote;
        talk.author = holder;
        talk.updatedAt = updatedAt;
        talk.count = nextCount;

        unchecked {
            ++totalHoodTalks;
        }

        emit HoodTalkUpdated(
            tokenId,
            holder,
            nextCount,
            updatedAt,
            quote
        );
    }

    /// @notice Returns the latest Hood Talk stored for a token.
    /// @param tokenId The OnChainHoodies token ID.
    /// @return quote The latest selected Hood Talk.
    /// @return author The holder who selected the latest Hood Talk.
    /// @return updatedAt Timestamp when the latest Hood Talk was committed.
    /// @return count Total number of Hood Talks committed for the token.
    function getHoodTalk(
        uint256 tokenId
    )
        external
        view
        override
        returns (
            string memory quote,
            address author,
            uint64 updatedAt,
            uint32 count
        )
    {
        HoodTalk storage talk = _hoodTalks[tokenId];

        return (
            talk.quote,
            talk.author,
            talk.updatedAt,
            talk.count
        );
    }

    /// @notice Returns only the latest Hood Talk text for a token.
    /// @param tokenId The OnChainHoodies token ID.
    /// @return The latest Hood Talk, or an empty string if none exists.
    function currentQuote(
        uint256 tokenId
    ) external view override returns (string memory) {
        return _hoodTalks[tokenId].quote;
    }

    /// @notice Returns the number of Hood Talks committed for a token.
    /// @param tokenId The OnChainHoodies token ID.
    /// @return The token's cumulative Hood Talk count.
    function hoodTalkCount(
        uint256 tokenId
    ) external view override returns (uint32) {
        return _hoodTalks[tokenId].count;
    }

    /// @notice Returns the earliest timestamp when the token may speak again.
    /// @dev The cooldown follows the token and is not reset by transfers.
    /// Returns zero when the token has never spoken.
    /// @param tokenId The OnChainHoodies token ID.
    /// @return The next permitted update timestamp, or zero.
    function nextUpdateAt(
        uint256 tokenId
    ) external view override returns (uint256) {
        HoodTalk storage talk = _hoodTalks[tokenId];

        if (talk.updatedAt == 0) {
            return 0;
        }

        return uint256(talk.updatedAt) + UPDATE_COOLDOWN;
    }

    /// @notice Returns the exact EIP-712 digest expected by the registry.
    /// @dev This helper is useful for backend integration and contract tests.
    /// The count must be the token's next Hood Talk count.
    /// @param tokenId The OnChainHoodies token ID.
    /// @param holder The current owner expected to submit the transaction.
    /// @param quote The exact approved Hood Talk.
    /// @param count The next Hood Talk count for the token.
    /// @param deadline The signature expiry timestamp.
    /// @return The EIP-712 digest that the authorized signer must sign.
    function authorizationDigest(
        uint256 tokenId,
        address holder,
        string calldata quote,
        uint32 count,
        uint256 deadline
    ) external view returns (bytes32) {
        return _authorizationDigest(
            tokenId,
            holder,
            keccak256(bytes(quote)),
            count,
            deadline
        );
    }

    /// @notice Changes the address permitted to authorize Hood Talks.
    /// @dev Only the registry owner may call this function.
    /// Existing signatures created by the previous signer become invalid.
    /// @param newSigner The new authorized signer address.
    function setAuthorizedSigner(
        address newSigner
    ) external onlyOwner {
        if (newSigner == address(0)) {
            revert ZeroAddress();
        }

        address previousSigner = authorizedSigner;
        authorizedSigner = newSigner;

        emit AuthorizedSignerUpdated(
            previousSigner,
            newSigner
        );
    }

    /// @notice Transfers administration of the registry.
    /// @dev Only the current registry owner may call this function.
    /// @param newOwner The new registry owner address.
    function transferOwnership(
        address newOwner
    ) external onlyOwner {
        if (newOwner == address(0)) {
            revert ZeroAddress();
        }

        address previousOwner = owner;
        owner = newOwner;

        emit OwnershipTransferred(
            previousOwner,
            newOwner
        );
    }

    /// @notice Reports whether this contract implements an interface.
    /// @param interfaceId The ERC-165 interface identifier.
    /// @return True when interfaceId is supported.
    function supportsInterface(
        bytes4 interfaceId
    ) public view virtual override returns (bool) {
        return
            interfaceId == type(IHoodTalkRegistry).interfaceId ||
            super.supportsInterface(interfaceId);
    }

    /// @dev Enforces the 24-hour cooldown for the token regardless of
    /// ownership changes.
    function _enforceCooldown(
        HoodTalk storage talk
    ) private view {
        if (talk.updatedAt == 0) {
            return;
        }

        uint256 availableAt =
            uint256(talk.updatedAt) + UPDATE_COOLDOWN;

        if (block.timestamp < availableAt) {
            revert CooldownActive(availableAt);
        }
    }

    /// @dev Verifies that authorizedSigner approved the exact submission.
    function _verifySignature(
        uint256 tokenId,
        address holder,
        bytes32 quoteHash,
        uint32 count,
        uint256 deadline,
        bytes calldata signature
    ) private view {
        bytes32 digest = _authorizationDigest(
            tokenId,
            holder,
            quoteHash,
            count,
            deadline
        );

        if (
            digest.recover(signature) !=
            authorizedSigner
        ) {
            revert InvalidAuthorization();
        }
    }

    /// @dev Builds the EIP-712 digest for a Hood Talk authorization.
    function _authorizationDigest(
        uint256 tokenId,
        address holder,
        bytes32 quoteHash,
        uint32 count,
        uint256 deadline
    ) private view returns (bytes32) {
        bytes32 structHash = keccak256(
            abi.encode(
                HOOD_TALK_TYPEHASH,
                tokenId,
                holder,
                quoteHash,
                count,
                deadline
            )
        );

        return _hashTypedDataV4(structHash);
    }
}
Chain explorer4604msChain node76ms