// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title NoN Bond Privacy NFT /// @notice One NFT per cloaked bond. The token carries the commitment and nothing /// else: no asset, no amount, no history. Retiring a bond publishes its /// nullifier, the same commitment/nullifier construction Zcash pioneered, /// so the set stays honest without ever opening. /// @dev Interlinked with $n0n: cloaking requires the minter to hold the token. interface IERC20Minimal { function balanceOf(address account) external view returns (uint256); } interface IERC721Receiver { function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external returns (bytes4); } contract NonBondNFT { string public constant name = "NoN Bond"; string public constant symbol = "n0nBOND"; IERC20Minimal public immutable non; // the $n0n token this collection is interlinked with uint256 public nextId = 1; uint256 public totalCloaked; uint256 public totalRetired; mapping(uint256 => address) private _owner; mapping(address => uint256) private _balance; mapping(uint256 => address) public getApproved; mapping(address => mapping(address => bool)) public isApprovedForAll; mapping(uint256 => bytes32) public commitmentOf; // tokenId => cloaked commitment mapping(bytes32 => bool) public commitmentCloaked; // a commitment mints at most once mapping(bytes32 => bool) public nullifierUsed; // Zcash-style spend markers event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); event ApprovalForAll(address indexed owner, address indexed operator, bool approved); event Cloaked(uint256 indexed tokenId, bytes32 indexed commitment, address indexed holder); event Retired(uint256 indexed tokenId, bytes32 indexed nullifier); constructor(address nonToken) { non = IERC20Minimal(nonToken); } // ---- shielded set ---- /// @notice Mint the privacy NFT for a cloaked bond. Only the commitment goes onchain. function cloakBond(bytes32 commitment) external returns (uint256 tokenId) { require(commitment != bytes32(0), "empty commitment"); require(!commitmentCloaked[commitment], "commitment already cloaked"); require(address(non) == address(0) || non.balanceOf(msg.sender) > 0, "hold $n0n to cloak"); tokenId = nextId++; commitmentCloaked[commitment] = true; commitmentOf[tokenId] = commitment; _owner[tokenId] = msg.sender; unchecked { _balance[msg.sender]++; totalCloaked++; } emit Transfer(address(0), msg.sender, tokenId); emit Cloaked(tokenId, commitment, msg.sender); } /// @notice Retire a bond: burn its NFT and publish the nullifier. The receipt /// holder computes the nullifier offchain; publishing it proves the /// bond is spent without revealing what it was. function retire(uint256 tokenId, bytes32 nullifier) external { require(_owner[tokenId] == msg.sender, "not the holder"); require(nullifier != bytes32(0), "empty nullifier"); require(!nullifierUsed[nullifier], "nullifier used"); nullifierUsed[nullifier] = true; delete getApproved[tokenId]; delete commitmentOf[tokenId]; _owner[tokenId] = address(0); unchecked { _balance[msg.sender]--; totalRetired++; } emit Transfer(msg.sender, address(0), tokenId); emit Retired(tokenId, nullifier); } // ---- ERC-721 ---- function ownerOf(uint256 tokenId) public view returns (address o) { o = _owner[tokenId]; require(o != address(0), "no token"); } function balanceOf(address a) external view returns (uint256) { require(a != address(0), "zero address"); return _balance[a]; } function approve(address to, uint256 tokenId) external { address o = ownerOf(tokenId); require(msg.sender == o || isApprovedForAll[o][msg.sender], "not authorized"); getApproved[tokenId] = to; emit Approval(o, to, tokenId); } function setApprovalForAll(address op, bool ok) external { isApprovedForAll[msg.sender][op] = ok; emit ApprovalForAll(msg.sender, op, ok); } function transferFrom(address from, address to, uint256 tokenId) public { address o = ownerOf(tokenId); require(o == from, "wrong from"); require(to != address(0), "zero to"); require( msg.sender == o || msg.sender == getApproved[tokenId] || isApprovedForAll[o][msg.sender], "not authorized" ); delete getApproved[tokenId]; unchecked { _balance[from]--; _balance[to]++; } _owner[tokenId] = to; emit Transfer(from, to, tokenId); } function safeTransferFrom(address from, address to, uint256 tokenId) external { safeTransferFrom(from, to, tokenId, ""); } function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public { transferFrom(from, to, tokenId); if (to.code.length > 0) { require( IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data) == IERC721Receiver.onERC721Received.selector, "unsafe receiver" ); } } function supportsInterface(bytes4 id) external pure returns (bool) { return id == 0x01ffc9a7 || id == 0x80ac58cd || id == 0x5b5e139f; // 165, 721, 721Metadata } // ---- fully onchain, fully private metadata ---- function tokenURI(uint256 tokenId) external view returns (string memory) { ownerOf(tokenId); // must exist bytes32 c = commitmentOf[tokenId]; string memory ch = _hex(c); string memory svg = string(abi.encodePacked( "", "", "", "", "", "0x", _short(ch), "", "NON BOND - CLOAKED", "" )); string memory json = string(abi.encodePacked( '{"name":"NoN Bond #', _num(tokenId), '","description":"A cloaked bond on Robinhood Chain. This token carries the commitment and nothing else.",', '"attributes":[{"trait_type":"commitment","value":"0x', ch, '"}],', '"image":"data:image/svg+xml;base64,', _b64(bytes(svg)), '"}' )); return string(abi.encodePacked("data:application/json;base64,", _b64(bytes(json)))); } // ---- pure helpers ---- bytes16 private constant HEX = 0x30313233343536373839616263646566; string private constant B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; function _hex(bytes32 v) private pure returns (string memory) { bytes memory s = new bytes(64); for (uint256 i = 0; i < 32; i++) { s[i * 2] = HEX[uint8(v[i]) >> 4]; s[i * 2 + 1] = HEX[uint8(v[i]) & 0x0f]; } return string(s); } function _short(string memory h) private pure returns (string memory) { bytes memory b = bytes(h); bytes memory s = new bytes(19); for (uint256 i = 0; i < 8; i++) s[i] = b[i]; s[8] = "."; s[9] = "."; s[10] = "."; for (uint256 i = 0; i < 8; i++) s[11 + i] = b[56 + i]; return string(s); } function _num(uint256 v) private pure returns (string memory) { if (v == 0) return "0"; uint256 t = v; uint256 d; while (t > 0) { d++; t /= 10; } bytes memory s = new bytes(d); while (v > 0) { s[--d] = bytes1(uint8(48 + v % 10)); v /= 10; } return string(s); } function _b64(bytes memory data) private pure returns (string memory) { if (data.length == 0) return ""; bytes memory table = bytes(B64); uint256 encLen = 4 * ((data.length + 2) / 3); bytes memory out = new bytes(encLen); uint256 i; uint256 j; while (i + 3 <= data.length) { uint256 n = (uint256(uint8(data[i])) << 16) | (uint256(uint8(data[i+1])) << 8) | uint256(uint8(data[i+2])); out[j++] = table[(n >> 18) & 63]; out[j++] = table[(n >> 12) & 63]; out[j++] = table[(n >> 6) & 63]; out[j++] = table[n & 63]; i += 3; } if (data.length - i == 1) { uint256 n = uint256(uint8(data[i])) << 16; out[j++] = table[(n >> 18) & 63]; out[j++] = table[(n >> 12) & 63]; out[j++] = "="; out[j++] = "="; } else if (data.length - i == 2) { uint256 n = (uint256(uint8(data[i])) << 16) | (uint256(uint8(data[i+1])) << 8); out[j++] = table[(n >> 18) & 63]; out[j++] = table[(n >> 12) & 63]; out[j++] = table[(n >> 6) & 63]; out[j++] = "="; } return string(out); } }