diff --git a/contracts/SessionNameService.sol b/contracts/SessionNameService.sol new file mode 100644 index 0000000..de9194b --- /dev/null +++ b/contracts/SessionNameService.sol @@ -0,0 +1,434 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import {ERC721BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ISessionNameService} from "./interfaces/ISessionNameService.sol"; + +/** + * @title SessionNameService (SNS) + * @notice L2 name service contract storing names as ERC-721 NFTs. + * @author https://getsession.org/ + * @dev Names must contain only valid Base64 characters (A-Z, a-z, 0-9, +, /). + * @dev Registration requires REGISTERER_ROLE or DEFAULT_ADMIN_ROLE. + * @dev Optional renewal/expiration mechanism managed by admin. + * @dev This contract is upgradeable using UUPS pattern. + */ +contract SessionNameService is + ISessionNameService, + ERC721Upgradeable, + ERC721BurnableUpgradeable, + AccessControlUpgradeable, + UUPSUpgradeable { + using Strings for uint256; + using SafeERC20 for IERC20; + + // Stores NFT metadata associated with a registered name. + struct NameAssets { + uint256 id; // Token ID (keccak256 hash of the name) + uint256 renewals; // Timestamp of the last renewal (or registration if never renewed) + } + + struct TextRecords { + string sessionName; // Type 1 + string lokinetName; // Type 3 + } + + // Stores the original name associated with a token ID. + // Necessary for cleanup during burn/expire operations. + struct LinkedNames { + string name; + } + + // Total number of currently registered names (active NFTs). + uint256 private totalSupply_; + // Role required to register names or expire them. + bytes32 public REGISTERER_ROLE; + // Default duration for which a name registration/renewal is valid. + uint256 public expiration; + // Base URI for constructing token URIs (metadata). + string public baseTokenURI; + // Flag controlled by admin to enable/disable the renewal and expiration features. + bool public allowRenewals; + + // Maps name string to its corresponding NFT asset data. + mapping(string => NameAssets) public namesToAssets; + // Maps token ID (hash of name) back to its original name string. + mapping(uint256 => LinkedNames) public idsToNames; + // Maps token ID to its associated text records. + mapping(uint256 => TextRecords) public tokenIdToTextRecord; + + // Fee-related state variables + IERC20 public paymentToken; + uint256 public registrationFee; + uint256 public transferFee; + + // Custom Errors + error NameNotRegistered(); + error NotNameOwner(); + error RenewalPeriodNotOver(); + error NullName(); + error UnsupportedCharacters(); + error NameAlreadyRegistered(); + error NotAuthorized(); + error RenewalsDisabled(); + error InvalidExpirationDuration(); + error ExpirationDurationTooLong(); + error InvalidRecordType(); + error InvalidFee(); + error InvalidTokenAddress(); + error InsufficientPayment(); + error TransferFailed(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /** + * @notice Initializes the SessionNameService contract. + * @param baseURI Base URI for token metadata. + * @dev Grants DEFAULT_ADMIN_ROLE to the deployer. + */ + function initialize(string memory baseURI) external initializer { + __ERC721_init("SessionNameService", "SNS"); + __ERC721Burnable_init(); + __AccessControl_init(); + __UUPSUpgradeable_init(); + + baseTokenURI = baseURI; + expiration = 365 days; + totalSupply_ = 0; + REGISTERER_ROLE = keccak256("REGISTERER_ROLE"); + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + /** + * @notice Authorizes an upgrade to a new implementation. + * @dev Only callable by the admin. + * @param newImplementation Address of the new implementation contract. + */ + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} + + /** + * @notice Resolves a name to its associated text record of a specific type. + * @param _name The name to resolve. + * @param recordType The type of record to retrieve (1 for session, 3 for lokinet). + * @return string The text record, reverts if not registered and returns an empty string if not set + */ + function resolve(string memory _name, uint8 recordType) external view override returns (string memory) { + uint256 hashOfName = uint256(keccak256(abi.encodePacked(_name))); + + if (bytes(idsToNames[hashOfName].name).length == 0) { + revert NameNotRegistered(); + } + + TextRecords storage records = tokenIdToTextRecord[hashOfName]; + + if (recordType == 1) { + return records.sessionName; + } else if (recordType == 3) { + return records.lokinetName; + } else { + return ""; + } + } + + /** + * @notice Returns the total number of registered names (NFTs). + */ + function totalSupply() external view returns (uint256) { + return totalSupply_; + } + + /** + * @notice Renews a name registration, updating its renewal timestamp. + * @param _name Name to renew. + * @dev Requires allowRenewals to be true. + * @dev Caller must be the owner of the name NFT. + */ + function renewName(string memory _name) external isRenewalsAllowed { + NameAssets storage asset = namesToAssets[_name]; + + if (asset.id == 0) revert NameNotRegistered(); + address owner = _requireOwned(asset.id); + if (owner != msg.sender) revert ERC721IncorrectOwner(msg.sender, asset.id, owner); + + if (registrationFee > 0) { + if (address(paymentToken) == address(0)) revert InvalidTokenAddress(); + paymentToken.safeTransferFrom(msg.sender, address(this), registrationFee); + } + + uint256 expirationTime = asset.renewals + expiration; + if (expirationTime <= block.timestamp) { + asset.renewals = block.timestamp; + } else { + asset.renewals = expirationTime; + } + + emit NameRenewed(_name, owner, asset.renewals); + } + + /** + * @notice Burns a name NFT if its expiration period has passed. + * @param _name Name to expire. + * @dev Requires allowRenewals to be true and caller to have REGISTERER_ROLE. + * @dev Cleans up all relevant storage entries when successful. + */ + function expireName(string memory _name) external isRenewalsAllowed onlyRegisterer { + NameAssets memory asset = namesToAssets[_name]; + uint256 tokenId = asset.id; + + if (tokenId == 0) revert NameNotRegistered(); + if (asset.renewals + expiration >= block.timestamp) revert RenewalPeriodNotOver(); + + address owner = ownerOf(tokenId); + delete idsToNames[tokenId]; + delete namesToAssets[_name]; + delete tokenIdToTextRecord[tokenId]; + + emit NameExpired(_name, owner, tokenId); + + _burn(tokenId); // This handles the Transfer event. + // Explicitly decrement supply after successful expiration. + if (totalSupply_ > 0) { + // Prevent underflow + totalSupply_--; + } + } + + /** + * @notice Updates the base URI for token metadata. + * @param baseURI_ The new base URI string. + * @dev Requires DEFAULT_ADMIN_ROLE. + */ + function setBaseTokenURI(string memory baseURI_) external onlyRole(DEFAULT_ADMIN_ROLE) { + baseTokenURI = baseURI_; + } + + /** + * @notice Enables or disables the renewal and expiration features. + * @dev Requires DEFAULT_ADMIN_ROLE. + */ + function flipRenewals() external onlyRole(DEFAULT_ADMIN_ROLE) { + allowRenewals = !allowRenewals; + } + + /** + * @notice Sets the expiration duration for names. + * @param newExpirationDuration The new duration in seconds. + * @dev Requires DEFAULT_ADMIN_ROLE. + * @dev Duration must be between 30 days and 100 years. + */ + function setExpirationDuration(uint256 newExpirationDuration) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newExpirationDuration < 30 days) revert InvalidExpirationDuration(); + if (newExpirationDuration > 100 * 365 days) revert ExpirationDurationTooLong(); + + expiration = newExpirationDuration; + } + + /** + * @notice Registers a single name and mints the corresponding NFT. + * @param to The address to receive the NFT. + * @param _name The name to register (must be valid Base64 characters). + * @return uint256 The token ID of the newly minted NFT. + * @dev Validates name (non-empty, Base64, unique). + */ + function registerName(address to, string memory _name) public onlyRegisterer returns (uint256) { + if (registrationFee > 0) { + if (address(paymentToken) == address(0)) revert InvalidTokenAddress(); + paymentToken.safeTransferFrom(to, address(this), registrationFee); + } + + if (bytes(_name).length == 0) revert NullName(); + if (!isValidBase64(_name)) revert UnsupportedCharacters(); + if (namesToAssets[_name].id != 0) revert NameAlreadyRegistered(); + + uint256 newTokenId = uint256(keccak256(abi.encodePacked(_name))); + namesToAssets[_name] = NameAssets(newTokenId, block.timestamp); + idsToNames[newTokenId].name = _name; + tokenIdToTextRecord[newTokenId] = TextRecords("", ""); + + _safeMint(to, newTokenId); + totalSupply_++; + emit NameRegistered(_name, to, newTokenId); + return newTokenId; + } + + /** + * @inheritdoc ERC721Upgradeable + * @dev Constructs the token URI by concatenating the `baseTokenURI` and `tokenId`. + * @dev Returns an empty string if `baseTokenURI` is not set. + * @dev Reverts with `ERC721NonexistentToken` if `tokenId` does not exist. + */ + function tokenURI(uint256 tokenId) public view override returns (string memory) { + _requireOwned(tokenId); + + string memory base = baseTokenURI; + return bytes(base).length > 0 ? string.concat(base, tokenId.toString()) : ""; + } + + /** + * @inheritdoc ERC721Upgradeable + * @dev Declares support for ISessionNameService, ERC721, and AccessControl interfaces. + */ + function supportsInterface(bytes4 interfaceId) public view override(ERC721Upgradeable, AccessControlUpgradeable) returns (bool) { + return type(ISessionNameService).interfaceId == interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @inheritdoc ERC721BurnableUpgradeable + * @dev Extends the standard burn functionality to also clean up SNS-specific storage. + * @dev Deletes entries from `namesToAssets` and `idsToNames`. + * @dev Emits `NameDeleted` event. + * @dev Decrements `totalSupply_` after successful burn. + */ + function burn(uint256 tokenId) public override(ERC721BurnableUpgradeable) { + address owner = _requireOwned(tokenId); + string memory _name = idsToNames[tokenId].name; + + delete idsToNames[tokenId]; + delete tokenIdToTextRecord[tokenId]; + if (bytes(_name).length > 0) { + delete namesToAssets[_name]; + } + + emit NameDeleted(_name, owner, tokenId); + + super.burn(tokenId); // Handles Transfer event and calls internal OZ _burn. + // Decrement supply after successful burn. + if (totalSupply_ > 0) { + // Prevent underflow + totalSupply_--; + } + } + + /** + * @notice Checks if a string contains only valid Base64 characters (A-Z, a-z, 0-9, +, /) and optional padding (=). + * @param str The input string. + * @return bool True if the string is valid, false otherwise. + */ + function isValidBase64(string memory str) private pure returns (bool) { + bytes memory b = bytes(str); + for (uint i = 0; i < b.length; i++) { + bytes1 char = b[i]; + // Check if the character is A-Z, a-z, 0-9, +, /, or = + if ( + !(char >= 0x41 && char <= 0x5A) && // A-Z + !(char >= 0x61 && char <= 0x7A) && // a-z + !(char >= 0x30 && char <= 0x39) && // 0-9 + char != 0x2B && // + + char != 0x2F && // / + char != 0x3D // = + ) { + return false; + } + } + return true; + } + + /** + * @notice Sets a specific type of text record associated with a token ID. + * @param tokenId The token ID to set the record for. + * @param recordType The type of record (1 for session, 3 for lokinet). + * @param text The text data to associate with the token ID. + * @dev Only the owner or approved operators can set the text record. + */ + function setTextRecord(uint256 tokenId, uint8 recordType, string calldata text) external { + address owner = ownerOf(tokenId); + if (msg.sender != owner && getApproved(tokenId) != msg.sender && !isApprovedForAll(owner, msg.sender)) { + revert NotAuthorized(); + } + + if (recordType == 1) { + tokenIdToTextRecord[tokenId].sessionName = text; + } else if (recordType == 3) { + tokenIdToTextRecord[tokenId].lokinetName = text; + } else { + revert InvalidRecordType(); + } + + emit TextRecordUpdated(tokenId, recordType, text); + } + + /** + * @notice Modified transfer function to include fee payment + * @param from Address to transfer from + * @param to Address to transfer to + * @param tokenId Token ID to transfer + */ + function transferFrom(address from, address to, uint256 tokenId) public override(ERC721Upgradeable) { + if (!_isAuthorized(from, msg.sender, tokenId)) { + revert ERC721InsufficientApproval(msg.sender, tokenId); + } + if (from != _requireOwned(tokenId)) { + revert ERC721IncorrectOwner(from, tokenId, _requireOwned(tokenId)); + } + if (transferFee > 0) { + if (address(paymentToken) == address(0)) revert InvalidTokenAddress(); + paymentToken.safeTransferFrom(msg.sender, address(this), transferFee); + } + super.transferFrom(from, to, tokenId); + } + + /** + * @notice Sets the payment token for registration and transfer fees + * @param _paymentToken Address of the ERC20 token to use for payments + * @dev Requires DEFAULT_ADMIN_ROLE + */ + function setPaymentToken(address _paymentToken) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_paymentToken == address(0)) revert InvalidTokenAddress(); + paymentToken = IERC20(_paymentToken); + emit PaymentTokenSet(_paymentToken); + } + + /** + * @notice Sets the registration and transfer fees + * @param _registrationFee Amount of tokens required for registration + * @param _transferFee Amount of tokens required for transfers + * @dev Requires DEFAULT_ADMIN_ROLE + */ + function setFees(uint256 _registrationFee, uint256 _transferFee) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_registrationFee == 0 && _transferFee == 0) revert InvalidFee(); + registrationFee = _registrationFee; + transferFee = _transferFee; + emit FeesSet(_registrationFee, _transferFee); + } + + /** + * @notice Withdraws collected fees to the specified address + * @param to Address to receive the collected fees + * @dev Requires DEFAULT_ADMIN_ROLE + */ + function withdrawFees(address to) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (to == address(0)) revert InvalidTokenAddress(); + uint256 balance = paymentToken.balanceOf(address(this)); + if (balance == 0) revert InsufficientPayment(); + + paymentToken.safeTransfer(to, balance); + emit FeesWithdrawn(to, balance); + } + + // --- Modifiers --- + + /** + * @dev Modifier restricting to REGISTERER_ROLE or DEFAULT_ADMIN_ROLE. + */ + modifier onlyRegisterer() { + if (!(hasRole(REGISTERER_ROLE, msg.sender) || hasRole(DEFAULT_ADMIN_ROLE, msg.sender))) revert NotAuthorized(); + _; + } + + /** + * @dev Modifier requiring the renewal feature to be enabled. + */ + modifier isRenewalsAllowed() { + if (!allowRenewals) revert RenewalsDisabled(); + _; + } +} diff --git a/contracts/interfaces/ISessionNameService.sol b/contracts/interfaces/ISessionNameService.sol new file mode 100644 index 0000000..9fb2266 --- /dev/null +++ b/contracts/interfaces/ISessionNameService.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/** + * @title ISessionNameService Interface + * @notice Interface for the Session Name Service (SNS) contract, which manages + * name registrations as ERC-721 NFTs. + */ +interface ISessionNameService { + // --- Events expected to be emitted by implementations --- + + /** + * @dev Emitted when a name is successfully registered. + * @param name The lowercase name registered. + * @param owner The address receiving the name NFT. + * @param tokenId The token ID (keccak256 hash of the name) of the NFT. + */ + event NameRegistered(string indexed name, address indexed owner, uint256 tokenId); + + /** + * @dev Emitted when a name NFT is burned by its owner. + * @param name The lowercase name associated with the burned token. + * @param owner The address of the owner who initiated the burn. + * @param tokenId The token ID of the burned NFT. + */ + event NameDeleted(string indexed name, address indexed owner, uint256 tokenId); + + /** + * @dev Emitted when a name's registration is successfully renewed. + * @param name The lowercase name renewed. + * @param owner The address of the name owner. + * @param timestamp The block timestamp when the renewal occurred. + */ + event NameRenewed(string indexed name, address indexed owner, uint256 timestamp); + + /** + * @dev Emitted when an expired name NFT is burned by an authorized account (Registerer/Admin). + * @param name The lowercase name associated with the expired token. + * @param owner The address of the owner whose name expired. + * @param tokenId The token ID of the expired NFT that was burned. + */ + event NameExpired(string indexed name, address indexed owner, uint256 tokenId); + + /** + * @dev Emitted when the text record for a name NFT is updated. + * @param tokenId The token ID of the NFT whose record was updated. + * @param recordType The type of record updated (1 for session, 3 for lokinet). + * @param text The new text record string. + */ + event TextRecordUpdated(uint256 indexed tokenId, uint8 indexed recordType, string text); + + /** + * @dev Emitted when the payment token is set. + * @param token The address of the new payment token. + */ + event PaymentTokenSet(address indexed token); + + /** + * @dev Emitted when registration and transfer fees are set. + * @param registrationFee The new registration fee amount. + * @param transferFee The new transfer fee amount. + */ + event FeesSet(uint256 registrationFee, uint256 transferFee); + + /** + * @dev Emitted when collected fees are withdrawn. + * @param to The address that received the fees. + * @param amount The amount of fees withdrawn. + */ + event FeesWithdrawn(address indexed to, uint256 amount); + + // --- Functions --- + + /** + * @notice Resolves a name to its associated text record of a specific type. + * @param _name The name to resolve (case-insensitive). + * @param recordType The type of record to retrieve (1 for session, 3 for lokinet). + * @return string The text record associated with the name and type. + * @dev Implementations should convert the name to lowercase before lookup. + * @dev Reverts if the name is not registered. + * @dev Returns an empty string if the name is registered but the specific record type is not set. + */ + function resolve(string memory _name, uint8 recordType) external view returns (string memory); + + /** + * @notice Registers a new name, minting an ERC-721 NFT to the specified owner. + * @param to The address that will own the newly registered name NFT. + * @param _name The name to register (case-insensitive, must be alphanumeric). + * @return uint256 The token ID of the newly minted name NFT. + * @dev Implementations typically require specific roles (e.g., REGISTERER_ROLE) for registration. + * @dev Should convert the name to lowercase and ensure it's alphanumeric and not already registered. + * @dev Should emit a `NameRegistered` event upon successful registration. + */ + function registerName(address to, string memory _name) external returns (uint256); +} diff --git a/scripts/deploy-session-name-service.js b/scripts/deploy-session-name-service.js new file mode 100644 index 0000000..3cca0d0 --- /dev/null +++ b/scripts/deploy-session-name-service.js @@ -0,0 +1,106 @@ +// This script deploys the SessionNameService contract, which is an ERC721-based +// name service for Session. It sets up the base URI for token metadata. +// +const hre = require("hardhat"); +const chalk = require("chalk"); + +const ethers = hre.ethers; +const { upgrades } = require("hardhat"); + +async function main() { + // Base URI for token metadata - this should be updated to your actual metadata URI + const BASE_URI = "https://api.getsession.org/sns/metadata/"; + + const args = { + BASE_URI, + }; + + await deploySessionNameService(args); +} + +async function deploySessionNameService(args = {}, verify = true) { + [owner] = await ethers.getSigners(); + const ownerAddress = await owner.getAddress(); + + const networkName = hre.network.name; + console.log("Deploying SessionNameService contract to:", chalk.yellow(networkName)); + + if (verify) { + let apiKey; + if (typeof hre.config.etherscan?.apiKey === "object") { + apiKey = hre.config.etherscan.apiKey[networkName]; + } else { + apiKey = hre.config.etherscan?.apiKey; + } + if (!apiKey || apiKey == "") { + console.error( + chalk.red("Error: API key for contract verification is missing."), + ); + console.error( + "Please set it in your Hardhat configuration under 'etherscan.apiKey'.", + ); + process.exit(1); + } + } + + const BASE_URI = args.BASE_URI; + + const SessionNameService = await ethers.getContractFactory("SessionNameService", owner); + let sessionNameService; + + try { + console.log("Deploying SessionNameService as an upgradeable contract with transparent proxy..."); + sessionNameService = await upgrades.deployProxy(SessionNameService, [BASE_URI] ); + await sessionNameService.waitForDeployment(); + + console.log("Proxy deployed to:", chalk.greenBright(await sessionNameService.getAddress())); + } catch (error) { + console.error("Failed to deploy SessionNameService contract:", error); + process.exit(1); + } + + console.log( + " ", + chalk.cyan(`SessionNameService Contract`), + "deployed to:", + chalk.greenBright(await sessionNameService.getAddress()), + "on network:", + chalk.yellow(networkName), + ); + console.log( + " ", + "Base URI set to:", + chalk.green(BASE_URI), + ); + + if (verify) { + console.log(chalk.yellow("\n--- Verifying SessionNameService Implementation ---\n")); + console.log("Waiting 6 confirmations to ensure etherscan has processed tx"); + await sessionNameService.deploymentTransaction().wait(6); + console.log("Finished Waiting"); + + // Get implementation address for verification + const implementationAddress = await upgrades.erc1967.getImplementationAddress( + await sessionNameService.getAddress() + ); + + try { + await hre.run("verify:verify", { + address: implementationAddress, + constructorArguments: [], + contract: "contracts/SessionNameService.sol:SessionNameService", + force: true, + }); + } catch (error) { + console.error(chalk.red("Verification failed:"), error); + } + console.log(chalk.green("Contract verification complete.")); + } + + return { sessionNameService }; +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/register-session-name.js b/scripts/register-session-name.js new file mode 100644 index 0000000..324f5af --- /dev/null +++ b/scripts/register-session-name.js @@ -0,0 +1,165 @@ +// This script registers a name using the SessionNameService contract. +// It requires the contract address and the name to register. +// +const hre = require("hardhat"); +const chalk = require("chalk"); + +const ethers = hre.ethers; + +async function main() { + [owner] = await ethers.getSigners(); + // These values should be updated for your specific deployment + const SESSION_NAME_SERVICE_ADDRESS = "0xF698CCF07208D14288c4A92E0bE9930D6F41BD7c"; // Replace with your deployed contract address + //const NAME_TO_REGISTER = "example"; // Replace with the name you want to register + const NAME_TO_REGISTER = "rRpbk0XnuO7dI5tuD1DCPifYwnIpTTYeI8TEyAYkCdM="; // Replace with the name you want to register + const RECIPIENT_ADDRESS = await owner.getAddress(); // Replace with the address that should own the name + const TEXT_RECORD = "Hello from a hashsed Session Name Service name - sean!"; // Text record to set for the name + + const args = { + SESSION_NAME_SERVICE_ADDRESS, + NAME_TO_REGISTER, + RECIPIENT_ADDRESS, + TEXT_RECORD, + }; + + await registerSessionName(args); +} + +async function registerSessionName(args = {}) { + [owner] = await ethers.getSigners(); + const ownerAddress = await owner.getAddress(); + + const networkName = hre.network.name; + console.log("Registering Session name on network:", chalk.yellow(networkName)); + + const SESSION_NAME_SERVICE_ADDRESS = args.SESSION_NAME_SERVICE_ADDRESS; + const NAME_TO_REGISTER = args.NAME_TO_REGISTER; + const RECIPIENT_ADDRESS = args.RECIPIENT_ADDRESS || ownerAddress; + const TEXT_RECORD = args.TEXT_RECORD || ""; + + if (!SESSION_NAME_SERVICE_ADDRESS || SESSION_NAME_SERVICE_ADDRESS === "0x...") { + console.error( + chalk.red("Error: SessionNameService contract address is not set."), + ); + console.error( + "Please update the SESSION_NAME_SERVICE_ADDRESS constant in this script.", + ); + process.exit(1); + } + + if (!NAME_TO_REGISTER || NAME_TO_REGISTER === "example") { + console.error( + chalk.red("Error: Name to register is not set."), + ); + console.error( + "Please update the NAME_TO_REGISTER constant in this script.", + ); + process.exit(1); + } + + // Get the contract instance + const SessionNameService = await ethers.getContractFactory("SessionNameService"); + const sessionNameService = SessionNameService.attach(SESSION_NAME_SERVICE_ADDRESS); + + console.log( + " ", + chalk.cyan(`SessionNameService Contract`), + "at:", + chalk.greenBright(SESSION_NAME_SERVICE_ADDRESS), + ); + console.log( + " ", + "Registering name:", + chalk.green(NAME_TO_REGISTER), + "for address:", + chalk.green(RECIPIENT_ADDRESS), + ); + console.log( + " ", + "Text record to set:", + chalk.green(TEXT_RECORD || "(empty)"), + ); + + try { + // Register the name + const tx = await sessionNameService.registerName(RECIPIENT_ADDRESS, NAME_TO_REGISTER); + console.log("Registration transaction sent:", chalk.yellow(tx.hash)); + + // Wait for the transaction to be mined + const receipt = await tx.wait(); + console.log("Registration confirmed in block:", chalk.green(receipt.blockNumber)); + + // Get the token ID for the registered name + const nameHash = ethers.keccak256(ethers.toUtf8Bytes(NAME_TO_REGISTER.toLowerCase())); + const tokenId = BigInt(nameHash); + + console.log( + " ", + chalk.green("Successfully registered name:"), + chalk.cyan(NAME_TO_REGISTER), + "with token ID:", + chalk.green(tokenId.toString()), + ); + + // Check if the name is correctly assigned to the recipient + const nameOwner = await sessionNameService.ownerOf(tokenId); + console.log( + " ", + "Name owner verified:", + chalk.green(nameOwner), + ); + + // Set the text record if provided + if (TEXT_RECORD) { + console.log(chalk.yellow("\n--- Setting Text Record ---\n")); + + // Check if the caller is the owner or has approval + if (nameOwner.toLowerCase() !== ownerAddress.toLowerCase()) { + console.log("Checking if caller has approval to set text record..."); + const isApproved = await sessionNameService.isApprovedForAll(nameOwner, ownerAddress); + if (!isApproved) { + console.error( + chalk.red("Error: The caller is not the owner and does not have approval to set the text record."), + ); + console.error( + "Please ensure the caller is the owner or has been approved to set the text record.", + ); + process.exit(1); + } + } + + // Set the text record + const setTextTx = await sessionNameService.setTextRecord(tokenId, TEXT_RECORD); + console.log("Text record transaction sent:", chalk.yellow(setTextTx.hash)); + + // Wait for the transaction to be mined + const setTextReceipt = await setTextTx.wait(); + console.log("Text record confirmed in block:", chalk.green(setTextReceipt.blockNumber)); + + // Verify the text record was set correctly + const updatedTextRecord = await sessionNameService.resolve(NAME_TO_REGISTER); + console.log( + " ", + "Text record verified:", + chalk.green(updatedTextRecord), + ); + } else { + // Get the text record (should be empty by default) + const textRecord = await sessionNameService.resolve(NAME_TO_REGISTER); + console.log( + " ", + "Text record:", + chalk.green(textRecord || "(empty)"), + ); + } + + } catch (error) { + console.error("Failed to register name or set text record:", error); + process.exit(1); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/upgrade-session-name-service.js b/scripts/upgrade-session-name-service.js new file mode 100644 index 0000000..8ad03c0 --- /dev/null +++ b/scripts/upgrade-session-name-service.js @@ -0,0 +1,63 @@ +const hre = require("hardhat"); +const { ethers, upgrades } = require('hardhat'); +const chalk = require('chalk'); + +async function main() { + // Address of the proxy contract to upgrade - replace with your deployed proxy address + const PROXY_ADDRESS = "REPLACE_WITH_YOUR_DEPLOYED_PROXY_ADDRESS"; + + const networkName = hre.network.name; + console.log("Upgrading SessionNameService contract on:", chalk.yellow(networkName)); + + // Ensure we have API key to verify + let apiKey; + if (typeof hre.config.etherscan?.apiKey === 'object') { + apiKey = hre.config.etherscan.apiKey[networkName]; + } else { + apiKey = hre.config.etherscan?.apiKey; + } + if (!apiKey || apiKey == "") { + console.error(chalk.red("Error: API key for contract verification is missing.")); + console.error("Please set it in your Hardhat configuration under 'etherscan.apiKey'."); + process.exit(1); // Exit with an error code + } + + // Get the SessionNameService factory + const SessionNameService = await ethers.getContractFactory("SessionNameService"); + console.log('Upgrading SessionNameService proxy...'); + + try { + // Perform the upgrade + const sessionNameService = await upgrades.upgradeProxy(PROXY_ADDRESS, SessionNameService); + console.log('SessionNameService upgraded successfully'); + + // Get the new implementation address + const implementationAddress = await upgrades.erc1967.getImplementationAddress( + await sessionNameService.getAddress() + ); + console.log("New implementation address:", chalk.greenBright(implementationAddress)); + + // Verify the new implementation + console.log(chalk.yellow("\n--- Verifying new SessionNameService implementation ---\n")); + await sessionNameService.waitForDeployment(); + try { + await hre.run("verify:verify", { + address: implementationAddress, + constructorArguments: [], + contract: "contracts/SessionNameService.sol:SessionNameService", + force: true, + }); + console.log(chalk.green("Contract verification complete.")); + } catch (error) { + console.error(chalk.red("Verification failed:"), error); + } + } catch (error) { + console.error("Failed to upgrade SessionNameService:", error); + process.exit(1); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/test/unit-js/SessionNameServiceTest.js b/test/unit-js/SessionNameServiceTest.js new file mode 100644 index 0000000..9b51fc7 --- /dev/null +++ b/test/unit-js/SessionNameServiceTest.js @@ -0,0 +1,1428 @@ +const { + loadFixture, + time, +} = require('@nomicfoundation/hardhat-toolbox/network-helpers'); +const { expect } = require('chai'); +const { ethers } = require('hardhat'); +const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); + +// Helper function to calculate name hash (token ID) +function calculateNameHash(name) { + return ethers.keccak256(ethers.toUtf8Bytes(name)); +} + +describe('SessionNameService', function () { + const BASE_URI = 'https://api.example.com/sns/'; + const TEST_NAME_1 = 'alice'; + const TEST_NAME_2 = 'bob'; + const TEST_NAME_INVALID_CHARS = 'charlie!'; + const TEST_NAME_UPPER = 'ALICE'; // For case-insensitivity check + const TEST_NAME_MIXED = 'Alice'; + const TEST_NAME_WITH_PLUS = 'name+plus'; + const TEST_NAME_WITH_SLASH = 'name/slash'; + const TEST_NAME_WITH_EQUALS = 'name=='; // Example Base64 padding + const TEST_NAME_WITH_NUMBERS = 'name123'; + const TEST_NAME_INVALID_SPACE = 'name space'; + const TEST_NAME_INVALID_DASH = 'name-dash'; + const TEST_NAME_INVALID_UNICODE = '你好'; + + const INITIAL_EXPIRATION_DAYS = 365; + const ONE_DAY = 24 * 60 * 60; + const THIRTY_DAYS = 30 * ONE_DAY; + const YEAR_PLUS_ONE_DAY = (INITIAL_EXPIRATION_DAYS + 1) * ONE_DAY; + + // Record types + const SESSION_RECORD_TYPE = 1; + const LOKINET_RECORD_TYPE = 3; + + // Fee-related constants + const REGISTRATION_FEE = ethers.parseUnits("1.0", 9); + const TRANSFER_FEE = ethers.parseUnits("0.5", 9); + + async function deploySessionNameServiceFixture() { + const [owner, registerer, user1, user2, otherAccount] = + await ethers.getSigners(); + + // Deploy mock ERC20 token for testing + const MockToken = await ethers.getContractFactory("MockERC20"); + const mockToken = await MockToken.deploy("Mock Token", "MTK", ethers.parseUnits("1000000", 9)); + await mockToken.waitForDeployment(); + + const SessionNameService = await ethers.getContractFactory("SessionNameService", owner); + sns = await upgrades.deployProxy(SessionNameService, [BASE_URI] ); + await sns.waitForDeployment(); + + // Grant REGISTERER_ROLE to the 'registerer' account + const REGISTERER_ROLE = ethers.keccak256(ethers.toUtf8Bytes("REGISTERER_ROLE")); + await sns.connect(owner).grantRole(REGISTERER_ROLE, registerer.address); + + // Set up payment token and fees + await sns.connect(owner).setPaymentToken(await mockToken.getAddress()); + await sns.connect(owner).setFees(REGISTRATION_FEE, TRANSFER_FEE); + + // Mint tokens to users for testing + await mockToken.transfer(owner.address, ethers.parseUnits("1000", 9)); + await mockToken.transfer(registerer.address, ethers.parseUnits("1000", 9)); + await mockToken.transfer(user1.address, ethers.parseUnits("1000", 9)); + await mockToken.transfer(user2.address, ethers.parseUnits("1000", 9)); + + // Approve tokens for the contract + await mockToken.connect(owner).approve(await sns.getAddress(), ethers.parseUnits("1000", 9)); + await mockToken.connect(registerer).approve(await sns.getAddress(), ethers.parseUnits("1000", 9)); + await mockToken.connect(user1).approve(await sns.getAddress(), ethers.parseUnits("1000", 9)); + await mockToken.connect(user2).approve(await sns.getAddress(), ethers.parseUnits("1000", 9)); + + return { + sns, + mockToken, + owner, + registerer, + user1, + user2, + otherAccount, + REGISTERER_ROLE, + }; + } + + // --- Deployment and Initialization --- + describe('Deployment', function () { + it('Should set the correct ERC721 name and symbol', async function () { + const { sns } = await loadFixture(deploySessionNameServiceFixture); + expect(await sns.name()).to.equal('SessionNameService'); + expect(await sns.symbol()).to.equal('SNS'); + }); + + it('Should set the correct baseTokenURI', async function () { + const { sns } = await loadFixture(deploySessionNameServiceFixture); + expect(await sns.baseTokenURI()).to.equal(BASE_URI); + }); + + it('Should grant DEFAULT_ADMIN_ROLE to the deployer', async function () { + const { sns, owner } = await loadFixture(deploySessionNameServiceFixture); + const ADMIN_ROLE = await sns.DEFAULT_ADMIN_ROLE(); + expect(await sns.hasRole(ADMIN_ROLE, owner.address)).to.be.true; + }); + + it('Should initialize totalSupply to 0', async function () { + const { sns } = await loadFixture(deploySessionNameServiceFixture); + expect(await sns.totalSupply()).to.equal(0); + }); + + it('Should initialize expiration correctly', async function () { + const { sns } = await loadFixture(deploySessionNameServiceFixture); + expect(await sns.expiration()).to.equal(INITIAL_EXPIRATION_DAYS * ONE_DAY); + }); + + it('Should initialize allowRenewals to false', async function () { + const { sns } = await loadFixture(deploySessionNameServiceFixture); + expect(await sns.allowRenewals()).to.be.false; + }); + }); + + // --- Access Control --- + describe('Access Control', function () { + it('DEFAULT_ADMIN_ROLE can grant REGISTERER_ROLE', async function () { + const { sns, owner, otherAccount, REGISTERER_ROLE } = await loadFixture( + deploySessionNameServiceFixture + ); + await expect(sns.connect(owner).grantRole(REGISTERER_ROLE, otherAccount.address)) + .to.not.be.reverted; + expect(await sns.hasRole(REGISTERER_ROLE, otherAccount.address)).to.be.true; + }); + + it('Non-admin cannot grant REGISTERER_ROLE', async function () { + const { sns, otherAccount, user1, REGISTERER_ROLE } = await loadFixture( + deploySessionNameServiceFixture + ); + await expect( + sns.connect(otherAccount).grantRole(REGISTERER_ROLE, user1.address) + ).to.be.revertedWithCustomError(sns, 'AccessControlUnauthorizedAccount'); + }); + + it('DEFAULT_ADMIN_ROLE can revoke REGISTERER_ROLE', async function () { + const { sns, owner, registerer, REGISTERER_ROLE } = await loadFixture(deploySessionNameServiceFixture); + await sns.connect(owner).revokeRole(REGISTERER_ROLE, registerer.address); + expect(await sns.hasRole(REGISTERER_ROLE, registerer.address)).to.be.false; + }); + + it('Only REGISTERER_ROLE or ADMIN_ROLE can call registerName', async function () { + const { sns, registerer, owner, user1, user2, otherAccount } = await loadFixture( + deploySessionNameServiceFixture + ); + + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_1)) + .to.not.be.reverted; + + await expect(sns.connect(owner).registerName(user2.address, TEST_NAME_2)) + .to.not.be.reverted; + + await expect( + sns.connect(otherAccount).registerName(otherAccount.address, 'failname') + ).to.be.revertedWithCustomError(sns, 'NotAuthorized'); + }); + + it('Only REGISTERER_ROLE or ADMIN_ROLE can call expireName', async function () { + const { sns, owner, registerer, user1, otherAccount } = await loadFixture(deploySessionNameServiceFixture); + + await sns.connect(owner).flipRenewals(); + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + + await time.increase(YEAR_PLUS_ONE_DAY); + + await expect(sns.connect(otherAccount).expireName(TEST_NAME_1)) + .to.be.revertedWithCustomError(sns, 'NotAuthorized'); + + await expect(sns.connect(user1).expireName(TEST_NAME_1)) + .to.be.revertedWithCustomError(sns, 'NotAuthorized'); + + await expect(sns.connect(registerer).expireName(TEST_NAME_1)).to.not.be.reverted; + + await sns.connect(registerer).registerName(user1.address, TEST_NAME_2); + await time.increase(YEAR_PLUS_ONE_DAY); + + await expect(sns.connect(owner).expireName(TEST_NAME_2)).to.not.be.reverted; + }); + + it('Only ADMIN_ROLE can call setBaseTokenURI', async function () { + const { sns, owner, otherAccount } = await loadFixture( + deploySessionNameServiceFixture + ); + const newURI = 'ipfs://newcid/'; + await expect(sns.connect(owner).setBaseTokenURI(newURI)).to.not.be.reverted; + expect(await sns.baseTokenURI()).to.equal(newURI); + await expect( + sns.connect(otherAccount).setBaseTokenURI('ipfs://fail/') + ).to.be.revertedWithCustomError(sns, 'AccessControlUnauthorizedAccount'); + }); + + it('Only ADMIN_ROLE can call flipRenewals', async function () { + const { sns, owner, otherAccount } = await loadFixture( + deploySessionNameServiceFixture + ); + await expect(sns.connect(owner).flipRenewals()).to.not.be.reverted; + expect(await sns.allowRenewals()).to.be.true; + await expect(sns.connect(otherAccount).flipRenewals()) + .to.be.revertedWithCustomError(sns, 'AccessControlUnauthorizedAccount'); + }); + + it('Only ADMIN_ROLE can call setExpirationDuration', async function () { + const { sns, owner, otherAccount } = await loadFixture(deploySessionNameServiceFixture); + const validDuration = BigInt(60 * ONE_DAY); // 60 days (valid) + const tooShortDuration = BigInt(10 * ONE_DAY); // 10 days (invalid, < 30 days) + const tooLongDuration = BigInt(101 * 365 * ONE_DAY); // 101 years (invalid, > 100 years) + + await expect(sns.connect(owner).setExpirationDuration(validDuration)).to.not.be.reverted; + expect(await sns.expiration()).to.equal(validDuration); + + await expect( + sns.connect(otherAccount).setExpirationDuration(validDuration) + ).to.be.revertedWithCustomError(sns, 'AccessControlUnauthorizedAccount'); + + await expect( + sns.connect(owner).setExpirationDuration(tooShortDuration) + ).to.be.revertedWithCustomError(sns, 'InvalidExpirationDuration'); + + await expect( + sns.connect(owner).setExpirationDuration(tooLongDuration) + ).to.be.revertedWithCustomError(sns, 'ExpirationDurationTooLong'); + }); + }); + + // --- Name Registration --- + describe('Name Registration (registerName)', function () { + it('Should allow REGISTERER_ROLE to register a name', async function () { + const { sns, registerer, user1 } = await loadFixture( + deploySessionNameServiceFixture + ); + const tokenId = calculateNameHash(TEST_NAME_1); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_1)) + .to.emit(sns, 'NameRegistered') + .withArgs(TEST_NAME_1, user1.address, tokenId); + + expect(await sns.ownerOf(tokenId)).to.equal(user1.address); + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(""); + expect(await sns.balanceOf(user1.address)).to.equal(1); + expect(await sns.totalSupply()).to.equal(1); + + const asset = await sns.namesToAssets(TEST_NAME_1); + expect(asset.id).to.equal(tokenId); + const linkedName = await sns.idsToNames(tokenId); + expect(linkedName).to.equal(TEST_NAME_1); + }); + + it('Should handle case-sensitivity on registration', async function () { + const { sns, registerer, user1, user2 } = await loadFixture( + deploySessionNameServiceFixture + ); + const nameLower = TEST_NAME_1; // "alice" + const nameUpper = TEST_NAME_UPPER; // "ALICE" + const nameMixed = TEST_NAME_MIXED; // "Alice" + const tokenIdLower = calculateNameHash(nameLower); + const tokenIdUpper = calculateNameHash(nameUpper); + const tokenIdMixed = calculateNameHash(nameMixed); + + // Register lowercase + await expect(sns.connect(registerer).registerName(user1.address, nameLower)) + .to.emit(sns, 'NameRegistered') + .withArgs(nameLower, user1.address, tokenIdLower); + expect(await sns.ownerOf(tokenIdLower)).to.equal(user1.address); + expect(await sns.totalSupply()).to.equal(1); + + // Register uppercase - should succeed as it's a different name + await expect(sns.connect(registerer).registerName(user2.address, nameUpper)) + .to.emit(sns, 'NameRegistered') + .withArgs(nameUpper, user2.address, tokenIdUpper); + expect(await sns.ownerOf(tokenIdUpper)).to.equal(user2.address); + expect(await sns.totalSupply()).to.equal(2); + + // Register mixed case - should succeed + await expect(sns.connect(registerer).registerName(user1.address, nameMixed)) + .to.emit(sns, 'NameRegistered') + .withArgs(nameMixed, user1.address, tokenIdMixed); + expect(await sns.ownerOf(tokenIdMixed)).to.equal(user1.address); + expect(await sns.totalSupply()).to.equal(3); + + // Check resolution is case-sensitive + expect(await sns.resolve(nameLower, SESSION_RECORD_TYPE)).to.equal(""); + expect(await sns.resolve(nameUpper, SESSION_RECORD_TYPE)).to.equal(""); + expect(await sns.resolve(nameMixed, SESSION_RECORD_TYPE)).to.equal(""); + + // Check internal storage preserves case + const assetLower = await sns.namesToAssets(nameLower); + expect(assetLower.id).to.equal(tokenIdLower); + const linkedLower = await sns.idsToNames(tokenIdLower); + expect(linkedLower).to.equal(nameLower); + + const assetUpper = await sns.namesToAssets(nameUpper); + expect(assetUpper.id).to.equal(tokenIdUpper); + const linkedUpper = await sns.idsToNames(tokenIdUpper); + expect(linkedUpper).to.equal(nameUpper); + + const assetMixed = await sns.namesToAssets(nameMixed); + expect(assetMixed.id).to.equal(tokenIdMixed); + const linkedMixed = await sns.idsToNames(tokenIdMixed); + expect(linkedMixed).to.equal(nameMixed); + }); + + it('Should revert if registering an empty name', async function () { + const { sns, registerer, user1 } = await loadFixture( + deploySessionNameServiceFixture + ); + await expect( + sns.connect(registerer).registerName(user1.address, '') + ).to.be.revertedWithCustomError(sns, 'NullName'); + }); + + it('Should register names with valid Base64 characters', async function () { + const { sns, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_WITH_PLUS)).to.not.be.reverted; + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_WITH_SLASH)).to.not.be.reverted; + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_WITH_EQUALS)).to.not.be.reverted; + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_WITH_NUMBERS)).to.not.be.reverted; + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_UPPER)).to.not.be.reverted; + }); + + it('Should revert if registering a name with invalid characters', async function () { + const { sns, registerer, user1 } = await loadFixture( + deploySessionNameServiceFixture + ); + await expect( + sns.connect(registerer).registerName(user1.address, TEST_NAME_INVALID_CHARS) // charlie! + ).to.be.revertedWithCustomError(sns, 'UnsupportedCharacters'); + await expect( + sns.connect(registerer).registerName(user1.address, TEST_NAME_INVALID_SPACE) // "with space" + ).to.be.revertedWithCustomError(sns, 'UnsupportedCharacters'); + await expect( + sns.connect(registerer).registerName(user1.address, TEST_NAME_INVALID_DASH) // "with-dash" + ).to.be.revertedWithCustomError(sns, 'UnsupportedCharacters'); + await expect( + sns.connect(registerer).registerName(user1.address, TEST_NAME_INVALID_UNICODE) // "你好" + ).to.be.revertedWithCustomError(sns, 'UnsupportedCharacters'); + }); + + it('Should revert if registering a name that is already registered (case-sensitive)', async function () { + const { sns, registerer, user1, user2 } = await loadFixture( + deploySessionNameServiceFixture + ); + // Register lowercase + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + // Attempt to re-register exact same name - should fail + await expect( + sns.connect(registerer).registerName(user2.address, TEST_NAME_1) + ).to.be.revertedWithCustomError(sns, 'NameAlreadyRegistered'); + + // Attempt to register uppercase version - should succeed now + await expect( + sns.connect(registerer).registerName(user2.address, TEST_NAME_UPPER) + ).to.not.be.reverted; + }); + + it('Should return the correct token ID on successful registration', async function () { + const { sns, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + const name = TEST_NAME_1; + const expectedTokenId = calculateNameHash(name); + const returnedTokenId = await sns.connect(registerer).registerName.staticCall(user1.address, name); + expect(returnedTokenId).to.equal(expectedTokenId); + }); + + it('Cannot register without sufficient token balance', async function () { + const { sns, registerer, user1, mockToken } = await loadFixture(deploySessionNameServiceFixture); + + // Set allowance to 0 to simulate insufficient balance + await mockToken.connect(user1).approve(await sns.getAddress(), 0); + + await expect( + sns.connect(registerer).registerName(user1.address, TEST_NAME_1) + ).to.be.revertedWithCustomError(mockToken, 'ERC20InsufficientAllowance') + .withArgs(await sns.getAddress(), 0, REGISTRATION_FEE); + }); + }); + + // --- Name Resolution --- + describe('Name Resolution (`resolve`)', function () { + it('Should resolve a registered name to its owner', async function () { + const { sns, registerer, user1 } = await loadFixture( + deploySessionNameServiceFixture + ); + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(""); + expect(await sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)).to.equal(""); + }); + + it('Should return empty string for a name with different casing if not registered', async function () { + const { sns, registerer, user1 } = await loadFixture( + deploySessionNameServiceFixture + ); + // Register lowercase 'alice' + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + // Try resolving uppercase 'ALICE' - should revert with NameNotRegistered + await expect(sns.resolve(TEST_NAME_UPPER, SESSION_RECORD_TYPE)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + await expect(sns.resolve(TEST_NAME_MIXED, SESSION_RECORD_TYPE)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + }); + + it('Should revert with NameNotRegistered for an unregistered name', async function () { + const { sns } = await loadFixture(deploySessionNameServiceFixture); + await expect(sns.resolve('nonexistent', SESSION_RECORD_TYPE)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + await expect(sns.resolve('nonexistent', LOKINET_RECORD_TYPE)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + }); + + it('Should revert with NameNotRegistered after a name is burned', async function () { + const { sns, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + const name = TEST_NAME_1; + await sns.connect(registerer).registerName(user1.address, name); + const tokenId = calculateNameHash(name); + expect(await sns.resolve(name, SESSION_RECORD_TYPE)).to.equal(""); + + await sns.connect(user1).burn(tokenId); + + await expect(sns.resolve(name, SESSION_RECORD_TYPE)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + await expect(sns.resolve(name, LOKINET_RECORD_TYPE)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + }); + + it('Should return empty string for invalid record type', async function () { + const { sns, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + + // Try with invalid record type (2 is unused) + expect(await sns.resolve(TEST_NAME_1, 2)).to.equal(""); + + // Try with another invalid record type + expect(await sns.resolve(TEST_NAME_1, 4)).to.equal(""); + }); + + it('Should resolve session and lokinet names separately', async function () { + const { sns, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + const name = TEST_NAME_1; + await sns.connect(registerer).registerName(user1.address, name); + const tokenId = calculateNameHash(name); + + const sessionText = "session.name.record"; + const lokinetText = "lokinet.address.record"; + + // Set different record types + await sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, sessionText); + await sns.connect(user1).setTextRecord(tokenId, LOKINET_RECORD_TYPE, lokinetText); + + // Verify they're resolved separately + expect(await sns.resolve(name, SESSION_RECORD_TYPE)).to.equal(sessionText); + expect(await sns.resolve(name, LOKINET_RECORD_TYPE)).to.equal(lokinetText); + }); + }); + + // --- Renewals and Expiration --- + describe('Renewals and Expiration', function () { + let sns, owner, registerer, user1, user2, otherAccount, name, tokenId; + const expirationPeriod = INITIAL_EXPIRATION_DAYS * ONE_DAY; + + beforeEach(async function () { + const fixtureData = await loadFixture(deploySessionNameServiceFixture); + sns = fixtureData.sns; + owner = fixtureData.owner; + registerer = fixtureData.registerer; + user1 = fixtureData.user1; + user2 = fixtureData.user2; + otherAccount = fixtureData.otherAccount; + + name = TEST_NAME_1; + await sns.connect(owner).flipRenewals(); + expect(await sns.allowRenewals()).to.be.true; + + await sns.connect(registerer).registerName(user1.address, name); + tokenId = calculateNameHash(name); + }); + + describe('`renewName`', function () { + it('Should allow the owner to renew their name', async function () { + const initialAsset = await sns.namesToAssets(name); + const initialTimestamp = initialAsset.renewals; + + await time.increase(ONE_DAY * 10); + const expectedTimestamp = initialTimestamp + BigInt(expirationPeriod); + + await expect(sns.connect(user1).renewName(name)) + .to.emit(sns, 'NameRenewed') + .withArgs(name, user1.address, anyValue); + + const renewedAsset = await sns.namesToAssets(name); + expect(renewedAsset.renewals).to.be.gt(initialTimestamp); + expect(renewedAsset.renewals).to.be.closeTo(expectedTimestamp, 2); + }); + + it('Should revert if called by someone other than the owner', async function () { + await expect(sns.connect(otherAccount).renewName(name)) + .to.be.revertedWithCustomError(sns, 'ERC721IncorrectOwner') + .withArgs(otherAccount.address, tokenId, user1.address); + }); + + it('Should revert if renewals are disabled', async function () { + await sns.connect(owner).flipRenewals(); + expect(await sns.allowRenewals()).to.be.false; + + await expect(sns.connect(user1).renewName(name)) + .to.be.revertedWithCustomError(sns, 'RenewalsDisabled'); + }); + + it('Should revert if the name is not registered', async function () { + await expect(sns.connect(user1).renewName('nonexistent')) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + }); + + it('Should revert renewal if name casing does not match owned name', async function () { + // User1 owns 'alice' (TEST_NAME_1) + await time.increase(ONE_DAY * 5); + + // Try to renew 'ALICE' (TEST_NAME_UPPER) - should fail as user1 doesn't own it + await expect(sns.connect(user1).renewName(TEST_NAME_UPPER)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + + // Try to renew 'Alice' (TEST_NAME_MIXED) - should fail + await expect(sns.connect(user1).renewName(TEST_NAME_MIXED)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + }); + }); + + describe('`expireName`', function () { + it('Should allow REGISTERER to expire a name after the expiration period', async function () { + const initialAsset = await sns.namesToAssets(name); + const renewalTimestamp = initialAsset.renewals; + const expireTime = Number(renewalTimestamp) + expirationPeriod; + + await time.increaseTo(expireTime + 1); + + await expect(sns.connect(registerer).expireName(name)) + .to.emit(sns, 'NameExpired') + .withArgs(name, user1.address, tokenId); + + await expect(sns.ownerOf(tokenId)).to.be.revertedWithCustomError(sns, 'ERC721NonexistentToken'); + }); + + it('Should allow ADMIN to expire a name after the expiration period', async function () { + const initialAsset = await sns.namesToAssets(name); + const renewalTimestamp = initialAsset.renewals; + const expireTime = Number(renewalTimestamp) + expirationPeriod; + + await time.increaseTo(expireTime + 1); + + await expect(sns.connect(owner).expireName(name)) + .to.emit(sns, 'NameExpired') + .withArgs(name, user1.address, tokenId); + + await expect(sns.ownerOf(tokenId)).to.be.reverted; + }); + + it('Should revert if trying to expire before the expiration period', async function () { + const initialAsset = await sns.namesToAssets(name); + const renewalTimestamp = initialAsset.renewals; + const expireTime = Number(renewalTimestamp) + expirationPeriod; + + await time.increaseTo(expireTime - ONE_DAY); + + await expect(sns.connect(registerer).expireName(name)) + .to.be.revertedWithCustomError(sns, 'RenewalPeriodNotOver'); + }); + + it('Should revert if renewals are disabled', async function () { + await sns.connect(owner).flipRenewals(); + expect(await sns.allowRenewals()).to.be.false; + + await time.increase(YEAR_PLUS_ONE_DAY); + + await expect(sns.connect(registerer).expireName(name)) + .to.be.revertedWithCustomError(sns, 'RenewalsDisabled'); + }); + + it('Should revert if the name is not registered', async function () { + await time.increase(YEAR_PLUS_ONE_DAY); + await expect(sns.connect(registerer).expireName('nonexistent')) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + }); + + it('Should revert expiration if name casing does not match existing name', async function () { + // 'alice' (TEST_NAME_1) is registered + const initialAsset = await sns.namesToAssets(name); + const renewalTimestamp = initialAsset.renewals; + const expireTime = Number(renewalTimestamp) + expirationPeriod; + + await time.increaseTo(expireTime + 1); + + // Try to expire 'ALICE' - should fail + await expect(sns.connect(registerer).expireName(TEST_NAME_UPPER)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + + // Try to expire 'Alice' - should fail + await expect(sns.connect(registerer).expireName(TEST_NAME_MIXED)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + }); + + it('Should correctly handle expiration after renewal', async function () { + // 1. Register: timestamp T0 + const initialAsset = await sns.namesToAssets(name); + const t0 = initialAsset.renewals; + + // 2. Advance time by 100 days (T0 + 100d) + await time.increase(100 * ONE_DAY); + + // 3. Renew: timestamp T1 = T0 + 100d + await sns.connect(user1).renewName(name); + const renewedAsset = await sns.namesToAssets(name); + const t1 = renewedAsset.renewals; + expect(t1).to.be.gt(t0); + + // 4. Calculate new expiration time: T1 + expirationPeriod + const newExpireTime = Number(t1) + expirationPeriod; + + // 5. Try to expire based on old time (should fail) + const oldExpireTime = Number(t0) + expirationPeriod; + await time.increaseTo(oldExpireTime + 1); + await expect(sns.connect(registerer).expireName(name)) + .to.be.revertedWithCustomError(sns, 'RenewalPeriodNotOver'); + + // 6. Advance past new expiration time (T1 + expirationPeriod + 1) + await time.increaseTo(newExpireTime + 1); + + // 7. Expire (should succeed) + await expect(sns.connect(registerer).expireName(name)) + .to.emit(sns, 'NameExpired') + .withArgs(name, user1.address, tokenId); + + await expect(sns.ownerOf(tokenId)).to.be.reverted; + }); + }); + }); + + // --- Name Burning (burn) --- + describe('Name Burning (burn)', function () { + let sns, registerer, user1, user2, tokenId, otherAccount; + + beforeEach(async function () { + const fixture = await loadFixture(deploySessionNameServiceFixture); + sns = fixture.sns; + registerer = fixture.registerer; + user1 = fixture.user1; + user2 = fixture.user2; + otherAccount = fixture.otherAccount; + + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + tokenId = calculateNameHash(TEST_NAME_1); + + await sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, "some text"); + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal("some text"); + expect(await sns.totalSupply()).to.equal(1); + }); + + it('Should allow the owner to burn their name NFT', async function () { + await expect(sns.connect(user1).burn(tokenId)) + .to.emit(sns, 'NameDeleted') + .withArgs(TEST_NAME_1, user1.address, tokenId) + .and.to.emit(sns, 'Transfer') + .withArgs(user1.address, ethers.ZeroAddress, tokenId); + + expect(await sns.totalSupply()).to.equal(0); + expect(await sns.balanceOf(user1.address)).to.equal(0); + await expect(sns.ownerOf(tokenId)).to.be.revertedWithCustomError( + sns, + 'ERC721NonexistentToken' + ); + + // After burning, resolve should now revert with NameNotRegistered + await expect(sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + await expect(sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)) + .to.be.revertedWithCustomError(sns, 'NameNotRegistered'); + + // Just verify that both fields in the TextRecords struct are empty strings + const textRecords = await sns.tokenIdToTextRecord(tokenId); + expect(textRecords.sessionName).to.equal(""); + expect(textRecords.lokinetName).to.equal(""); + + const asset = await sns.namesToAssets(TEST_NAME_1); + expect(asset.id).to.equal(0); + const linkedName = await sns.idsToNames(tokenId); + expect(linkedName).to.equal(''); + }); + + it('Should revert if trying to burn a non-existent token', async function () { + const nonExistentTokenId = calculateNameHash("nonexistent"); + await expect(sns.connect(user1).burn(nonExistentTokenId)) + .to.be.revertedWithCustomError(sns, 'ERC721NonexistentToken') + .withArgs(nonExistentTokenId); + }); + + it('Should prevent non-owner from burning the token', async function () { + await expect(sns.connect(otherAccount).burn(tokenId)) + .to.be.revertedWithCustomError(sns, 'ERC721InsufficientApproval') + .withArgs(otherAccount.address, tokenId); + }); + + it('Should allow re-registration after burning', async function () { + await sns.connect(user1).burn(tokenId); + expect(await sns.totalSupply()).to.equal(0); + + const newOwner = user2; + await expect(sns.connect(registerer).registerName(newOwner.address, TEST_NAME_1)) + .to.emit(sns, 'NameRegistered') + .withArgs(TEST_NAME_1, newOwner.address, tokenId); + + expect(await sns.ownerOf(tokenId)).to.equal(newOwner.address); + expect(await sns.totalSupply()).to.equal(1); + }); + }); + + // --- Re-registration after Expiration --- + describe('Re-registration after Expiration', function () { + it('Should allow re-registration after expiration', async function () { + const { sns, owner, registerer, user1, user2, otherAccount } = await loadFixture(deploySessionNameServiceFixture); + const name = 'expiringname'; + const tokenId = calculateNameHash(name); + const expirationPeriod = INITIAL_EXPIRATION_DAYS * ONE_DAY; + + await sns.connect(owner).flipRenewals(); + await sns.connect(registerer).registerName(user1.address, name); + + const asset = await sns.namesToAssets(name); + const expireTime = Number(asset.renewals) + expirationPeriod; + await time.increaseTo(expireTime + 1); + + await sns.connect(registerer).expireName(name); + await expect(sns.ownerOf(tokenId)).to.be.revertedWithCustomError(sns, 'ERC721NonexistentToken'); + expect(await sns.totalSupply()).to.equal(0); + + const newOwner = user2; + await expect(sns.connect(registerer).registerName(newOwner.address, name)) + .to.emit(sns, 'NameRegistered') + .withArgs(name, newOwner.address, tokenId); + + expect(await sns.ownerOf(tokenId)).to.equal(newOwner.address); + expect(await sns.totalSupply()).to.equal(1); + // After re-registration, it should have an empty record again + expect(await sns.resolve(name, SESSION_RECORD_TYPE)).to.equal(""); + }); + }); + + // --- Token URI --- + describe('Token URI (`tokenURI`)', function () { + it('Should return the correct token URI for a registered name', async function () { + const { sns, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + const name = TEST_NAME_1; + await sns.connect(registerer).registerName(user1.address, name); + const tokenId = BigInt(calculateNameHash(name)); + const expectedURI = BASE_URI + tokenId.toString(); + expect(await sns.tokenURI(tokenId)).to.equal(expectedURI); + }); + + it('Should return an empty string if baseTokenURI is not set', async function () { + const { sns, owner, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + await sns.connect(owner).setBaseTokenURI(''); + expect(await sns.baseTokenURI()).to.equal(''); + + const name = TEST_NAME_1; + await sns.connect(registerer).registerName(user1.address, name); + const tokenId = calculateNameHash(name); + expect(await sns.tokenURI(tokenId)).to.equal(''); + }); + + it('Should revert for a non-existent token', async function () { + const { sns } = await loadFixture(deploySessionNameServiceFixture); + const nonExistentTokenId = calculateNameHash('nonexistent'); + await expect(sns.tokenURI(nonExistentTokenId)) + .to.be.revertedWithCustomError(sns, 'ERC721NonexistentToken'); + }); + }); + + // --- ERC721 Specifics & Overrides --- + describe('ERC721 Specifics & Overrides', function () { + it('Should support required interfaces (ERC721, AccessControl, ISessionNameService)', async function () { + const { sns } = await loadFixture(deploySessionNameServiceFixture); + const erc721InterfaceId = '0x80ac58cd'; + const accessControlInterfaceId = '0x7965db0b'; + + expect(await sns.supportsInterface(erc721InterfaceId)).to.be.true; + expect(await sns.supportsInterface(accessControlInterfaceId)).to.be.true; + }); + + it('Should allow minting (from address(0))', async function () { + const { sns, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_1)).to.not.be.reverted; + }); + + it('Should allow burning (to address(0))', async function () { + const { sns, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + const tokenId = calculateNameHash(TEST_NAME_1); + await expect(sns.connect(user1).burn(tokenId)).to.not.be.reverted; + }); + }); + + // --- Upgradeability --- + describe('Upgradeability', function () { + it('Should be upgradeable', async function () { + const { sns, owner, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + + // Register a name + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + const tokenId = calculateNameHash(TEST_NAME_1); + expect(await sns.ownerOf(tokenId)).to.equal(user1.address); + + // Verify the contract has upgradeToAndCall method (UUPS pattern) + expect(typeof sns.upgradeToAndCall).to.equal('function'); + }); + + it('Should not allow non-admin to upgrade the contract', async function () { + const { sns, user1 } = await loadFixture(deploySessionNameServiceFixture); + + // Deploy a new implementation + const SessionNameServiceV2Factory = await ethers.getContractFactory('SessionNameService'); + const newImplementation = await SessionNameServiceV2Factory.deploy(); + await newImplementation.waitForDeployment(); + + // Try to upgrade as non-admin + await expect(sns.connect(user1).upgradeToAndCall(await newImplementation.getAddress(), "0x")) + .to.be.revertedWithCustomError(sns, 'AccessControlUnauthorizedAccount'); + }); + }); + + describe('ERC721 Transfers and Approvals', function () { + let sns, owner, registerer, user1, user2, otherAccount, tokenId, name; + + beforeEach(async function () { + const fixture = await loadFixture(deploySessionNameServiceFixture); + sns = fixture.sns; + owner = fixture.owner; + registerer = fixture.registerer; + user1 = fixture.user1; + user2 = fixture.user2; + otherAccount = fixture.otherAccount; + name = TEST_NAME_1; + + await sns.connect(registerer).registerName(user1.address, name); + tokenId = calculateNameHash(name); + await sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, "original text"); + await sns.connect(owner).flipRenewals(); + }); + + it('Should allow owner to transfer using transferFrom', async function () { + await sns.connect(user1).approve(user1.address, tokenId); + await expect(sns.connect(user1).transferFrom(user1.address, user2.address, tokenId)) + .to.emit(sns, 'Transfer') + .withArgs(user1.address, user2.address, tokenId); + expect(await sns.ownerOf(tokenId)).to.equal(user2.address); + expect(await sns.resolve(name, SESSION_RECORD_TYPE)).to.equal("original text"); + expect(await sns.balanceOf(user1.address)).to.equal(0); + expect(await sns.balanceOf(user2.address)).to.equal(1); + }); + + it('Should allow owner to transfer using safeTransferFrom', async function () { + await expect(sns.connect(user1)['safeTransferFrom(address,address,uint256)'](user1.address, user2.address, tokenId)) + .to.emit(sns, 'Transfer') + .withArgs(user1.address, user2.address, tokenId); + expect(await sns.ownerOf(tokenId)).to.equal(user2.address); + expect(await sns.resolve(name, SESSION_RECORD_TYPE)).to.equal("original text"); + }); + + it('Should allow approved address to transfer using transferFrom', async function () { + await sns.connect(user1).approve(user2.address, tokenId); + expect(await sns.getApproved(tokenId)).to.equal(user2.address); + await expect(sns.connect(user2).transferFrom(user1.address, otherAccount.address, tokenId)) + .to.emit(sns, 'Transfer') + .withArgs(user1.address, otherAccount.address, tokenId); + expect(await sns.ownerOf(tokenId)).to.equal(otherAccount.address); + expect(await sns.resolve(name, SESSION_RECORD_TYPE)).to.equal("original text"); + }); + + it('Should allow approved address to transfer using safeTransferFrom', async function () { + await sns.connect(user1).approve(user2.address, tokenId); + await expect(sns.connect(user2)['safeTransferFrom(address,address,uint256)'](user1.address, otherAccount.address, tokenId)) + .to.emit(sns, 'Transfer') + .withArgs(user1.address, otherAccount.address, tokenId); + expect(await sns.ownerOf(tokenId)).to.equal(otherAccount.address); + expect(await sns.resolve(name, SESSION_RECORD_TYPE)).to.equal("original text"); + }); + + it('Should allow approved operator (setApprovalForAll) to transfer', async function () { + await sns.connect(user1).setApprovalForAll(user2.address, true); + expect(await sns.isApprovedForAll(user1.address, user2.address)).to.be.true; + await expect(sns.connect(user2).transferFrom(user1.address, otherAccount.address, tokenId)) + .to.emit(sns, 'Transfer') + .withArgs(user1.address, otherAccount.address, tokenId); + expect(await sns.ownerOf(tokenId)).to.equal(otherAccount.address); + expect(await sns.resolve(name, SESSION_RECORD_TYPE)).to.equal("original text"); + }); + + it('Should revert transferFrom if caller is not owner or approved', async function () { + await expect(sns.connect(otherAccount).transferFrom(user1.address, user2.address, tokenId)) + .to.be.revertedWithCustomError(sns, 'ERC721InsufficientApproval') + .withArgs(otherAccount.address, tokenId); + }); + + it('Should revert transferFrom if from address is not owner', async function () { + await sns.connect(user1).approve(user2.address, tokenId); + await expect(sns.connect(user2).transferFrom(otherAccount.address, user2.address, tokenId)) + .to.be.revertedWithCustomError(sns, 'ERC721IncorrectOwner') + .withArgs(otherAccount.address, tokenId, user1.address); + }); + + it('Should clear approval after transfer', async function () { + await sns.connect(user1).approve(user2.address, tokenId); + expect(await sns.getApproved(tokenId)).to.equal(user2.address); + await sns.connect(user2).transferFrom(user1.address, otherAccount.address, tokenId); + expect(await sns.getApproved(tokenId)).to.equal(ethers.ZeroAddress); + }); + + it('Should allow the new owner to set text record after transfer', async function () { + await sns.connect(user1).transferFrom(user1.address, user2.address, tokenId); + expect(await sns.ownerOf(tokenId)).to.equal(user2.address); + + const newText = "text set by new owner"; + await expect(sns.connect(user2).setTextRecord(tokenId, SESSION_RECORD_TYPE, newText)) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, SESSION_RECORD_TYPE, newText); + expect(await sns.resolve(name, SESSION_RECORD_TYPE)).to.equal(newText); + + // Check the TextRecords fields directly + const textRecords = await sns.tokenIdToTextRecord(tokenId); + expect(textRecords.sessionName).to.equal(newText); + expect(textRecords.lokinetName).to.equal(""); + }); + + it('Should allow the new owner to renew after transfer', async function () { + await sns.connect(user1).transferFrom(user1.address, user2.address, tokenId); + expect(await sns.ownerOf(tokenId)).to.equal(user2.address); + + await time.increase(ONE_DAY * 10); + const initialAsset = await sns.namesToAssets(name); + const testExpirationPeriod = INITIAL_EXPIRATION_DAYS * ONE_DAY; + const expectedTimestamp = initialAsset.renewals + BigInt(testExpirationPeriod); + + await expect(sns.connect(user2).renewName(name)) + .to.emit(sns, 'NameRenewed') + .withArgs(name, user2.address, anyValue); + + const renewedAsset = await sns.namesToAssets(name); + expect(renewedAsset.renewals).to.be.gt(initialAsset.renewals); + expect(renewedAsset.renewals).to.be.closeTo(expectedTimestamp, 2); + }); + + it('Should revert approve if caller is not owner or approved operator', async function () { + await expect(sns.connect(otherAccount).approve(user2.address, tokenId)) + .to.be.revertedWithCustomError(sns, 'ERC721InvalidApprover') + .withArgs(otherAccount.address); + }); + + it('Should allow owner to approve', async function () { + await expect(sns.connect(user1).approve(user2.address, tokenId)).to.not.be.reverted; + expect(await sns.getApproved(tokenId)).to.equal(user2.address); + }); + + it('Should allow approved operator to approve on behalf of owner', async function () { + await sns.connect(user1).setApprovalForAll(otherAccount.address, true); + await expect(sns.connect(otherAccount).approve(user2.address, tokenId)).to.not.be.reverted; + expect(await sns.getApproved(tokenId)).to.equal(user2.address); + }); + + it('Should allow owner to setApprovalForAll', async function () { + await expect(sns.connect(user1).setApprovalForAll(user2.address, true)).to.not.be.reverted; + expect(await sns.isApprovedForAll(user1.address, user2.address)).to.be.true; + await expect(sns.connect(user1).setApprovalForAll(user2.address, false)).to.not.be.reverted; + expect(await sns.isApprovedForAll(user1.address, user2.address)).to.be.false; + }); + }); + + // --- Internal Helper Functions (Tested via public functions) --- + describe('Internal Helper Functions (isValidBase64)', function() { + it('isValidBase64 works as expected (tested via registration)', async function() { + const { sns, registerer, user1 } = await loadFixture(deploySessionNameServiceFixture); + // Valid chars (A-Z, a-z, 0-9, +, /, =) + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_1)).to.not.be.reverted; // alice + await sns.connect(user1).burn(calculateNameHash(TEST_NAME_1)); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_UPPER)).to.not.be.reverted; // ALICE + await sns.connect(user1).burn(calculateNameHash(TEST_NAME_UPPER)); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_WITH_NUMBERS)).to.not.be.reverted; // name123 + await sns.connect(user1).burn(calculateNameHash(TEST_NAME_WITH_NUMBERS)); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_WITH_PLUS)).to.not.be.reverted; // name+plus + await sns.connect(user1).burn(calculateNameHash(TEST_NAME_WITH_PLUS)); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_WITH_SLASH)).to.not.be.reverted; // name/slash + await sns.connect(user1).burn(calculateNameHash(TEST_NAME_WITH_SLASH)); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_WITH_EQUALS)).to.not.be.reverted; // name== + await sns.connect(user1).burn(calculateNameHash(TEST_NAME_WITH_EQUALS)); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_MIXED)).to.not.be.reverted; // Alice + // Don't burn the last one + + // Invalid chars + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_INVALID_CHARS)) // invalid! + .to.be.revertedWithCustomError(sns, 'UnsupportedCharacters'); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_INVALID_SPACE)) // invalid space + .to.be.revertedWithCustomError(sns, 'UnsupportedCharacters'); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_INVALID_DASH)) // invalid-dash + .to.be.revertedWithCustomError(sns, 'UnsupportedCharacters'); + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_INVALID_UNICODE)) // 你好 + .to.be.revertedWithCustomError(sns, 'UnsupportedCharacters'); + }); + }); + + // --- Text Record Management --- + describe('Text Record Management (setTextRecord)', function () { + let sns, owner, registerer, user1, user2, tokenId, otherAccount; + const testText = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; // Example 64 char hex + + beforeEach(async function () { + const fixture = await loadFixture(deploySessionNameServiceFixture); + sns = fixture.sns; + owner = fixture.owner; + registerer = fixture.registerer; + user1 = fixture.user1; + user2 = fixture.user2; + otherAccount = fixture.otherAccount; + // Register a name for user1 + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + tokenId = calculateNameHash(TEST_NAME_1); + // Initial check: resolve returns empty string + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(""); + expect(await sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)).to.equal(""); + }); + + it('Should allow the owner to set the session text record', async function () { + await expect(sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, testText)) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, SESSION_RECORD_TYPE, testText); + + // Check that the session record was set but lokinet is still empty + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(testText); + expect(await sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)).to.equal(""); + }); + + it('Should allow the owner to set the lokinet text record', async function () { + await expect(sns.connect(user1).setTextRecord(tokenId, LOKINET_RECORD_TYPE, testText)) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, LOKINET_RECORD_TYPE, testText); + + // Check that the lokinet record was set but session is still empty + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(""); + expect(await sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)).to.equal(testText); + }); + + it('Should allow an approved address to set text records of different types', async function () { + const sessionText = "approved session text"; + const lokinetText = "approved lokinet text"; + + await sns.connect(user1).approve(user2.address, tokenId); + + await expect(sns.connect(user2).setTextRecord(tokenId, SESSION_RECORD_TYPE, sessionText)) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, SESSION_RECORD_TYPE, sessionText); + + await expect(sns.connect(user2).setTextRecord(tokenId, LOKINET_RECORD_TYPE, lokinetText)) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, LOKINET_RECORD_TYPE, lokinetText); + + // Verify both record types are set correctly + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(sessionText); + expect(await sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)).to.equal(lokinetText); + }); + + it('Should allow an approved operator (setApprovalForAll) to set text records', async function () { + const sessionText = "operator session text"; + const lokinetText = "operator lokinet text"; + + await sns.connect(user1).setApprovalForAll(user2.address, true); + + await expect(sns.connect(user2).setTextRecord(tokenId, SESSION_RECORD_TYPE, sessionText)) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, SESSION_RECORD_TYPE, sessionText); + + await expect(sns.connect(user2).setTextRecord(tokenId, LOKINET_RECORD_TYPE, lokinetText)) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, LOKINET_RECORD_TYPE, lokinetText); + + // Verify both record types are set correctly + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(sessionText); + expect(await sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)).to.equal(lokinetText); + + // Set back to empty for both types + await expect(sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, "")) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, SESSION_RECORD_TYPE, ""); + + await expect(sns.connect(user1).setTextRecord(tokenId, LOKINET_RECORD_TYPE, "")) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, LOKINET_RECORD_TYPE, ""); + + // Verify both are empty again + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(""); + expect(await sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)).to.equal(""); + }); + + it('Should prevent non-owner/non-approved from setting the text record', async function () { + // Using registerer account (not owner or approved) + await expect(sns.connect(registerer).setTextRecord(tokenId, SESSION_RECORD_TYPE, testText)) + .to.be.revertedWithCustomError(sns, 'NotAuthorized'); + + await expect(sns.connect(registerer).setTextRecord(tokenId, LOKINET_RECORD_TYPE, testText)) + .to.be.revertedWithCustomError(sns, 'NotAuthorized'); + + // Using other account (not owner or approved) + await expect(sns.connect(otherAccount).setTextRecord(tokenId, SESSION_RECORD_TYPE, testText)) + .to.be.revertedWithCustomError(sns, 'NotAuthorized'); + + await expect(sns.connect(otherAccount).setTextRecord(tokenId, LOKINET_RECORD_TYPE, testText)) + .to.be.revertedWithCustomError(sns, 'NotAuthorized'); + }); + + it('Should prevent setting the text record for a non-existent token', async function () { + const nonExistentTokenId = calculateNameHash("nonexistent"); + await expect(sns.connect(user1).setTextRecord(nonExistentTokenId, SESSION_RECORD_TYPE, testText)) + .to.be.revertedWithCustomError(sns, 'ERC721NonexistentToken') + .withArgs(nonExistentTokenId); + + await expect(sns.connect(user1).setTextRecord(nonExistentTokenId, LOKINET_RECORD_TYPE, testText)) + .to.be.revertedWithCustomError(sns, 'ERC721NonexistentToken') + .withArgs(nonExistentTokenId); + }); + + it('Should revert with InvalidRecordType for unsupported record types', async function () { + // Using record type 2 which is unused/invalid + await expect(sns.connect(user1).setTextRecord(tokenId, 2, testText)) + .to.be.revertedWithCustomError(sns, 'InvalidRecordType'); + + // Using record type 4 which is also invalid + await expect(sns.connect(user1).setTextRecord(tokenId, 4, testText)) + .to.be.revertedWithCustomError(sns, 'InvalidRecordType'); + }); + + it('Should allow setting empty text records for both types', async function () { + // Set session record + await sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, testText); + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(testText); + + // Set lokinet record + const lokinetText = "lokinet test text"; + await sns.connect(user1).setTextRecord(tokenId, LOKINET_RECORD_TYPE, lokinetText); + expect(await sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)).to.equal(lokinetText); + + // Set both back to empty + await expect(sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, "")) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, SESSION_RECORD_TYPE, ""); + + await expect(sns.connect(user1).setTextRecord(tokenId, LOKINET_RECORD_TYPE, "")) + .to.emit(sns, 'TextRecordUpdated') + .withArgs(tokenId, LOKINET_RECORD_TYPE, ""); + + // Verify both are empty again + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(""); + expect(await sns.resolve(TEST_NAME_1, LOKINET_RECORD_TYPE)).to.equal(""); + }); + + it('Should allow the owner to set a text record before renewal', async function () { + const originalText = "original text"; + await sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, originalText); + + // Check text record is set + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(originalText); + + // Enable renewals + await sns.connect(owner).flipRenewals(); + expect(await sns.allowRenewals()).to.be.true; + + // Verify renewal keeps the text record + await time.increase(ONE_DAY * 10); + await sns.connect(user1).renewName(TEST_NAME_1); + + // Text record should still be available + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(originalText); + }); + + it('Should preserve the text record after renewal', async function () { + const originalText = "original text"; + // Set text record + await sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, originalText); + + // Advance time + await time.increase(ONE_DAY * 20); + + // Enable renewals + await sns.connect(owner).flipRenewals(); + expect(await sns.allowRenewals()).to.be.true; + + // Renew + await sns.connect(user1).renewName(TEST_NAME_1); + + // Text record should be preserved + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(originalText); + }); + + it('Should allow updating text record after renewal', async function () { + const originalText = "original text"; + const updatedText = "updated after renewal"; + + // Set text record + await sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, originalText); + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(originalText); + + // Advance time + await time.increase(ONE_DAY * 20); + + // Enable renewals + await sns.connect(owner).flipRenewals(); + expect(await sns.allowRenewals()).to.be.true; + + // Renew + await sns.connect(user1).renewName(TEST_NAME_1); + + // Update text record + await sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, updatedText); + + // Text record should be updated + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(updatedText); + }); + + it('Should transfer name with text record intact', async function () { + const originalText = "original text"; + + // Set text record + await sns.connect(user1).setTextRecord(tokenId, SESSION_RECORD_TYPE, originalText); + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(originalText); + + // Transfer to user2 + await sns.connect(user1).transferFrom(user1.address, user2.address, tokenId); + + // Text record should be preserved after transfer + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(originalText); + + // New owner can update the text record + const newText = "updated by new owner"; + await sns.connect(user2).setTextRecord(tokenId, SESSION_RECORD_TYPE, newText); + expect(await sns.resolve(TEST_NAME_1, SESSION_RECORD_TYPE)).to.equal(newText); + }); + }); + + // --- Total Supply Management --- + describe('Total Supply Management', function () { + it('Should increment totalSupply on registration', async function () { + const { sns, registerer, user1, user2 } = await loadFixture(deploySessionNameServiceFixture); + expect(await sns.totalSupply()).to.equal(0); + + await sns.connect(registerer).registerName(user1.address, 'supplytest1'); + expect(await sns.totalSupply()).to.equal(1); + + await sns.connect(registerer).registerName(user2.address, 'supplytest2'); + expect(await sns.totalSupply()).to.equal(2); + }); + + it('Should decrement totalSupply on burn', async function () { + const { sns, registerer, user1, user2 } = await loadFixture(deploySessionNameServiceFixture); + const name1 = 'burnsupply1'; + const name2 = 'burnsupply2'; + const tokenId1 = calculateNameHash(name1); + const tokenId2 = calculateNameHash(name2); + + await sns.connect(registerer).registerName(user1.address, name1); + await sns.connect(registerer).registerName(user2.address, name2); + expect(await sns.totalSupply()).to.equal(2); + + await sns.connect(user1).burn(tokenId1); + expect(await sns.totalSupply()).to.equal(1); + + await sns.connect(user2).burn(tokenId2); + expect(await sns.totalSupply()).to.equal(0); + }); + + it('Should decrement totalSupply on expiration', async function () { + const { sns, owner, registerer, user1, user2 } = await loadFixture(deploySessionNameServiceFixture); + const name1 = 'expiresupply1'; + const name2 = 'expiresupply2'; + const expirationPeriod = INITIAL_EXPIRATION_DAYS * ONE_DAY; + + // Enable renewals and register names + await sns.connect(owner).flipRenewals(); + await sns.connect(registerer).registerName(user1.address, name1); + await sns.connect(registerer).registerName(user2.address, name2); + expect(await sns.totalSupply()).to.equal(2); + + // Expire first name + const asset1 = await sns.namesToAssets(name1); + const expireTime1 = Number(asset1.renewals) + expirationPeriod; + await time.setNextBlockTimestamp(expireTime1 + 1); + await ethers.provider.send('evm_mine', []); + await sns.connect(registerer).expireName(name1); + expect(await sns.totalSupply()).to.equal(1, "Total supply did not decrease after first expiration"); + + // Expire second name + const asset2 = await sns.namesToAssets(name2); + const expireTime2 = Number(asset2.renewals) + expirationPeriod; + await time.setNextBlockTimestamp(expireTime2 + 10); + await ethers.provider.send('evm_mine', []); + await sns.connect(registerer).expireName(name2); + expect(await sns.totalSupply()).to.equal(0, "Total supply did not decrease after second expiration"); + }); + }); + + // --- Fee Management --- + describe('Fee Management', function () { + it('Only ADMIN_ROLE can set payment token', async function () { + const { sns, owner, otherAccount, mockToken } = await loadFixture(deploySessionNameServiceFixture); + + await expect(sns.connect(owner).setPaymentToken(await mockToken.getAddress())) + .to.emit(sns, 'PaymentTokenSet') + .withArgs(await mockToken.getAddress()); + + await expect( + sns.connect(otherAccount).setPaymentToken(await mockToken.getAddress()) + ).to.be.revertedWithCustomError(sns, 'AccessControlUnauthorizedAccount'); + }); + + it('Only ADMIN_ROLE can set fees', async function () { + const { sns, owner, otherAccount } = await loadFixture(deploySessionNameServiceFixture); + + await expect(sns.connect(owner).setFees(REGISTRATION_FEE, TRANSFER_FEE)) + .to.emit(sns, 'FeesSet') + .withArgs(REGISTRATION_FEE, TRANSFER_FEE); + + await expect( + sns.connect(otherAccount).setFees(REGISTRATION_FEE, TRANSFER_FEE) + ).to.be.revertedWithCustomError(sns, 'AccessControlUnauthorizedAccount'); + }); + + it('Only ADMIN_ROLE can withdraw fees', async function () { + const { sns, owner, otherAccount, user1, mockToken } = await loadFixture(deploySessionNameServiceFixture); + + // Register a name to generate some fees + await sns.connect(owner).registerName(user1.address, TEST_NAME_1); + + const initialBalance = await mockToken.balanceOf(owner.address); + + await expect(sns.connect(owner).withdrawFees(owner.address)) + .to.emit(sns, 'FeesWithdrawn') + .withArgs(owner.address, REGISTRATION_FEE); + + const finalBalance = await mockToken.balanceOf(owner.address); + expect(finalBalance - initialBalance).to.equal(REGISTRATION_FEE); + + await expect( + sns.connect(otherAccount).withdrawFees(otherAccount.address) + ).to.be.revertedWithCustomError(sns, 'AccessControlUnauthorizedAccount'); + }); + + it('Registration requires payment of fee', async function () { + const { sns, registerer, user1, mockToken } = await loadFixture(deploySessionNameServiceFixture); + + const initialBalance = await mockToken.balanceOf(user1.address); + + await expect(sns.connect(registerer).registerName(user1.address, TEST_NAME_1)) + .to.emit(sns, 'NameRegistered') + .withArgs(TEST_NAME_1, user1.address, calculateNameHash(TEST_NAME_1)); + + const finalBalance = await mockToken.balanceOf(user1.address); + expect(initialBalance - finalBalance).to.equal(REGISTRATION_FEE); + }); + + it('Transfer requires payment of fee', async function () { + const { sns, registerer, user1, user2, mockToken } = await loadFixture(deploySessionNameServiceFixture); + + // Register a name first + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + + const initialBalance = await mockToken.balanceOf(user1.address); + + // Transfer the name + await expect(sns.connect(user1).transferFrom(user1.address, user2.address, calculateNameHash(TEST_NAME_1))) + .to.emit(sns, 'Transfer') + .withArgs(user1.address, user2.address, calculateNameHash(TEST_NAME_1)); + + const finalBalance = await mockToken.balanceOf(user1.address); + expect(initialBalance - finalBalance).to.equal(TRANSFER_FEE); + }); + + it('Renewal requires payment of fee', async function () { + const { sns, owner, registerer, user1, mockToken } = await loadFixture(deploySessionNameServiceFixture); + + // Enable renewals + await sns.connect(owner).flipRenewals(); + + // Register a name first + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + + const initialBalance = await mockToken.balanceOf(user1.address); + + // Renew the name + await expect(sns.connect(user1).renewName(TEST_NAME_1)) + .to.emit(sns, 'NameRenewed') + .withArgs(TEST_NAME_1, user1.address, anyValue); + + const finalBalance = await mockToken.balanceOf(user1.address); + expect(initialBalance - finalBalance).to.equal(REGISTRATION_FEE); + }); + + it('Cannot register without sufficient token balance', async function () { + const { sns, registerer, user1, mockToken } = await loadFixture(deploySessionNameServiceFixture); + + // Set allowance to 0 to simulate insufficient balance + await mockToken.connect(user1).approve(await sns.getAddress(), 0); + + await expect( + sns.connect(registerer).registerName(user1.address, TEST_NAME_1) + ).to.be.revertedWithCustomError(mockToken, 'ERC20InsufficientAllowance') + .withArgs(await sns.getAddress(), 0, REGISTRATION_FEE); + }); + + it('Cannot transfer without sufficient token balance', async function () { + const { sns, registerer, user1, user2, mockToken } = await loadFixture(deploySessionNameServiceFixture); + + // Register a name first + await sns.connect(registerer).registerName(user1.address, TEST_NAME_1); + + // Set allowance to 0 to simulate insufficient balance + await mockToken.connect(user1).approve(await sns.getAddress(), 0); + + await expect( + sns.connect(user1).transferFrom(user1.address, user2.address, calculateNameHash(TEST_NAME_1)) + ).to.be.revertedWithCustomError(mockToken, 'ERC20InsufficientAllowance') + .withArgs(await sns.getAddress(), 0, TRANSFER_FEE); + }); + }); +});