// SPDX-License-Identifier: MIT pragma solidity 0.8.36; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; /// @notice One wallet-bound NFT per address. Activity is attested by the published scoring service. /// @dev No owner, upgrade, price, withdrawal, or signer-change function. The attestor is trusted for historical activity. contract OutlawPass is ERC721 { using Strings for uint256; bytes32 public constant POLICY_HASH = keccak256("OutlawPass activity v1: Robinhood4663; window180d; activeDays30=40; contracts10=25; recipientDays100=20; firstActivityAge180d=15; floorEach; successfulOutbound; excludeSelf; snapshot64blocks; oneWalletBoundPass"); bytes32 public constant ATTESTATION_TYPEHASH = keccak256("Activity(address wallet,uint8 score,uint64 snapshotBlock,bytes32 snapshotHash,uint64 deadline,bytes32 policyHash)"); address public attestor; bytes32[5] public artHashes; uint256 public totalSupply; mapping(address => uint256) public passOf; struct Activity { address wallet; uint8 score; uint64 snapshotBlock; bytes32 snapshotHash; uint64 deadline; } struct Pass { uint8 score; uint64 snapshotBlock; bytes32 snapshotHash; uint64 mintedAt; } mapping(uint256 => Pass) public passes; error InvalidAttestation(); error AlreadyMinted(); error WalletBound(); event Locked(uint256 tokenId); event PassMinted(address indexed wallet, uint256 indexed tokenId, uint8 score, uint8 tier, uint64 snapshotBlock, bytes32 snapshotHash); constructor(address activityAttestor, bytes32[5] memory artworkHashes) ERC721("Outlaw Pass", "HOODPASS") { if (activityAttestor == address(0)) revert InvalidAttestation(); attestor = activityAttestor; for (uint256 i; i < 5; ++i) { if (artworkHashes[i] == bytes32(0)) revert InvalidAttestation(); artHashes[i] = artworkHashes[i]; } } /// @notice Standard EIP-712 domain, recomputed to bind signatures to this deployment and chain. function DOMAIN_SEPARATOR() public view returns (bytes32) { return keccak256(abi.encode(keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256("Outlaw Pass"), keccak256("1"), block.chainid, address(this))); } /// @dev Robinhood uses Arbitrum block semantics: block.number is an ancestor-chain height. /// ArbSys returns the same child-chain numbering used by JSON-RPC and the activity indexer. function _chainBlockNumber() internal view returns (uint256) { (bool ok, bytes memory result) = address(0x64).staticcall(abi.encodeWithSignature("arbBlockNumber()")); if (ok && result.length == 32) return abi.decode(result, (uint256)); return block.number; } function mint(Activity calldata activity, bytes calldata signature) external returns (uint256 tokenId) { if (passOf[msg.sender] != 0) revert AlreadyMinted(); if (activity.wallet != msg.sender || activity.score > 100 || activity.deadline < block.timestamp || activity.deadline > block.timestamp + 30 minutes || activity.snapshotBlock == 0 || activity.snapshotBlock >= _chainBlockNumber() || activity.snapshotHash == bytes32(0)) revert InvalidAttestation(); bytes32 digest = MessageHashUtils.toTypedDataHash(DOMAIN_SEPARATOR(), keccak256(abi.encode(ATTESTATION_TYPEHASH, activity.wallet, activity.score, activity.snapshotBlock, activity.snapshotHash, activity.deadline, POLICY_HASH))); if (ECDSA.recover(digest, signature) != attestor) revert InvalidAttestation(); tokenId = ++totalSupply; passOf[msg.sender] = tokenId; passes[tokenId] = Pass(activity.score, activity.snapshotBlock, activity.snapshotHash, uint64(block.timestamp)); _safeMint(msg.sender, tokenId); emit Locked(tokenId); emit PassMinted(msg.sender, tokenId, activity.score, tierFor(activity.score), activity.snapshotBlock, activity.snapshotHash); } function tierFor(uint8 score) public pure returns (uint8) { if (score > 100) revert InvalidAttestation(); return score < 20 ? 0 : score < 40 ? 1 : score < 65 ? 2 : score < 85 ? 3 : 4; } function tierName(uint8 tier) public pure returns (string memory) { if (tier == 0) return "Recruit"; if (tier == 1) return "Scout"; if (tier == 2) return "Ranger"; if (tier == 3) return "Warden"; if (tier == 4) return "Legend"; revert InvalidAttestation(); } function tokenURI(uint256 tokenId) public view override returns (string memory) { _requireOwned(tokenId); Pass memory p = passes[tokenId]; uint8 tier = tierFor(p.score); string[5] memory files = ["recruit", "scout", "ranger", "warden", "legend"]; bytes memory metadata = abi.encodePacked( '{"name":"Outlaw Pass #', tokenId.toString(), '","description":"A wallet-bound OutlawHood identity on Robinhood Chain. Activity tier is fixed at mint under policy v1. Tier supply is not capped. Historical activity is attested by the OutlawHood scoring service.","image":"https://outlawhood.xyz/assets/passes/', files[tier], '.jpg","external_url":"https://outlawhood.xyz/#outlaw-pass","attributes":[{"trait_type":"Tier","value":"', tierName(tier), '"},{"trait_type":"Hood Score","value":', uint256(p.score).toString(), ',"max_value":100},{"trait_type":"Snapshot Block","value":', uint256(p.snapshotBlock).toString(), '},{"trait_type":"Chain","value":"Robinhood Chain 4663"},{"trait_type":"Transferability","value":"Wallet-bound"},{"trait_type":"Policy","value":"Activity v1"}],"properties":{"art_sha256":"', Strings.toHexString(uint256(artHashes[tier]),32), '","snapshot_hash":"', Strings.toHexString(uint256(p.snapshotHash),32), '"}}' ); return string.concat("data:application/json;base64,", Base64.encode(metadata)); } function locked(uint256 tokenId) external view returns (bool) { _requireOwned(tokenId); return true; } function supportsInterface(bytes4 interfaceId) public view override returns (bool) { return interfaceId == 0xb45a3c0e || super.supportsInterface(interfaceId); } function approve(address, uint256) public pure override { revert WalletBound(); } function setApprovalForAll(address, bool) public pure override { revert WalletBound(); } function _update(address to, uint256 tokenId, address auth) internal override returns (address) { if (_ownerOf(tokenId) != address(0)) revert WalletBound(); return super._update(to, tokenId, auth); } }