diff --git a/.cspell.json b/.cspell.json index 84d43097..bf6c1173 100644 --- a/.cspell.json +++ b/.cspell.json @@ -72,6 +72,7 @@ "Reentrancy", "SFID", "EXTCODECOPY", + "EXTCODEHASH", "solady", "SLOAD", "Bitmask", @@ -105,6 +106,18 @@ "repoint", "repointed", "cutover", + "autonumber", + "dedup", + "runbook", + "selfdestruct", + "SELFDESTRUCT", + "proxiable", + "codehash", + "codehashes", + "immediates", + "newbase", + "newcontract", + "opping", "Axelar", "IEIP", "calldataload", @@ -133,6 +146,16 @@ "remy", "aabbcc", "mfas", - "reqs" + "reqs", + "impls", + "zkout", + "pushable", + "remappings", + "staticcall", + "agentic", + "delegatecalls", + "repoints", + "reentrant", + "zkvm" ] } diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 620436fe..ba066d47 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -53,7 +53,7 @@ jobs: run: yarn - name: Run coverage - run: forge coverage --match-path "test/{Swarm*,ServiceProvider,FleetIdentity}*.t.sol" --ir-minimum --report lcov --report-file coverage.lcov + run: forge coverage --match-path "test/{Swarm*,ServiceProvider,FleetIdentity,collections/*}*.t.sol" --ir-minimum --report lcov --report-file coverage.lcov - name: Upload coverage report uses: actions/upload-artifact@v4 @@ -73,7 +73,7 @@ jobs: update-comment: true working-directory: ./ - - name: Check line coverage threshold + - name: Check line coverage threshold (swarms) run: | # Extract line coverage from lcov report for src/swarms/ contracts only # Parse lcov format: find swarm file sections and sum their LF/LH values @@ -108,6 +108,42 @@ jobs: echo "Coverage check passed: $COVERAGE% >= $THRESHOLD%" + - name: Check line coverage threshold (collections) + run: | + # Extract line coverage from lcov report for src/collections/ contracts only. + # While src/collections/ is documentation-only, this step skips cleanly with a + # warning. As soon as Solidity sources land, the gate enforces the same 95% + # threshold as swarms. + LINES_FOUND=$(awk ' + /^SF:.*src\/collections\// { in_section = 1 } + /^end_of_record/ { in_section = 0 } + in_section && /^LF:/ { sum += substr($0, 4) } + END { print sum+0 } + ' coverage.lcov) + + LINES_HIT=$(awk ' + /^SF:.*src\/collections\// { in_section = 1 } + /^end_of_record/ { in_section = 0 } + in_section && /^LH:/ { sum += substr($0, 4) } + END { print sum+0 } + ' coverage.lcov) + + if [ "$LINES_FOUND" -eq 0 ]; then + echo "::warning::No Solidity sources found under src/collections/ — coverage gate skipped (will enforce once contracts land)" + exit 0 + fi + + COVERAGE=$(awk "BEGIN {printf \"%.2f\", ($LINES_HIT / $LINES_FOUND) * 100}") + echo "Collections line coverage: $COVERAGE% ($LINES_HIT / $LINES_FOUND lines)" + + THRESHOLD=95 + if awk "BEGIN {exit !($COVERAGE < $THRESHOLD)}"; then + echo "Error: Line coverage ($COVERAGE%) is below the required threshold ($THRESHOLD%)" + exit 1 + fi + + echo "Coverage check passed: $COVERAGE% >= $THRESHOLD%" + Specification-PDF: runs-on: ubuntu-latest if: github.event_name == 'pull_request' && github.base_ref == 'main' diff --git a/hardhat-deploy/DeployCollectionFactory.ts b/hardhat-deploy/DeployCollectionFactory.ts new file mode 100644 index 00000000..cabf4847 --- /dev/null +++ b/hardhat-deploy/DeployCollectionFactory.ts @@ -0,0 +1,178 @@ +import { Provider, Wallet } from "zksync-ethers"; +import { Deployer } from "@matterlabs/hardhat-zksync"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import "@matterlabs/hardhat-zksync-node/dist/type-extensions"; +import "@matterlabs/hardhat-zksync-verify/dist/src/type-extensions"; +import * as dotenv from "dotenv"; + +// Load .env-prod for mainnet, .env-test otherwise +const envFile = + process.env.HARDHAT_NETWORK === "zkSyncMainnet" ? ".env-prod" : ".env-test"; +dotenv.config({ path: envFile }); + +/** + * Deploys the user collections system (CollectionFactory + UserCollection721 + + * UserCollection1155) on ZkSync Era, then verifies all four contracts. + * + * Mirrors the Envelope/Swarm Hardhat deploy scripts. Preferred over the Foundry + * flow (ops/deploy_collection_factory_zksync.sh) when source verification of the + * factory logic is needed: the `@matterlabs/hardhat-zksync-verify` plugin + * conveys `factoryDependencies` to the verifier, which the standard-JSON helper + * (ops/verify_zksync_contracts.py) does not — that gap leaves the factory logic + * unverifiable because it carries the ERC1967Proxy bytecode hash as a dep. + * + * Deploy order (matches DeployCollectionFactoryZkSync.s.sol): + * 1. UserCollection721 implementation (shared impl behind per-collection proxies) + * 2. UserCollection1155 implementation + * 3. CollectionFactory logic + * 4. ERC1967Proxy(factoryLogic, initialize(admin, operator, impl721, impl1155)) + * + * Required environment variables (from .env-test / .env-prod): + * - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas. + * - N_FACTORY_ADMIN: Address that will hold DEFAULT_ADMIN_ROLE (multisig on mainnet). + * - N_FACTORY_OPERATOR: Backend service address that will hold OPERATOR_ROLE. + * + * Usage: + * yarn hardhat deploy-zksync \ + * --script DeployCollectionFactory.ts \ + * --network zkSyncSepoliaTestnet + */ +module.exports = async function (hre: HardhatRuntimeEnvironment) { + const ZERO = "0x0000000000000000000000000000000000000000"; + + const rpcUrl = hre.network.config.url!; + const provider = new Provider(rpcUrl); + const wallet = new Wallet(process.env.DEPLOYER_PRIVATE_KEY!, provider); + const deployer = new Deployer(hre, wallet); + + const admin = process.env.N_FACTORY_ADMIN ?? ""; + const operator = process.env.N_FACTORY_OPERATOR ?? ""; + + if (!admin || admin === ZERO) { + throw new Error("N_FACTORY_ADMIN is required and must be non-zero"); + } + if (!operator || operator === ZERO) { + throw new Error("N_FACTORY_OPERATOR is required and must be non-zero"); + } + + console.log("=== Deploying User Collections on ZkSync ==="); + console.log("Network: ", hre.network.name); + console.log("Deployer: ", wallet.address); + console.log("Admin: ", admin); + console.log("Operator: ", operator); + console.log(""); + + // 1. UserCollection721 implementation (CREATE; deployed once, shared by all + // per-collection ERC1967Proxy instances the factory spins up later). + console.log("1. Deploying UserCollection721 implementation..."); + const impl721Artifact = await deployer.loadArtifact("UserCollection721"); + const impl721 = await deployer.deploy(impl721Artifact, []); + await impl721.waitForDeployment(); + const impl721Addr = await impl721.getAddress(); + console.log(" UserCollection721 Implementation:", impl721Addr); + + // 2. UserCollection1155 implementation. + console.log("2. Deploying UserCollection1155 implementation..."); + const impl1155Artifact = await deployer.loadArtifact("UserCollection1155"); + const impl1155 = await deployer.deploy(impl1155Artifact, []); + await impl1155.waitForDeployment(); + const impl1155Addr = await impl1155.getAddress(); + console.log(" UserCollection1155 Implementation:", impl1155Addr); + + // 3. CollectionFactory logic. + console.log("3. Deploying CollectionFactory logic..."); + const factoryArtifact = await deployer.loadArtifact("CollectionFactory"); + const factoryLogic = await deployer.deploy(factoryArtifact, []); + await factoryLogic.waitForDeployment(); + const factoryLogicAddr = await factoryLogic.getAddress(); + console.log(" CollectionFactory Implementation:", factoryLogicAddr); + + // 4. ERC1967Proxy + atomic initialize (this is the factory's OWN proxy; the + // per-collection proxies are deployed by the factory at createCollection*). + console.log("4. Deploying ERC1967Proxy(CollectionFactory)..."); + const initData = factoryLogic.interface.encodeFunctionData("initialize", [ + admin, + operator, + impl721Addr, + impl1155Addr, + ]); + // Load by fully-qualified name: the hardhat-zksync-upgradable plugin ships a + // second ERC1967Proxy artifact, so the bare short name is ambiguous (HH701). + const proxyArtifact = await deployer.loadArtifact( + "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy", + ); + const factoryProxy = await deployer.deploy(proxyArtifact, [ + factoryLogicAddr, + initData, + ]); + await factoryProxy.waitForDeployment(); + const factoryProxyAddr = await factoryProxy.getAddress(); + console.log(" CollectionFactory Proxy:", factoryProxyAddr); + console.log(""); + + console.log("=== Deployment Complete ==="); + console.log("CollectionFactory Proxy: ", factoryProxyAddr); + console.log("CollectionFactory Implementation:", factoryLogicAddr); + console.log("UserCollection721 Implementation: ", impl721Addr); + console.log("UserCollection1155 Implementation:", impl1155Addr); + console.log(""); + + // Verification — the hardhat-zksync-verify plugin handles the factory's + // factoryDependencies, so all four (incl. the factory logic) verify fully. + console.log("=== Verifying Contracts ==="); + + const verify = async ( + label: string, + address: string, + contract: string, + constructorArguments: any[], + ) => { + try { + console.log(`Verifying ${label}...`); + await hre.run("verify:verify", { address, contract, constructorArguments }); + } catch (e: any) { + console.log("Verification failed or already verified:", e.message); + } + }; + + await verify( + "UserCollection721", + impl721Addr, + "src/collections/UserCollection721.sol:UserCollection721", + [], + ); + await verify( + "UserCollection1155", + impl1155Addr, + "src/collections/UserCollection1155.sol:UserCollection1155", + [], + ); + await verify( + "CollectionFactory (logic)", + factoryLogicAddr, + "src/collections/CollectionFactory.sol:CollectionFactory", + [], + ); + await verify( + "CollectionFactory (proxy)", + factoryProxyAddr, + // Hardhat identifies OZ contracts by their npm remap path (where the + // artifact lives: artifacts-zk/@openzeppelin/...), NOT the Foundry lib path. + "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy", + [factoryLogicAddr, initData], + ); + + console.log(""); + console.log(`=== Add these to ${envFile}: ===`); + console.log(`COLLECTION_FACTORY_PROXY=${factoryProxyAddr}`); + console.log(`COLLECTION_FACTORY_IMPL=${factoryLogicAddr}`); + console.log(`USER_COLLECTION_721_IMPL=${impl721Addr}`); + console.log(`USER_COLLECTION_1155_IMPL=${impl1155Addr}`); + + if (admin === operator) { + console.log(""); + console.log( + "NOTE: N_FACTORY_ADMIN == N_FACTORY_OPERATOR. Fine for testnet, but on mainnet admin should be a multisig and operator a separate backend key.", + ); + } +}; diff --git a/hardhat.config.ts b/hardhat.config.ts index e8ebd10d..fce48c99 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -61,7 +61,10 @@ const config: HardhatUserConfig = { }, }, zksolc: { - version: "1.5.1", + // Aligned with foundry-zksync and the explorer verification settings + // (zksolc v1.5.15, optimizer mode 3) so hardhat-deployed contracts verify + // consistently — including the bare ERC1967Proxy via the standard-JSON path. + version: "1.5.15", settings: { // find all available options in the official documentation // https://era.zksync.io/docs/tools/hardhat/hardhat-zksync-solc.html#configuration diff --git a/ops/deploy_collection_factory_zksync.sh b/ops/deploy_collection_factory_zksync.sh new file mode 100755 index 00000000..dfc8c640 --- /dev/null +++ b/ops/deploy_collection_factory_zksync.sh @@ -0,0 +1,723 @@ +#!/bin/bash +# ============================================================================= +# deploy_collection_factory_zksync.sh +# +# Automated deployment script for the user collections system +# (CollectionFactory + UserCollection721 + UserCollection1155) on ZkSync Era. +# +# OVERVIEW: +# --------- +# Deploys the upgradeable user collections system to ZkSync Era using Foundry +# with --zksync (zksolc compiler). +# +# Mirrors the swarms deployment pattern (ops/deploy_swarm_contracts_zksync.sh): +# - Temp-move L1-incompatible files (SSTORE2/EXTCODECOPY) so zksolc compiles +# - Forge build with --zksync, skip tests +# - Run the Forge script via --broadcast (or dry-run without) +# - Source code verification via ops/verify_zksync_contracts.py (the +# ZkSync verifier rejects forge --verify and forge verify-contract) +# - Append deployed addresses to .env-test or .env-prod +# +# Collections itself has no SSTORE2/EXTCODECOPY usage, but `forge build --zksync` +# compiles the entire tree, so files like SwarmRegistryL1Upgradeable and +# test/upgrade-demo/TestUpgradeOnAnvil still need to be moved out of the way. +# +# CONTRACT ARCHITECTURE: +# ---------------------- +# - UserCollection721 implementation (deployed behind a per-collection ERC1967Proxy) +# - UserCollection1155 implementation (deployed behind a per-collection ERC1967Proxy) +# - CollectionFactory logic + ERC1967Proxy (UUPS-upgradeable factory) +# +# USAGE: +# ------ +# # Testnet dry run: +# ./ops/deploy_collection_factory_zksync.sh testnet +# +# # Testnet (actual deployment): +# ./ops/deploy_collection_factory_zksync.sh testnet --broadcast +# +# # Mainnet: +# ./ops/deploy_collection_factory_zksync.sh mainnet --broadcast +# +# REQUIRED ENVIRONMENT VARIABLES (loaded from .env-test / .env-prod): +# ------------------------------------------------------------------- +# - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas +# - N_FACTORY_ADMIN: Multisig that will hold DEFAULT_ADMIN_ROLE on the factory +# - N_FACTORY_OPERATOR: Backend service address that will hold OPERATOR_ROLE +# +# OPTIONAL ENVIRONMENT VARIABLES: +# ------------------------------- +# - L2_RPC: Override the default zkSync RPC URL for the network +# - OPERATOR_PRIVATE_KEY: Key holding OPERATOR_ROLE, used to sign the post-deploy +# createCollection721 smoke test. If unset, the smoke +# test runs only when the deployer EOA is the operator. +# - COMPILER_VERSION: solc version passed to source verification (default 0.8.26) +# - ZKSOLC_VERSION: zksolc version passed to source verification (default v1.5.15) +# - CONFIRM_MAINNET: Set to "YES" to skip the interactive mainnet confirmation +# prompt (for non-interactive/CI mainnet runs) +# - RUN_MAINNET_SMOKE_TEST: Set to "true" to allow the smoke test to create a +# (permanent) collection on mainnet; default skips it +# +# NOTE: For mainnet, prefer a keystore/--account over a raw private key in the +# env file — raw keys passed to `cast --private-key` are visible in `ps`. +# +# ============================================================================= + +# Exit on any error, and make pipelines fail if ANY stage fails (not just the +# last). Without pipefail, `forge ... | tee log` would mask a failed deploy +# because tee's exit status (0) would win. See H1 in the deploy-script review. +set -eo pipefail + +# ============================================================================= +# Configuration +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +NETWORK="${1:-testnet}" +BROADCAST="${2:-}" + +case "$NETWORK" in + testnet) + ENV_FILE=".env-test" + EXPLORER_URL="https://sepolia.explorer.zksync.io" + VERIFIER_URL="https://explorer.sepolia.era.zksync.dev/contract_verification" + ;; + mainnet) + ENV_FILE=".env-prod" + EXPLORER_URL="https://explorer.zksync.io" + VERIFIER_URL="https://zksync2-mainnet-explorer.zksync.io/contract_verification" + ;; + *) + echo "Error: Unknown network '$NETWORK'. Use 'testnet' or 'mainnet'." + exit 1 + ;; +esac + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Normalize an address or a 32-byte left-padded slot word to a comparable form: +# lowercase, 0x-prefixed, low 20 bytes only. Lets us compare `cast storage` +# output (padded) against a plain address regardless of case/padding. +_norm_addr() { + local hex="${1#0x}" + hex=$(echo "$hex" | tr '[:upper:]' '[:lower:]') + # Keep the rightmost 40 hex chars (20 bytes). + echo "0x${hex: -40}" +} + +# ============================================================================= +# Pre-flight Checks +# ============================================================================= + +preflight_checks() { + log_info "Running pre-flight checks..." + + cd "$PROJECT_ROOT" + + if ! command -v forge &> /dev/null; then + log_error "forge not found. Install foundry-zksync." + exit 1 + fi + + if ! forge --version | grep -q "zksync"; then + log_error "forge does not have ZkSync support. Install with: foundryup-zksync" + exit 1 + fi + + if ! command -v cast &> /dev/null; then + log_error "cast not found. Install foundry." + exit 1 + fi + + if [ ! -f "$ENV_FILE" ]; then + log_error "Environment file '$ENV_FILE' not found." + exit 1 + fi + + set -a + source "$ENV_FILE" + set +a + + if [ -z "$DEPLOYER_PRIVATE_KEY" ]; then + log_error "DEPLOYER_PRIVATE_KEY not set in $ENV_FILE" + exit 1 + fi + + if [ -z "$N_FACTORY_ADMIN" ]; then + log_error "N_FACTORY_ADMIN not set in $ENV_FILE (must be the factory admin multisig)" + exit 1 + fi + + if [ -z "$N_FACTORY_OPERATOR" ]; then + log_error "N_FACTORY_OPERATOR not set in $ENV_FILE (must be the backend service address)" + exit 1 + fi + + if [[ "$DEPLOYER_PRIVATE_KEY" != 0x* ]]; then + export DEPLOYER_PRIVATE_KEY="0x${DEPLOYER_PRIVATE_KEY}" + fi + + # Mainnet guardrail: require explicit confirmation before an irreversible + # broadcast. Set CONFIRM_MAINNET=YES to bypass for non-interactive runs. + if [ "$NETWORK" = "mainnet" ] && [ "$BROADCAST" = "--broadcast" ]; then + if [ "${CONFIRM_MAINNET:-}" = "YES" ]; then + log_warning "CONFIRM_MAINNET=YES set — proceeding with mainnet broadcast without prompt." + else + log_warning "About to deploy to ZkSync MAINNET (irreversible)." + log_warning " Admin: $N_FACTORY_ADMIN" + log_warning " Operator: $N_FACTORY_OPERATOR" + read -r -p "Type 'YES' to confirm mainnet deployment: " confirm + if [ "$confirm" != "YES" ]; then + log_error "Mainnet deployment aborted by user." + exit 1 + fi + fi + fi + + log_success "Pre-flight checks passed" +} + +# ============================================================================= +# Temporarily move L1-incompatible contracts so zksolc can compile the tree. +# Mirrors the move/restore pattern in ops/deploy_swarm_contracts_zksync.sh. +# ============================================================================= + +L1_BACKUP_DIR="/tmp/rollup-l1-backup-collections-deploy" + +move_l1_contracts() { + log_info "Moving L1-incompatible contracts to temporary location..." + + if [ -d "$L1_BACKUP_DIR" ]; then + log_warning "Found previous backup, restoring first..." + restore_l1_contracts 2>/dev/null || true + fi + + mkdir -p "$L1_BACKUP_DIR" + + [ -f "src/swarms/SwarmRegistryL1Upgradeable.sol" ] && \ + mv "src/swarms/SwarmRegistryL1Upgradeable.sol" "$L1_BACKUP_DIR/" + + [ -f "test/SwarmRegistryL1.t.sol" ] && \ + mv "test/SwarmRegistryL1.t.sol" "$L1_BACKUP_DIR/" + + [ -d "test/upgrade-demo" ] && \ + mv "test/upgrade-demo" "$L1_BACKUP_DIR/" + + [ -f "script/DeploySwarmUpgradeable.s.sol" ] && \ + mv "script/DeploySwarmUpgradeable.s.sol" "$L1_BACKUP_DIR/" + + [ -f "script/UpgradeSwarm.s.sol" ] && \ + mv "script/UpgradeSwarm.s.sol" "$L1_BACKUP_DIR/" + + log_success "L1 contracts moved to $L1_BACKUP_DIR" +} + +restore_l1_contracts() { + [ -d "$L1_BACKUP_DIR" ] || return 0 + log_info "Restoring L1 contracts from backup..." + + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" ] && \ + mv "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" "src/swarms/" + + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" ] && \ + mv "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" "test/" + + [ -d "$L1_BACKUP_DIR/upgrade-demo" ] && \ + mv "$L1_BACKUP_DIR/upgrade-demo" "test/" + + [ -f "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" ] && \ + mv "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" "script/" + + [ -f "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" ] && \ + mv "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" "script/" + + rm -rf "$L1_BACKUP_DIR" + + log_success "L1 contracts restored" +} + +trap restore_l1_contracts EXIT + +# ============================================================================= +# Compile +# ============================================================================= + +compile_contracts() { + log_info "Compiling contracts with Forge for ZkSync..." + forge build --zksync --skip test + log_success "Compilation complete" +} + +# ============================================================================= +# Build-artifact verification — factoryDependencies must be populated. +# Empty factoryDependencies on CollectionFactory means createCollection* +# would revert at runtime on EraVM (the original Clones.clone() bug). +# ============================================================================= + +verify_build_artifacts() { + log_info "Verifying CollectionFactory factoryDependencies are populated..." + + local artifact="zkout/CollectionFactory.sol/CollectionFactory.json" + if [ ! -f "$artifact" ]; then + log_error "Compiled artifact not found: $artifact" + exit 1 + fi + + local dep_count + if ! dep_count=$(jq -r '.factoryDependencies | length' "$artifact" 2>&1); then + log_error "jq failed parsing $artifact: $dep_count" + exit 1 + fi + + if [ -z "$dep_count" ] || [ "$dep_count" -eq 0 ]; then + log_error "CollectionFactory.factoryDependencies is empty or unreadable." + log_error "This means the factory cannot deploy per-collection proxies on EraVM." + log_error "Refer to design §3.5.2 / §7.2 row 15b — ERC1967Proxy must appear in factoryDeps." + exit 1 + fi + + log_success "factoryDependencies populated ($dep_count entries)" +} + +# ============================================================================= +# Implementation permanence (EraVM artifact gate). +# +# The Foundry opcode-walker test (test/collections/*.t.sol) asserts "no +# SELFDESTRUCT" against the EVM-compiled bytecode. That check does NOT carry +# over to the deployed artifact: EraVM uses a different bytecode format and ISA, +# and `selfdestruct` is unsupported at the VM level — so the impl can't be wiped +# on the target chain regardless. What CAN still regress is the impl +# accidentally exposing an upgrade entry point (e.g. someone adds +# `UUPSUpgradeable` later), which would break the §1.3 per-collection +# immutability promise. Function selectors are VM-agnostic, so we gate on the +# zksolc-emitted ABI of the actual deployed implementations. +# ============================================================================= + +verify_implementation_permanence() { + log_info "Verifying implementation ABIs expose no upgrade selectors..." + + local impls=( + "zkout/UserCollection721.sol/UserCollection721.json" + "zkout/UserCollection1155.sol/UserCollection1155.json" + ) + local forbidden='["upgradeTo","upgradeToAndCall","proxiableUUID"]' + + for artifact in "${impls[@]}"; do + if [ ! -f "$artifact" ]; then + log_error "Compiled artifact not found: $artifact" + exit 1 + fi + + local hits + if ! hits=$(jq -r --argjson f "$forbidden" \ + '[.abi[] | select(.type=="function") | .name] | map(select(. as $n | $f | index($n))) | length' \ + "$artifact" 2>&1); then + log_error "jq failed parsing $artifact: $hits" + exit 1 + fi + + if [ -z "$hits" ] || [ "$hits" -ne 0 ]; then + log_error "$artifact exposes an upgrade selector (proxiableUUID/upgradeTo*)." + log_error "Implementations must NOT inherit UUPSUpgradeable — see design §3.5.2 / §7.2 row 15b." + exit 1 + fi + + log_success "$(basename "$artifact"): no upgrade selectors" + done +} + +# ============================================================================= +# Deploy +# ============================================================================= + +deploy_contracts() { + log_info "Deploying CollectionFactory to ZkSync ($NETWORK)..." + + if [ "$NETWORK" = "mainnet" ]; then + RPC_URL="${L2_RPC:-https://mainnet.era.zksync.io}" + CHAIN_ID="324" + else + RPC_URL="${L2_RPC:-https://rpc.ankr.com/zksync_era_sepolia}" + CHAIN_ID="300" + fi + + FORGE_ARGS=( + "script" + "script/DeployCollectionFactoryZkSync.s.sol:DeployCollectionFactoryZkSync" + "--rpc-url" "$RPC_URL" + "--chain-id" "$CHAIN_ID" + "--zksync" + ) + + if [ "$BROADCAST" = "--broadcast" ]; then + FORGE_ARGS+=("--broadcast" "--slow") + # NOTE: We do NOT add --verify here. forge script --verify sends absolute + # source paths which the ZkSync verifier rejects. Source code verification + # is handled separately in verify_source_code() using the helper Python + # script that rewrites imports to project-rooted paths. + else + log_warning "DRY RUN MODE - Add '--broadcast' to actually deploy" + log_info "Would deploy with:" + log_info " N_FACTORY_ADMIN: $N_FACTORY_ADMIN" + log_info " N_FACTORY_OPERATOR: $N_FACTORY_OPERATOR" + log_info " RPC: $RPC_URL" + return 0 + fi + + DEPLOY_LOG="/tmp/collections-deploy-$$.txt" + + forge "${FORGE_ARGS[@]}" 2>&1 | tee "$DEPLOY_LOG" + + if [ "$BROADCAST" = "--broadcast" ]; then + COLLECTION_FACTORY_PROXY=$(grep -o 'CollectionFactory Proxy: 0x[0-9a-fA-F]*' "$DEPLOY_LOG" | tail -1 | grep -o '0x[0-9a-fA-F]*') + COLLECTION_FACTORY_IMPL=$(grep -o 'CollectionFactory Implementation: 0x[0-9a-fA-F]*' "$DEPLOY_LOG" | tail -1 | grep -o '0x[0-9a-fA-F]*') + USER_COLLECTION_721_IMPL=$(grep -o 'UserCollection721 Implementation: 0x[0-9a-fA-F]*' "$DEPLOY_LOG" | tail -1 | grep -o '0x[0-9a-fA-F]*') + USER_COLLECTION_1155_IMPL=$(grep -o 'UserCollection1155 Implementation: 0x[0-9a-fA-F]*' "$DEPLOY_LOG" | tail -1 | grep -o '0x[0-9a-fA-F]*') + + if [ -z "$COLLECTION_FACTORY_PROXY" ] || [ -z "$COLLECTION_FACTORY_IMPL" ] \ + || [ -z "$USER_COLLECTION_721_IMPL" ] || [ -z "$USER_COLLECTION_1155_IMPL" ]; then + log_error "Could not extract all addresses from deploy output" + log_info "Full output saved to: $DEPLOY_LOG" + cat "$DEPLOY_LOG" + exit 1 + fi + log_success "Deployment complete!" + fi + + rm -f "$DEPLOY_LOG" +} + +# ============================================================================= +# Post-deploy sanity checks +# ============================================================================= + +verify_deployment() { + if [ "$BROADCAST" != "--broadcast" ]; then + return 0 + fi + + log_info "Verifying deployment..." + + if [ "$NETWORK" = "mainnet" ]; then + RPC_URL="${L2_RPC:-https://mainnet.era.zksync.io}" + else + RPC_URL="${L2_RPC:-https://rpc.ankr.com/zksync_era_sepolia}" + fi + + local ADMIN_ROLE_HASH="0x0000000000000000000000000000000000000000000000000000000000000000" + local OPERATOR_ROLE_HASH + OPERATOR_ROLE_HASH=$(cast keccak "OPERATOR_ROLE") + + log_info "Checking DEFAULT_ADMIN_ROLE granted to admin..." + HAS_ADMIN=$(cast call "$COLLECTION_FACTORY_PROXY" \ + "hasRole(bytes32,address)(bool)" \ + "$ADMIN_ROLE_HASH" "$N_FACTORY_ADMIN" --rpc-url "$RPC_URL") + if [ "$HAS_ADMIN" != "true" ]; then + log_error "DEFAULT_ADMIN_ROLE is NOT granted to $N_FACTORY_ADMIN (got: $HAS_ADMIN)" + exit 1 + fi + log_success "Admin role granted: $HAS_ADMIN" + + log_info "Checking OPERATOR_ROLE granted to operator..." + HAS_OP=$(cast call "$COLLECTION_FACTORY_PROXY" \ + "hasRole(bytes32,address)(bool)" \ + "$OPERATOR_ROLE_HASH" "$N_FACTORY_OPERATOR" --rpc-url "$RPC_URL") + if [ "$HAS_OP" != "true" ]; then + log_error "OPERATOR_ROLE is NOT granted to $N_FACTORY_OPERATOR (got: $HAS_OP)" + exit 1 + fi + log_success "Operator role granted: $HAS_OP" + + log_info "Checking implementation pointers..." + IMPL_721=$(cast call "$COLLECTION_FACTORY_PROXY" "erc721Implementation()(address)" --rpc-url "$RPC_URL") + IMPL_1155=$(cast call "$COLLECTION_FACTORY_PROXY" "erc1155Implementation()(address)" --rpc-url "$RPC_URL") + if [ "$(_norm_addr "$IMPL_721")" != "$(_norm_addr "$USER_COLLECTION_721_IMPL")" ]; then + log_error "erc721Implementation mismatch: on-chain $IMPL_721 != deployed $USER_COLLECTION_721_IMPL" + exit 1 + fi + if [ "$(_norm_addr "$IMPL_1155")" != "$(_norm_addr "$USER_COLLECTION_1155_IMPL")" ]; then + log_error "erc1155Implementation mismatch: on-chain $IMPL_1155 != deployed $USER_COLLECTION_1155_IMPL" + exit 1 + fi + log_success "erc721Implementation: $IMPL_721" + log_success "erc1155Implementation: $IMPL_1155" + + log_info "Checking EIP-1967 implementation slot points at factory logic..." + IMPL_SLOT="0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" + STORED_IMPL=$(cast storage "$COLLECTION_FACTORY_PROXY" "$IMPL_SLOT" --rpc-url "$RPC_URL") + # The slot stores a left-padded 32-byte word; compare the low 20 bytes. + if [ "$(_norm_addr "$STORED_IMPL")" != "$(_norm_addr "$COLLECTION_FACTORY_IMPL")" ]; then + log_error "EIP-1967 slot mismatch: stored $STORED_IMPL != factory logic $COLLECTION_FACTORY_IMPL" + exit 1 + fi + log_success "EIP-1967 stored impl: $STORED_IMPL" + + log_success "Post-deploy sanity checks passed" +} + +# ============================================================================= +# End-to-end smoke test — exercise createCollection721 on the live network. +# This is the empirical check that the EraVM-compiled output works at runtime. +# ============================================================================= + +smoke_test_createCollection() { + if [ "$BROADCAST" != "--broadcast" ]; then + return 0 + fi + + # The smoke test creates a real, PERMANENT collection (collections are + # immutable and the externalId is consumed forever). On mainnet that pollutes + # the production registry, so skip it unless explicitly opted in. + if [ "$NETWORK" = "mainnet" ] && [ "${RUN_MAINNET_SMOKE_TEST:-}" != "true" ]; then + log_warning "Skipping createCollection721 smoke test on mainnet (would create a permanent collection)." + log_warning "Set RUN_MAINNET_SMOKE_TEST=true to run it intentionally." + return 0 + fi + + log_info "Running end-to-end smoke test: createCollection721..." + + local rpc + if [ "$NETWORK" = "mainnet" ]; then + rpc="${L2_RPC:-https://mainnet.era.zksync.io}" + else + rpc="${L2_RPC:-https://rpc.ankr.com/zksync_era_sepolia}" + fi + + # Determine which private key holds OPERATOR_ROLE for signing. + # Production typically separates the deployer EOA from the operator multisig + # or backend key; only run the smoke test if we have a key that can sign. + local signer_key + if [ -n "$OPERATOR_PRIVATE_KEY" ]; then + signer_key="$OPERATOR_PRIVATE_KEY" + [[ "$signer_key" != 0x* ]] && signer_key="0x${signer_key}" + else + local deployer_addr + deployer_addr=$(cast wallet address --private-key "$DEPLOYER_PRIVATE_KEY") + if [ "$(echo "$deployer_addr" | tr '[:upper:]' '[:lower:]')" = "$(echo "$N_FACTORY_OPERATOR" | tr '[:upper:]' '[:lower:]')" ]; then + signer_key="$DEPLOYER_PRIVATE_KEY" + else + log_warning "Skipping smoke test: deployer address ($deployer_addr) is not the operator ($N_FACTORY_OPERATOR), and OPERATOR_PRIVATE_KEY is not set." + log_warning "To run the smoke test against a multisig/separate operator, export OPERATOR_PRIVATE_KEY with a key that holds OPERATOR_ROLE." + return 0 + fi + fi + + # Build a minimal CreateParams721 calldata. owner = operator, + # additionalMinters = empty array, royaltyBps = 0, simple URIs. + local extId + extId=$(cast keccak "smoke-$(date +%s)") + + log_info "Calling createCollection721($extId)..." + # CreateParams721 fields per src/collections/interfaces/CollectionTypes.sol: + # (address owner, string name, string symbol, string baseURI, string contractURI, + # address royaltyRecipient, uint96 royaltyBps, address[] additionalMinters) + cast send "$COLLECTION_FACTORY_PROXY" \ + "createCollection721((address,string,string,string,string,address,uint96,address[]),bytes32)" \ + "($N_FACTORY_OPERATOR,Smoke,SMK,ipfs://smoke/,ipfs://smoke.json,$N_FACTORY_OPERATOR,0,[])" \ + "$extId" \ + --rpc-url "$rpc" \ + --private-key "$signer_key" \ + --zksync \ + || { log_error "createCollection721 reverted on-chain"; exit 1; } + + # Read the resulting collection address from the mapping. + local collection + collection=$(cast call "$COLLECTION_FACTORY_PROXY" \ + "collectionByExternalId(bytes32)(address)" "$extId" --rpc-url "$rpc") + + log_info "Smoke collection deployed at: $collection" + + # Assert non-empty code at the collection address. + local code_size + code_size=$(cast code "$collection" --rpc-url "$rpc" | wc -c) + if [ "$code_size" -lt 10 ]; then + log_error "Smoke collection has empty bytecode" + exit 1 + fi + + # Assert EIP-1967 impl slot equals expected impl. + local EIP1967_IMPL_SLOT="0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" + local stored + stored=$(cast storage "$collection" "$EIP1967_IMPL_SLOT" --rpc-url "$rpc") + if [ "$(_norm_addr "$stored")" != "$(_norm_addr "$USER_COLLECTION_721_IMPL")" ]; then + log_error "Smoke collection EIP-1967 slot mismatch: stored $stored != expected impl $USER_COLLECTION_721_IMPL" + exit 1 + fi + log_info "EIP-1967 impl slot: $stored (matches expected impl: $USER_COLLECTION_721_IMPL)" + + log_success "Smoke test passed: createCollection721 succeeded; collection has code; EIP-1967 slot verified" +} + +# ============================================================================= +# Source code verification on the block explorer +# ============================================================================= + +verify_source_code() { + if [ "$BROADCAST" != "--broadcast" ]; then + return 0 + fi + + log_info "Verifying source code on block explorer..." + + if [ "$NETWORK" = "mainnet" ]; then + CHAIN_ID="324" + else + CHAIN_ID="300" + fi + + BROADCAST_JSON="broadcast/DeployCollectionFactoryZkSync.s.sol/${CHAIN_ID}/run-latest.json" + if [ ! -f "$BROADCAST_JSON" ]; then + log_error "Broadcast file not found: $BROADCAST_JSON" + log_warning "Skipping source code verification" + return 1 + fi + + if ! command -v python3 &> /dev/null; then + log_error "python3 not found. Install Python 3.8+ for source code verification." + log_warning "Skipping source code verification" + return 1 + fi + + # Versions default to the toolchain this script was written against; override + # via COMPILER_VERSION / ZKSOLC_VERSION when the installed toolchain differs, + # otherwise verification silently fails on a version mismatch. + # Capture the exit code WITHOUT letting `set -e` abort here: source + # verification failing is non-fatal (the contracts are already deployed), it + # just needs a manual retry. The `|| exit_code=$?` keeps set -e from killing + # the script before we can warn. + local exit_code=0 + python3 "$SCRIPT_DIR/verify_zksync_contracts.py" \ + --broadcast "$BROADCAST_JSON" \ + --verifier-url "$VERIFIER_URL" \ + --compiler-version "${COMPILER_VERSION:-0.8.26}" \ + --zksolc-version "${ZKSOLC_VERSION:-v1.5.15}" \ + --project-root "$PROJECT_ROOT" || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + log_success "All contracts source-code verified on block explorer!" + else + log_warning "Some contracts failed source verification (deployment itself succeeded)" + log_info "Retry manually: python3 ops/verify_zksync_contracts.py --broadcast $BROADCAST_JSON --verifier-url $VERIFIER_URL" + fi +} + +# ============================================================================= +# Append deployed addresses to env file +# ============================================================================= + +update_env_file() { + if [ "$BROADCAST" != "--broadcast" ]; then + return 0 + fi + + log_info "Updating $ENV_FILE with deployed addresses..." + + TIMESTAMP=$(date +%Y-%m-%d) + + if grep -q "COLLECTION_FACTORY_PROXY" "$ENV_FILE"; then + log_info "Updating existing collections addresses in $ENV_FILE..." + sed -i.bak '/^# User Collections/,/^$/d' "$ENV_FILE" + sed -i.bak '/^COLLECTION_FACTORY_PROXY=/d' "$ENV_FILE" + sed -i.bak '/^COLLECTION_FACTORY_IMPL=/d' "$ENV_FILE" + sed -i.bak '/^USER_COLLECTION_721_IMPL=/d' "$ENV_FILE" + sed -i.bak '/^USER_COLLECTION_1155_IMPL=/d' "$ENV_FILE" + sed -i.bak -e :a -e '/^\n*$/{$d;N;ba' -e '}' "$ENV_FILE" + rm -f "${ENV_FILE}.bak" + fi + + cat >> "$ENV_FILE" << EOF + +# User Collections (ZkSync Era - deployed $TIMESTAMP) +COLLECTION_FACTORY_PROXY=$COLLECTION_FACTORY_PROXY +COLLECTION_FACTORY_IMPL=$COLLECTION_FACTORY_IMPL +USER_COLLECTION_721_IMPL=$USER_COLLECTION_721_IMPL +USER_COLLECTION_1155_IMPL=$USER_COLLECTION_1155_IMPL +EOF + + log_success "Environment file updated" +} + +# ============================================================================= +# Summary +# ============================================================================= + +print_summary() { + echo "" + echo "==============================================" + echo " DEPLOYMENT SUMMARY" + echo "==============================================" + echo "" + echo "Network: $NETWORK" + echo "Explorer: $EXPLORER_URL" + echo "" + + if [ "$BROADCAST" != "--broadcast" ]; then + echo "Mode: DRY RUN (no contracts deployed)" + echo "" + echo "To deploy for real, run:" + echo " $0 $NETWORK --broadcast" + return 0 + fi + + echo "Deployed Contracts:" + echo "-------------------" + echo "" + echo "CollectionFactory:" + echo " Proxy: $COLLECTION_FACTORY_PROXY" + echo " Implementation: $COLLECTION_FACTORY_IMPL" + echo " Explorer: $EXPLORER_URL/address/$COLLECTION_FACTORY_PROXY" + echo "" + echo "UserCollection721:" + echo " Implementation: $USER_COLLECTION_721_IMPL" + echo " Explorer: $EXPLORER_URL/address/$USER_COLLECTION_721_IMPL" + echo "" + echo "UserCollection1155:" + echo " Implementation: $USER_COLLECTION_1155_IMPL" + echo " Explorer: $EXPLORER_URL/address/$USER_COLLECTION_1155_IMPL" + echo "" + echo "Configuration:" + echo " Admin: $N_FACTORY_ADMIN" + echo " Operator: $N_FACTORY_OPERATOR" + echo "" + echo "==============================================" +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + echo "" + echo "==============================================" + echo " ZkSync User Collections Deployment" + echo "==============================================" + echo "" + + cd "$PROJECT_ROOT" + + preflight_checks + move_l1_contracts + compile_contracts + verify_build_artifacts + verify_implementation_permanence + deploy_contracts + verify_deployment + smoke_test_createCollection + verify_source_code + update_env_file + print_summary +} + +main "$@" diff --git a/ops/upgrade_collection_factory_zksync.sh b/ops/upgrade_collection_factory_zksync.sh new file mode 100755 index 00000000..c3a88a13 --- /dev/null +++ b/ops/upgrade_collection_factory_zksync.sh @@ -0,0 +1,541 @@ +#!/bin/bash +# ============================================================================= +# upgrade_collection_factory_zksync.sh +# +# Orchestration wrapper for upgrading the user collections system on ZkSync Era. +# Companion to ops/deploy_collection_factory_zksync.sh; drives +# script/UpgradeCollectionFactory.s.sol through the same safety scaffolding the +# deploy uses (L1-file move/restore, --zksync compile, artifact gates, mainnet +# guard, post-broadcast asserts, source verification). +# +# THREE ACTIONS (see spec §9.4 and the Forge script's NatSpec): +# UPGRADE_FACTORY Deploy new CollectionFactory logic + upgradeToAndCall on the +# proxy. Changes the factory's EIP-1967 implementation slot. +# Optional REINIT_DATA env runs a reinitializer in the same tx. +# SET_IMPL_721 Deploy new UserCollection721 impl + setImplementation721. +# Affects FUTURE collections only; existing ones are immutable. +# SET_IMPL_1155 Same for UserCollection1155. +# +# USAGE: +# # Dry run (no broadcast): +# ./ops/upgrade_collection_factory_zksync.sh testnet UPGRADE_FACTORY +# +# # Broadcast: +# ./ops/upgrade_collection_factory_zksync.sh testnet SET_IMPL_721 --broadcast +# ./ops/upgrade_collection_factory_zksync.sh mainnet UPGRADE_FACTORY --broadcast +# +# REQUIRED ENVIRONMENT VARIABLES (loaded from .env-test / .env-prod): +# - DEPLOYER_PRIVATE_KEY: Key holding DEFAULT_ADMIN_ROLE on the factory proxy. +# - COLLECTION_FACTORY_PROXY (or FACTORY_PROXY): factory proxy address. +# +# OPTIONAL ENVIRONMENT VARIABLES: +# - L2_RPC: Override the default zkSync RPC URL. +# - REINIT_DATA: (UPGRADE_FACTORY only) ABI-encoded reinitializer call. +# - CONFIRM_MAINNET: Set to "YES" to skip the mainnet confirmation prompt. +# - COMPILER_VERSION / ZKSOLC_VERSION: source-verification version overrides. +# +# NOTE: prefer a keystore/--account over a raw private key for mainnet — raw +# keys passed to `cast --private-key` are visible in `ps`. +# ============================================================================= + +# Exit on any error; fail pipelines if any stage fails (not just the last). +set -eo pipefail + +# ============================================================================= +# Configuration +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +NETWORK="${1:-testnet}" +ACTION="${2:-}" +BROADCAST="${3:-}" + +case "$NETWORK" in + testnet) + ENV_FILE=".env-test" + EXPLORER_URL="https://sepolia.explorer.zksync.io" + VERIFIER_URL="https://explorer.sepolia.era.zksync.dev/contract_verification" + ;; + mainnet) + ENV_FILE=".env-prod" + EXPLORER_URL="https://explorer.zksync.io" + VERIFIER_URL="https://zksync2-mainnet-explorer.zksync.io/contract_verification" + ;; + *) + echo "Error: Unknown network '$NETWORK'. Use 'testnet' or 'mainnet'." + exit 1 + ;; +esac + +# Map the action to the contract it deploys and its source identifier. +case "$ACTION" in + UPGRADE_FACTORY) + TARGET_CONTRACT="CollectionFactory" + TARGET_SRC="src/collections/CollectionFactory.sol:CollectionFactory" + ;; + SET_IMPL_721) + TARGET_CONTRACT="UserCollection721" + TARGET_SRC="src/collections/UserCollection721.sol:UserCollection721" + ;; + SET_IMPL_1155) + TARGET_CONTRACT="UserCollection1155" + TARGET_SRC="src/collections/UserCollection1155.sol:UserCollection1155" + ;; + *) + echo "Error: ACTION (arg 2) must be one of: UPGRADE_FACTORY, SET_IMPL_721, SET_IMPL_1155." + echo "Usage: $0 [--broadcast]" + exit 1 + ;; +esac + +if [ "$NETWORK" = "mainnet" ]; then + RPC_URL="${L2_RPC:-https://mainnet.era.zksync.io}" + CHAIN_ID="324" +else + RPC_URL="${L2_RPC:-https://rpc.ankr.com/zksync_era_sepolia}" + CHAIN_ID="300" +fi + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Normalize an address or 32-byte left-padded slot word to a comparable form: +# lowercase, 0x-prefixed, low 20 bytes only. +_norm_addr() { + local hex="${1#0x}" + hex=$(echo "$hex" | tr '[:upper:]' '[:lower:]') + echo "0x${hex: -40}" +} + +ADMIN_ROLE_HASH="0x0000000000000000000000000000000000000000000000000000000000000000" +IMPL_SLOT="0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" + +# ============================================================================= +# Pre-flight +# ============================================================================= + +preflight_checks() { + log_info "Running pre-flight checks (action: $ACTION, network: $NETWORK)..." + cd "$PROJECT_ROOT" + + if ! command -v forge &> /dev/null; then + log_error "forge not found. Install foundry-zksync." + exit 1 + fi + if ! forge --version | grep -q "zksync"; then + log_error "forge does not have ZkSync support. Install with: foundryup-zksync" + exit 1 + fi + if ! command -v cast &> /dev/null; then + log_error "cast not found. Install foundry." + exit 1 + fi + if [ ! -f "$ENV_FILE" ]; then + log_error "Environment file '$ENV_FILE' not found." + exit 1 + fi + + set -a + source "$ENV_FILE" + set +a + + if [ -z "$DEPLOYER_PRIVATE_KEY" ]; then + log_error "DEPLOYER_PRIVATE_KEY not set in $ENV_FILE (must hold DEFAULT_ADMIN_ROLE)" + exit 1 + fi + if [[ "$DEPLOYER_PRIVATE_KEY" != 0x* ]]; then + export DEPLOYER_PRIVATE_KEY="0x${DEPLOYER_PRIVATE_KEY}" + fi + + # Resolve the factory proxy: explicit FACTORY_PROXY wins, else the address the + # deploy script wrote to the env file as COLLECTION_FACTORY_PROXY. + PROXY="${FACTORY_PROXY:-${COLLECTION_FACTORY_PROXY:-}}" + if [ -z "$PROXY" ]; then + log_error "No factory proxy address. Set FACTORY_PROXY or COLLECTION_FACTORY_PROXY in $ENV_FILE." + exit 1 + fi + export FACTORY_PROXY="$PROXY" + export ACTION + + log_info "Factory proxy: $PROXY" + + # Mainnet guardrail. + if [ "$NETWORK" = "mainnet" ] && [ "$BROADCAST" = "--broadcast" ]; then + if [ "${CONFIRM_MAINNET:-}" = "YES" ]; then + log_warning "CONFIRM_MAINNET=YES set — proceeding with mainnet upgrade without prompt." + else + log_warning "About to run '$ACTION' against ZkSync MAINNET factory $PROXY (irreversible)." + read -r -p "Type 'YES' to confirm mainnet upgrade: " confirm + if [ "$confirm" != "YES" ]; then + log_error "Mainnet upgrade aborted by user." + exit 1 + fi + fi + fi + + log_success "Pre-flight checks passed" +} + +# ============================================================================= +# L1-incompatible file move/restore (so `forge ... --zksync` compiles the tree). +# Mirrors ops/deploy_collection_factory_zksync.sh. +# ============================================================================= + +L1_BACKUP_DIR="/tmp/rollup-l1-backup-collections-upgrade" + +move_l1_contracts() { + log_info "Moving L1-incompatible contracts to temporary location..." + if [ -d "$L1_BACKUP_DIR" ]; then + log_warning "Found previous backup, restoring first..." + restore_l1_contracts 2>/dev/null || true + fi + mkdir -p "$L1_BACKUP_DIR" + + [ -f "src/swarms/SwarmRegistryL1Upgradeable.sol" ] && \ + mv "src/swarms/SwarmRegistryL1Upgradeable.sol" "$L1_BACKUP_DIR/" + [ -f "test/SwarmRegistryL1.t.sol" ] && \ + mv "test/SwarmRegistryL1.t.sol" "$L1_BACKUP_DIR/" + [ -d "test/upgrade-demo" ] && \ + mv "test/upgrade-demo" "$L1_BACKUP_DIR/" + [ -f "script/DeploySwarmUpgradeable.s.sol" ] && \ + mv "script/DeploySwarmUpgradeable.s.sol" "$L1_BACKUP_DIR/" + [ -f "script/UpgradeSwarm.s.sol" ] && \ + mv "script/UpgradeSwarm.s.sol" "$L1_BACKUP_DIR/" + + log_success "L1 contracts moved to $L1_BACKUP_DIR" +} + +restore_l1_contracts() { + [ -d "$L1_BACKUP_DIR" ] || return 0 + log_info "Restoring L1 contracts from backup..." + + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" ] && \ + mv "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" "src/swarms/" + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" ] && \ + mv "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" "test/" + [ -d "$L1_BACKUP_DIR/upgrade-demo" ] && \ + mv "$L1_BACKUP_DIR/upgrade-demo" "test/" + [ -f "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" ] && \ + mv "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" "script/" + [ -f "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" ] && \ + mv "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" "script/" + + rm -rf "$L1_BACKUP_DIR" + log_success "L1 contracts restored" +} + +trap restore_l1_contracts EXIT + +# ============================================================================= +# Compile +# ============================================================================= + +compile_contracts() { + log_info "Compiling contracts with Forge for ZkSync..." + forge build --zksync --skip test + log_success "Compilation complete" +} + +# ============================================================================= +# Artifact gates (action-specific). +# ============================================================================= + +verify_artifacts() { + if [ "$ACTION" = "UPGRADE_FACTORY" ]; then + # The new factory logic must still register ERC1967Proxy as a factoryDep, + # or createCollection* would revert at runtime on EraVM. + log_info "Verifying CollectionFactory factoryDependencies are populated..." + local artifact="zkout/CollectionFactory.sol/CollectionFactory.json" + if [ ! -f "$artifact" ]; then + log_error "Compiled artifact not found: $artifact" + exit 1 + fi + local dep_count + if ! dep_count=$(jq -r '.factoryDependencies | length' "$artifact" 2>&1); then + log_error "jq failed parsing $artifact: $dep_count" + exit 1 + fi + if [ -z "$dep_count" ] || [ "$dep_count" -eq 0 ]; then + log_error "CollectionFactory.factoryDependencies is empty — factory cannot deploy proxies." + exit 1 + fi + log_success "factoryDependencies populated ($dep_count entries)" + else + # New collection impl must not expose an upgrade selector (no UUPSUpgradeable), + # or the §1.3 per-collection immutability promise breaks. + log_info "Verifying $TARGET_CONTRACT exposes no upgrade selectors..." + local artifact="zkout/${TARGET_CONTRACT}.sol/${TARGET_CONTRACT}.json" + if [ ! -f "$artifact" ]; then + log_error "Compiled artifact not found: $artifact" + exit 1 + fi + local forbidden='["upgradeTo","upgradeToAndCall","proxiableUUID"]' + local hits + if ! hits=$(jq -r --argjson f "$forbidden" \ + '[.abi[] | select(.type=="function") | .name] | map(select(. as $n | $f | index($n))) | length' \ + "$artifact" 2>&1); then + log_error "jq failed parsing $artifact: $hits" + exit 1 + fi + if [ -z "$hits" ] || [ "$hits" -ne 0 ]; then + log_error "$TARGET_CONTRACT exposes an upgrade selector — must not inherit UUPSUpgradeable." + exit 1 + fi + log_success "$TARGET_CONTRACT: no upgrade selectors" + fi +} + +# ============================================================================= +# Storage-layout reminder. +# We no longer commit static layout baselines (they go stale and only mirror +# what git already has). For a real upgrade, regenerate the previous layout from +# the released ref and diff it against the new one — only appended fields +# (consuming __gap) are upgrade-safe; any moved/resized prior slot corrupts +# storage. +# ============================================================================= + +storage_layout_reminder() { + if [ "$ACTION" != "UPGRADE_FACTORY" ]; then + return 0 + fi + log_warning "Storage-layout check is manual. Before broadcasting a factory upgrade, diff the layout:" + log_warning " git stash; git checkout ; forge inspect $TARGET_CONTRACT storageLayout --json > /tmp/old.json; git checkout -; git stash pop" + log_warning " forge inspect $TARGET_CONTRACT storageLayout --json > /tmp/new.json" + log_warning " diff <(jq -S '.storage|map({label,slot,offset,type})' /tmp/old.json) <(jq -S '.storage|map({label,slot,offset,type})' /tmp/new.json)" + log_warning " Only APPENDED fields are safe. (Or wire up the OZ/zksync upgradable plugin for automated validation.)" +} + +# ============================================================================= +# On-chain admin-key + pre-state checks (broadcast only, read-only, pre-upgrade). +# ============================================================================= + +PRE_IMPL_721="" +PRE_IMPL_1155="" + +capture_pre_state() { + log_info "Verifying the deployer key holds DEFAULT_ADMIN_ROLE..." + local deployer + deployer=$(cast wallet address --private-key "$DEPLOYER_PRIVATE_KEY") + local is_admin + is_admin=$(cast call "$PROXY" "hasRole(bytes32,address)(bool)" \ + "$ADMIN_ROLE_HASH" "$deployer" --rpc-url "$RPC_URL") + if [ "$is_admin" != "true" ]; then + log_error "Deployer $deployer does NOT hold DEFAULT_ADMIN_ROLE on $PROXY — upgrade would revert." + exit 1 + fi + log_success "Deployer $deployer holds DEFAULT_ADMIN_ROLE" + + PRE_IMPL_721=$(cast call "$PROXY" "erc721Implementation()(address)" --rpc-url "$RPC_URL") + PRE_IMPL_1155=$(cast call "$PROXY" "erc1155Implementation()(address)" --rpc-url "$RPC_URL") + log_info "Pre-upgrade erc721Implementation: $PRE_IMPL_721" + log_info "Pre-upgrade erc1155Implementation: $PRE_IMPL_1155" +} + +# ============================================================================= +# Run the upgrade +# ============================================================================= + +NEW_IMPL="" + +run_upgrade() { + local forge_args=( + "script" + "script/UpgradeCollectionFactory.s.sol:UpgradeCollectionFactory" + "--rpc-url" "$RPC_URL" + "--chain-id" "$CHAIN_ID" + "--zksync" + ) + + if [ "$BROADCAST" != "--broadcast" ]; then + log_warning "DRY RUN MODE - Add '--broadcast' to actually upgrade" + log_info "Would run action '$ACTION' on proxy $PROXY via $RPC_URL" + [ -n "${REINIT_DATA:-}" ] && log_info " REINIT_DATA present (reinitializer migration)" + return 0 + fi + + capture_pre_state + + forge_args+=("--broadcast" "--slow") + + local upgrade_log="/tmp/collections-upgrade-$$.txt" + forge "${forge_args[@]}" 2>&1 | tee "$upgrade_log" + + NEW_IMPL=$(grep -o 'New Implementation: 0x[0-9a-fA-F]*' "$upgrade_log" | tail -1 | grep -o '0x[0-9a-fA-F]*') + rm -f "$upgrade_log" + + if [ -z "$NEW_IMPL" ]; then + log_error "Could not extract the new implementation address from the upgrade output." + exit 1 + fi + log_success "Upgrade broadcast. New implementation: $NEW_IMPL" +} + +# ============================================================================= +# Post-upgrade verification (broadcast only) — assert, don't just print. +# ============================================================================= + +verify_upgrade() { + if [ "$BROADCAST" != "--broadcast" ]; then + return 0 + fi + + log_info "Verifying upgrade outcome on-chain..." + + # Admin role must survive any upgrade. + local has_admin deployer + deployer=$(cast wallet address --private-key "$DEPLOYER_PRIVATE_KEY") + has_admin=$(cast call "$PROXY" "hasRole(bytes32,address)(bool)" \ + "$ADMIN_ROLE_HASH" "$deployer" --rpc-url "$RPC_URL") + if [ "$has_admin" != "true" ]; then + log_error "DEFAULT_ADMIN_ROLE lost after upgrade — aborting (state may be corrupt)." + exit 1 + fi + + local cur_721 cur_1155 + cur_721=$(cast call "$PROXY" "erc721Implementation()(address)" --rpc-url "$RPC_URL") + cur_1155=$(cast call "$PROXY" "erc1155Implementation()(address)" --rpc-url "$RPC_URL") + + case "$ACTION" in + UPGRADE_FACTORY) + # EIP-1967 slot must now point at the new factory logic. + local stored + stored=$(cast storage "$PROXY" "$IMPL_SLOT" --rpc-url "$RPC_URL") + if [ "$(_norm_addr "$stored")" != "$(_norm_addr "$NEW_IMPL")" ]; then + log_error "EIP-1967 slot $stored != new factory logic $NEW_IMPL" + exit 1 + fi + # Impl pointers must be preserved across the logic upgrade. + if [ "$(_norm_addr "$cur_721")" != "$(_norm_addr "$PRE_IMPL_721")" ]; then + log_error "erc721Implementation changed across upgrade: $PRE_IMPL_721 -> $cur_721" + exit 1 + fi + if [ "$(_norm_addr "$cur_1155")" != "$(_norm_addr "$PRE_IMPL_1155")" ]; then + log_error "erc1155Implementation changed across upgrade: $PRE_IMPL_1155 -> $cur_1155" + exit 1 + fi + log_success "Factory logic upgraded; impl pointers and admin role preserved" + ;; + SET_IMPL_721) + if [ "$(_norm_addr "$cur_721")" != "$(_norm_addr "$NEW_IMPL")" ]; then + log_error "erc721Implementation not updated: on-chain $cur_721 != new $NEW_IMPL" + exit 1 + fi + if [ "$(_norm_addr "$cur_1155")" != "$(_norm_addr "$PRE_IMPL_1155")" ]; then + log_error "erc1155Implementation unexpectedly changed: $PRE_IMPL_1155 -> $cur_1155" + exit 1 + fi + log_success "erc721Implementation updated to $NEW_IMPL; 1155 pointer unchanged" + ;; + SET_IMPL_1155) + if [ "$(_norm_addr "$cur_1155")" != "$(_norm_addr "$NEW_IMPL")" ]; then + log_error "erc1155Implementation not updated: on-chain $cur_1155 != new $NEW_IMPL" + exit 1 + fi + if [ "$(_norm_addr "$cur_721")" != "$(_norm_addr "$PRE_IMPL_721")" ]; then + log_error "erc721Implementation unexpectedly changed: $PRE_IMPL_721 -> $cur_721" + exit 1 + fi + log_success "erc1155Implementation updated to $NEW_IMPL; 721 pointer unchanged" + ;; + esac + + log_success "Post-upgrade verification passed" +} + +# ============================================================================= +# Source verification of the newly deployed contract (single-contract mode). +# ============================================================================= + +verify_source_code() { + if [ "$BROADCAST" != "--broadcast" ]; then + return 0 + fi + if ! command -v python3 &> /dev/null; then + log_warning "python3 not found — skipping source verification." + return 0 + fi + + log_info "Verifying $TARGET_CONTRACT source on the block explorer ($NEW_IMPL)..." + + # All upgrade targets have parameterless constructors → no constructor args. + local exit_code=0 + python3 "$SCRIPT_DIR/verify_zksync_contracts.py" \ + --address "$NEW_IMPL" \ + --contract "$TARGET_SRC" \ + --verifier-url "$VERIFIER_URL" \ + --compiler-version "${COMPILER_VERSION:-0.8.26}" \ + --zksolc-version "${ZKSOLC_VERSION:-v1.5.15}" \ + --project-root "$PROJECT_ROOT" || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + log_success "$TARGET_CONTRACT source-code verified" + else + log_warning "Source verification failed (upgrade itself succeeded). Retry manually:" + log_warning " python3 ops/verify_zksync_contracts.py --address $NEW_IMPL --contract $TARGET_SRC --verifier-url $VERIFIER_URL" + fi +} + +# ============================================================================= +# Summary +# ============================================================================= + +print_summary() { + echo "" + echo "==============================================" + echo " UPGRADE SUMMARY" + echo "==============================================" + echo "" + echo "Network: $NETWORK" + echo "Action: $ACTION" + echo "Proxy: $PROXY" + echo "Explorer: $EXPLORER_URL/address/$PROXY" + echo "" + if [ "$BROADCAST" != "--broadcast" ]; then + echo "Mode: DRY RUN (nothing broadcast)" + echo "" + echo "To run for real:" + echo " $0 $NETWORK $ACTION --broadcast" + return 0 + fi + echo "New $TARGET_CONTRACT implementation: $NEW_IMPL" + echo "Explorer: $EXPLORER_URL/address/$NEW_IMPL" + echo "" + echo "==============================================" +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + echo "" + echo "==============================================" + echo " ZkSync User Collections Upgrade" + echo "==============================================" + echo "" + + cd "$PROJECT_ROOT" + + preflight_checks + move_l1_contracts + compile_contracts + verify_artifacts + storage_layout_reminder + run_upgrade + verify_upgrade + verify_source_code + print_summary +} + +main "$@" diff --git a/ops/verify_zksync_contracts.py b/ops/verify_zksync_contracts.py index 4bc7c623..855dd01b 100755 --- a/ops/verify_zksync_contracts.py +++ b/ops/verify_zksync_contracts.py @@ -86,6 +86,9 @@ "SwarmRegistryUniversalUpgradeable": "src/swarms/SwarmRegistryUniversalUpgradeable.sol:SwarmRegistryUniversalUpgradeable", "ERC1967Proxy": "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy", "BondTreasuryPaymaster": "src/paymasters/BondTreasuryPaymaster.sol:BondTreasuryPaymaster", + "CollectionFactory": "src/collections/CollectionFactory.sol:CollectionFactory", + "UserCollection721": "src/collections/UserCollection721.sol:UserCollection721", + "UserCollection1155": "src/collections/UserCollection1155.sol:UserCollection1155", } # Some zkSync forge broadcasts record deployments as calls to ContractDeployer @@ -105,6 +108,14 @@ "ERC1967Proxy", "BondTreasuryPaymaster", ], + # Order must match DeployCollectionFactoryZkSync.run(): the two impls via + # CREATE, the factory logic, then the factory's own ERC1967Proxy. + "DeployCollectionFactoryZkSync.s.sol": [ + "UserCollection721", + "UserCollection1155", + "CollectionFactory", + "ERC1967Proxy", + ], } @@ -409,6 +420,18 @@ def main(): ) print(f"Found {len(contracts)} contracts in broadcast\n") + # A broadcast that maps to zero verifiable contracts is almost always a + # misconfiguration (missing CONTRACT_SOURCE_MAP / BROADCAST_CONTRACT_SEQUENCE + # entry), not a real "nothing to do". Fail loudly instead of reporting a + # vacuous success. + if not contracts: + print( + " [ERROR] No verifiable contracts resolved from this broadcast.\n" + " Add the deploy script to BROADCAST_CONTRACT_SEQUENCE and the\n" + " contract types to CONTRACT_SOURCE_MAP in this script." + ) + sys.exit(2) + success = 0 failed = 0 for address, source, ctor, name in contracts: diff --git a/script/DeployCollectionFactoryZkSync.s.sol b/script/DeployCollectionFactoryZkSync.s.sol new file mode 100644 index 00000000..4f82a288 --- /dev/null +++ b/script/DeployCollectionFactoryZkSync.s.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Script, console} from "forge-std/Script.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {CollectionFactory} from "../src/collections/CollectionFactory.sol"; +import {UserCollection721} from "../src/collections/UserCollection721.sol"; +import {UserCollection1155} from "../src/collections/UserCollection1155.sol"; + +/** + * @title DeployCollectionFactoryZkSync + * @notice Deployment script for the user collections system on ZkSync Era. + * @dev See `src/collections/doc/spec/user-collections-specification.md` (§9.1). + * + * Deploys, in order: + * 1. UserCollection721 implementation (CREATE only — never CREATE2; + * see §7.2 row 15). + * 2. UserCollection1155 implementation (CREATE only). + * 3. CollectionFactory logic. + * 4. ERC1967Proxy pointing at the factory logic, initialized with + * (admin, operator, impl721, impl1155). + * + * Usage: + * forge script script/DeployCollectionFactoryZkSync.s.sol \ + * --rpc-url $L2_RPC --broadcast --zksync + * + * Environment Variables: + * - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas. + * - N_FACTORY_ADMIN: Multisig that will hold DEFAULT_ADMIN_ROLE on the factory. + * - N_FACTORY_OPERATOR: Backend service address that will hold OPERATOR_ROLE. + */ +contract DeployCollectionFactoryZkSync is Script { + address public collection721Impl; + address public collection1155Impl; + address public factoryImpl; + address public factoryProxy; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address admin = vm.envAddress("N_FACTORY_ADMIN"); + address operator = vm.envAddress("N_FACTORY_OPERATOR"); + + require(admin != address(0), "N_FACTORY_ADMIN is zero"); + require(operator != address(0), "N_FACTORY_OPERATOR is zero"); + + console.log("=== Deploying User Collections on ZkSync ==="); + console.log("Admin:", admin); + console.log("Operator:", operator); + console.log(""); + + vm.startBroadcast(deployerPrivateKey); + + // 1. UserCollection721 implementation. + console.log("1. Deploying UserCollection721 implementation..."); + collection721Impl = address(new UserCollection721()); + console.log(" UserCollection721 Implementation:", collection721Impl); + + // 2. UserCollection1155 implementation. + console.log("2. Deploying UserCollection1155 implementation..."); + collection1155Impl = address(new UserCollection1155()); + console.log(" UserCollection1155 Implementation:", collection1155Impl); + + // 3. CollectionFactory logic. + console.log("3. Deploying CollectionFactory logic..."); + factoryImpl = address(new CollectionFactory()); + console.log(" CollectionFactory Implementation:", factoryImpl); + + // 4. ERC1967 proxy + atomic initialize. + console.log("4. Deploying ERC1967Proxy(CollectionFactory)..."); + bytes memory initData = abi.encodeCall( + CollectionFactory.initialize, (admin, operator, collection721Impl, collection1155Impl) + ); + factoryProxy = address(new ERC1967Proxy(factoryImpl, initData)); + console.log(" CollectionFactory Proxy:", factoryProxy); + console.log(""); + + vm.stopBroadcast(); + + // Summary — the orchestration shell script greps for these labels. + console.log("=== Deployment Complete ==="); + console.log("CollectionFactory Proxy:", factoryProxy); + console.log("CollectionFactory Implementation:", factoryImpl); + console.log("UserCollection721 Implementation:", collection721Impl); + console.log("UserCollection1155 Implementation:", collection1155Impl); + console.log(""); + console.log("Save the proxy address for future upgrades and operator calls."); + } +} diff --git a/script/UpgradeCollectionFactory.s.sol b/script/UpgradeCollectionFactory.s.sol new file mode 100644 index 00000000..6772c21b --- /dev/null +++ b/script/UpgradeCollectionFactory.s.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Script, console} from "forge-std/Script.sol"; + +import {CollectionFactory} from "../src/collections/CollectionFactory.sol"; +import {UserCollection721} from "../src/collections/UserCollection721.sol"; +import {UserCollection1155} from "../src/collections/UserCollection1155.sol"; + +/** + * @title UpgradeCollectionFactory + * @notice Three-mode upgrade script for the user collections system. + * @dev See `src/collections/doc/spec/user-collections-specification.md` (§9.4). + * + * **Modes**: + * - `UPGRADE_FACTORY`: deploys a fresh `CollectionFactory` logic + * contract and calls `upgradeToAndCall` on the existing proxy. Pass + * `REINIT_DATA` to invoke a `reinitializer(N)` migration in the same tx. + * - `SET_IMPL_721`: deploys a fresh `UserCollection721` implementation + * and calls `setImplementation721` on the proxy. Affects future clones + * only — existing clones remain on the previous implementation. + * - `SET_IMPL_1155`: same as above for `UserCollection1155`. + * + * **Pre-Upgrade Checklist (factory upgrade)**: + * 1. Diff storage layout against the previous released ref: + * `forge inspect CollectionFactory storageLayout --json` on the new code + * vs. the same on the released ref (see §9.4 for the exact commands). + * Only appended fields (consuming `__gap`) are safe; verify slot index + * AND byte offset for sub-word fields (lock bools). No static baseline + * JSON is committed. + * 2. Run all collections tests: `forge test --match-path "test/collections/**"`. + * 3. Test on a fork: re-run this script with `--fork-url $RPC_URL` first. + * 4. After broadcast, verify the new EIP-1967 implementation slot via + * `cast implementation $FACTORY_PROXY` and confirm role/state preservation. + * + * **Pre-Upgrade Checklist (setImplementation*)**: + * 1. Snapshot the new clone implementation's layout against the + * previous `UserCollection<721|1155>` baseline. + * 2. Confirm the post-`setImplementation*` `cast call` matches the new + * implementation address. + * + * Usage (zkSync Era — the `--zksync` flag is REQUIRED; without it forge compiles + * and deploys EVM bytecode, which is the wrong VM and will not register the new + * implementation as a factoryDep): + * ACTION=UPGRADE_FACTORY FACTORY_PROXY=0x... \ + * forge script script/UpgradeCollectionFactory.s.sol --rpc-url $RPC_URL --zksync --broadcast --slow + * + * ACTION=SET_IMPL_721 FACTORY_PROXY=0x... \ + * forge script script/UpgradeCollectionFactory.s.sol --rpc-url $RPC_URL --zksync --broadcast --slow + * + * ACTION=SET_IMPL_1155 FACTORY_PROXY=0x... \ + * forge script script/UpgradeCollectionFactory.s.sol --rpc-url $RPC_URL --zksync --broadcast --slow + * + * RECOMMENDED: run via the orchestration wrapper + * `ops/upgrade_collection_factory_zksync.sh [--broadcast]`, + * which handles the `--zksync` compile (including the temp move/restore of + * L1-only files like `SwarmRegistryL1Upgradeable` that zksolc cannot compile), + * the pre-upgrade storage-layout diff against the committed baseline, the + * admin-key check, the mainnet guard, and the post-broadcast asserts + + * source verification. Invoking this Forge script directly requires doing + * that move/restore yourself first. + * + * Environment Variables: + * - DEPLOYER_PRIVATE_KEY: Private key of the address holding `DEFAULT_ADMIN_ROLE` on the factory proxy. + * - FACTORY_PROXY: Address of the deployed `CollectionFactory` ERC1967 proxy. + * - ACTION: One of `UPGRADE_FACTORY`, `SET_IMPL_721`, `SET_IMPL_1155`. + * - REINIT_DATA: (UPGRADE_FACTORY only, optional) ABI-encoded reinitializer call. + */ +contract UpgradeCollectionFactory is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address proxyAddress = vm.envAddress("FACTORY_PROXY"); + string memory action = vm.envString("ACTION"); + + require(proxyAddress != address(0), "FACTORY_PROXY is zero"); + + console.log("=== Collections Upgrade ==="); + console.log("Action:", action); + console.log("Factory Proxy:", proxyAddress); + console.log(""); + + vm.startBroadcast(deployerPrivateKey); + + bytes32 actionHash = keccak256(bytes(action)); + address newImpl; + if (actionHash == keccak256("UPGRADE_FACTORY")) { + bytes memory reinitData = vm.envOr("REINIT_DATA", bytes("")); + newImpl = _upgradeFactory(proxyAddress, reinitData); + } else if (actionHash == keccak256("SET_IMPL_721")) { + newImpl = _setImpl721(proxyAddress); + } else if (actionHash == keccak256("SET_IMPL_1155")) { + newImpl = _setImpl1155(proxyAddress); + } else { + revert("Invalid ACTION. Use UPGRADE_FACTORY, SET_IMPL_721, or SET_IMPL_1155."); + } + + vm.stopBroadcast(); + + console.log(""); + console.log("=== Upgrade Complete ==="); + console.log("New Implementation:", newImpl); + console.log("Proxy (unchanged):", proxyAddress); + } + + function _upgradeFactory(address proxy, bytes memory reinitData) internal returns (address impl) { + console.log("Deploying new CollectionFactory logic..."); + impl = address(new CollectionFactory()); + console.log("New factory implementation:", impl); + + CollectionFactory factory = CollectionFactory(proxy); + if (reinitData.length > 0) { + console.log("Calling upgradeToAndCall with reinitializer..."); + factory.upgradeToAndCall(impl, reinitData); + } else { + console.log("Calling upgradeToAndCall..."); + factory.upgradeToAndCall(impl, ""); + } + } + + function _setImpl721(address proxy) internal returns (address impl) { + console.log("Deploying new UserCollection721 implementation..."); + impl = address(new UserCollection721()); + console.log("New 721 implementation:", impl); + + CollectionFactory factory = CollectionFactory(proxy); + factory.setImplementation721(impl); + console.log("setImplementation721 broadcast."); + } + + function _setImpl1155(address proxy) internal returns (address impl) { + console.log("Deploying new UserCollection1155 implementation..."); + impl = address(new UserCollection1155()); + console.log("New 1155 implementation:", impl); + + CollectionFactory factory = CollectionFactory(proxy); + factory.setImplementation1155(impl); + console.log("setImplementation1155 broadcast."); + } +} diff --git a/src/collections/CollectionFactory.sol b/src/collections/CollectionFactory.sol new file mode 100644 index 00000000..b9073948 --- /dev/null +++ b/src/collections/CollectionFactory.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ICollectionFactory} from "./interfaces/ICollectionFactory.sol"; +import {IUserCollection721} from "./interfaces/IUserCollection721.sol"; +import {IUserCollection1155} from "./interfaces/IUserCollection1155.sol"; +import {Standard, CreateParams721, CreateParams1155} from "./interfaces/CollectionTypes.sol"; + +/** + * @title CollectionFactory + * @notice UUPS-upgradeable, operator-triggered factory that deploys per-collection + * `ERC1967Proxy` instances of `UserCollection721` / `UserCollection1155`. + * @dev See `src/collections/doc/spec/user-collections-specification.md`. + * + * The factory atomically deploys a per-collection `ERC1967Proxy` + * pointing at the standard's implementation, with an `abi.encodeCall` + * to `initialize(p, msg.sender)` baked into the constructor so init + * runs in the proxy's storage in the same frame. `msg.sender` is + * auto-granted `MINTER_ROLE` (see §2.3). Records the + * `externalId → collection` mapping and emits `CollectionCreated`. + * Reverts on reused or zero `externalId`. + * + * Already-deployed collections are immutable (impls do not inherit + * `UUPSUpgradeable`; the EIP-1967 implementation slot is constructor- + * fixed). Admin can swap implementation pointers via `setImplementation*`, + * which only affects future collections. + */ +contract CollectionFactory is + Initializable, + AccessControlUpgradeable, + UUPSUpgradeable, + ICollectionFactory +{ + // ────────────────────────────────────────────── + // Roles + // ────────────────────────────────────────────── + + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + + // ────────────────────────────────────────────── + // Storage (V1) — order matters; see §6.1 of the spec. + // ────────────────────────────────────────────── + + address private _erc721Implementation; + address private _erc1155Implementation; + mapping(bytes32 => address) private _collectionByExternalId; + + /// @dev Reserved storage slots for future appended fields. + uint256[47] private __gap; + + // ────────────────────────────────────────────── + // Constructor + // ────────────────────────────────────────────── + + constructor() { + _disableInitializers(); + } + + // ────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────── + + /// @notice One-time proxy initializer (not part of the `ICollectionFactory` + /// consumer API — it is the `Initializable` deployment hook, invoked + /// once via the proxy constructor at deploy time). + /// @param admin Receives `DEFAULT_ADMIN_ROLE` (factory upgrades, role admin). + /// @param operator Receives `OPERATOR_ROLE` (may call `createCollection*`). + /// @param impl721 `UserCollection721` implementation; must be a contract. + /// @param impl1155 `UserCollection1155` implementation; must be a contract. + function initialize( + address admin, + address operator, + address impl721, + address impl1155 + ) external initializer { + if (admin == address(0) || operator == address(0) || impl721 == address(0) || impl1155 == address(0)) { + revert ZeroAddress(); + } + if (impl721.code.length == 0) revert NotAContract(impl721); + if (impl1155.code.length == 0) revert NotAContract(impl1155); + + // `__AccessControl_init` body is empty in OZ v5.6.1; the role grants + // below initialize all the state we need. + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(OPERATOR_ROLE, operator); + + _erc721Implementation = impl721; + _erc1155Implementation = impl1155; + } + + // ────────────────────────────────────────────── + // Creation + // ────────────────────────────────────────────── + + /// @inheritdoc ICollectionFactory + function createCollection721(CreateParams721 calldata p, bytes32 externalId) + external + onlyRole(OPERATOR_ROLE) + returns (address collection) + { + _checkExternalId(externalId); + + bytes memory initData = abi.encodeCall( + IUserCollection721.initialize, + (p, msg.sender) + ); + // SECURITY INVARIANT: the `_collectionByExternalId` write below lands + // AFTER the proxy deploy+init. This is reentrancy-safe ONLY because the + // implementation's `initialize` makes no calls to attacker-controlled + // addresses (it only grants roles and sets ERC-2981 royalty, none of + // which call out). Any future implementation MUST preserve that — if + // `initialize` ever performs an external call, reorder so the registry + // write precedes the deploy, or add a reentrancy guard here. + collection = address( + new ERC1967Proxy{salt: externalId}(_erc721Implementation, initData) + ); + + _collectionByExternalId[externalId] = collection; + emit CollectionCreated(p.owner, collection, Standard.ERC721, externalId); + } + + /// @inheritdoc ICollectionFactory + function createCollection1155(CreateParams1155 calldata p, bytes32 externalId) + external + onlyRole(OPERATOR_ROLE) + returns (address collection) + { + _checkExternalId(externalId); + + bytes memory initData = abi.encodeCall( + IUserCollection1155.initialize, + (p, msg.sender) + ); + // SECURITY INVARIANT: see `createCollection721` — the registry write + // trails the deploy+init and is safe only while `initialize` makes no + // external calls. Preserve that property in any future implementation. + collection = address( + new ERC1967Proxy{salt: externalId}(_erc1155Implementation, initData) + ); + + _collectionByExternalId[externalId] = collection; + emit CollectionCreated(p.owner, collection, Standard.ERC1155, externalId); + } + + // ────────────────────────────────────────────── + // Admin + // ────────────────────────────────────────────── + + /// @inheritdoc ICollectionFactory + function setImplementation721(address impl) external onlyRole(DEFAULT_ADMIN_ROLE) { + _validateImplementation(impl); + _erc721Implementation = impl; + emit ImplementationUpdated(Standard.ERC721, impl); + } + + /// @inheritdoc ICollectionFactory + function setImplementation1155(address impl) external onlyRole(DEFAULT_ADMIN_ROLE) { + _validateImplementation(impl); + _erc1155Implementation = impl; + emit ImplementationUpdated(Standard.ERC1155, impl); + } + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + /// @inheritdoc ICollectionFactory + function collectionByExternalId(bytes32 externalId) external view returns (address) { + return _collectionByExternalId[externalId]; + } + + /// @inheritdoc ICollectionFactory + function erc721Implementation() external view returns (address) { + return _erc721Implementation; + } + + /// @inheritdoc ICollectionFactory + function erc1155Implementation() external view returns (address) { + return _erc1155Implementation; + } + + // ────────────────────────────────────────────── + // Internals + // ────────────────────────────────────────────── + + function _checkExternalId(bytes32 externalId) private view { + if (externalId == bytes32(0)) revert InvalidExternalId(); + if (_collectionByExternalId[externalId] != address(0)) { + revert ExternalIdAlreadyUsed(externalId); + } + } + + function _validateImplementation(address impl) private view { + if (impl == address(0)) revert ZeroAddress(); + if (impl.code.length == 0) revert NotAContract(impl); + } + + // ────────────────────────────────────────────── + // UUPS authorization + // ────────────────────────────────────────────── + + function _authorizeUpgrade(address) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/src/collections/UserCollection1155.sol b/src/collections/UserCollection1155.sol new file mode 100644 index 00000000..1ec1fbd9 --- /dev/null +++ b/src/collections/UserCollection1155.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {ERC1155Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol"; +import {ERC1155SupplyUpgradeable} from + "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155SupplyUpgradeable.sol"; +import {ERC1155BurnableUpgradeable} from + "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155BurnableUpgradeable.sol"; +import {ERC2981Upgradeable} from "@openzeppelin/contracts-upgradeable/token/common/ERC2981Upgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +import {IUserCollection1155} from "./interfaces/IUserCollection1155.sol"; +import {CreateParams1155} from "./interfaces/CollectionTypes.sol"; + +/** + * @title UserCollection1155 + * @notice ERC-1155 implementation deployed behind a per-collection `ERC1967Proxy` by `CollectionFactory`. + * @dev See `src/collections/doc/spec/user-collections-specification.md` (§3.6). + * + * Bytecode-permanence invariants apply identically to UserCollection721 + * (see §7.2 row 15 and the §8.3 unit test): no `selfdestruct`, no + * caller-controlled `delegatecall`, deployment via `CREATE` only. + * + * Metadata convention (see spec §7.2 row 7): `uri` is mutable until + * `lockMetadata`; a shared `setURI` re-points the resolved URI for all IDs. + * Buyers get a freeze guarantee only from `metadataLocked`. + * + * Role finality (see spec §2.4): collections are deliberately created with + * NO `DEFAULT_ADMIN_ROLE` holder. `OWNER_ROLE` is its own non-transferable + * anchor (it admins `MINTER_ROLE`, but nothing admins `OWNER_ROLE`). Owner + * key loss permanently freezes owner-only functions; token transfers and + * existing minters are unaffected. + */ +contract UserCollection1155 is + Initializable, + ERC1155Upgradeable, + ERC1155SupplyUpgradeable, + ERC1155BurnableUpgradeable, + ERC2981Upgradeable, + AccessControlUpgradeable, + IUserCollection1155 +{ + // ────────────────────────────────────────────── + // Roles + // ────────────────────────────────────────────── + + bytes32 public constant OWNER_ROLE = keccak256("OWNER_ROLE"); + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + // ────────────────────────────────────────────── + // Constants + // ────────────────────────────────────────────── + + /// @notice Maximum number of (id, amount) pairs per `mintBatch` call. + uint256 public constant MAX_BATCH = 100; + + // ────────────────────────────────────────────── + // Storage (V1) — order matters; see §6.2 of the spec. + // The two booleans are declared adjacent so Solidity packs them into a + // single slot (bytes 0 and 1, 30 bytes free for future appended sub-word + // fields). 1155 omits `nextTokenId` (caller-chosen IDs). + // ────────────────────────────────────────────── + + string private _contractURI; + bool private _metadataLocked; + bool private _royaltiesLocked; + + /// @dev Reserved storage slots for future appended fields. + uint256[47] private __gap; + + // ────────────────────────────────────────────── + // Constructor + // ────────────────────────────────────────────── + + constructor() { + _disableInitializers(); + } + + // ────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────── + + /// @inheritdoc IUserCollection1155 + function initialize(CreateParams1155 calldata p, address operatorMinter) external initializer { + if (p.owner == address(0) || operatorMinter == address(0)) revert ZeroAddress(); + + // Only the inits with non-empty bodies in OZ v5.6.1 are called. The + // remaining `___init` functions for ERC1155Supply, Burnable, + // ERC2981, and AccessControl are empty in this version (kept by OZ as + // forward-compat shims). Re-add them if upgrading OZ. + __ERC1155_init(p.uri); + + _contractURI = p.contractURI; + + if (p.royaltyBps > 0) { + _setDefaultRoyalty(p.royaltyRecipient, p.royaltyBps); + } + + _setRoleAdmin(MINTER_ROLE, OWNER_ROLE); + + _grantRole(OWNER_ROLE, p.owner); + _grantRole(MINTER_ROLE, p.owner); + _grantRole(MINTER_ROLE, operatorMinter); + + uint256 len = p.additionalMinters.length; + for (uint256 i = 0; i < len; ++i) { + _grantRole(MINTER_ROLE, p.additionalMinters[i]); + } + } + + // ────────────────────────────────────────────── + // Minting + // ────────────────────────────────────────────── + + /// @inheritdoc IUserCollection1155 + function mint(address to, uint256 id, uint256 amount, bytes calldata data) + external + onlyRole(MINTER_ROLE) + { + _mint(to, id, amount, data); + } + + /// @inheritdoc IUserCollection1155 + function mintBatch( + address to, + uint256[] calldata ids, + uint256[] calldata amounts, + bytes calldata data + ) external onlyRole(MINTER_ROLE) { + uint256 len = ids.length; + if (len != amounts.length) revert LengthMismatch(); + if (len > MAX_BATCH) revert BatchTooLarge(len, MAX_BATCH); + _mintBatch(to, ids, amounts, data); + } + + // ────────────────────────────────────────────── + // Owner-mutable settings + // ────────────────────────────────────────────── + + /// @inheritdoc IUserCollection1155 + function setURI(string calldata newURI) external onlyRole(OWNER_ROLE) { + if (_metadataLocked) revert MetadataIsLocked(); + _setURI(newURI); + emit URIUpdated(newURI); + } + + /// @inheritdoc IUserCollection1155 + function setContractURI(string calldata newURI) external onlyRole(OWNER_ROLE) { + if (_metadataLocked) revert MetadataIsLocked(); + _contractURI = newURI; + emit ContractURIUpdated(newURI); + } + + /// @inheritdoc IUserCollection1155 + function setDefaultRoyalty(address recipient, uint96 bps) external onlyRole(OWNER_ROLE) { + if (_royaltiesLocked) revert RoyaltiesAreLocked(); + if (bps == 0) { + _deleteDefaultRoyalty(); + } else { + _setDefaultRoyalty(recipient, bps); + } + emit DefaultRoyaltyUpdated(recipient, bps); + } + + /// @inheritdoc IUserCollection1155 + function lockMetadata() external onlyRole(OWNER_ROLE) { + _metadataLocked = true; + emit MetadataLocked(); + } + + /// @inheritdoc IUserCollection1155 + function lockRoyalties() external onlyRole(OWNER_ROLE) { + _royaltiesLocked = true; + emit RoyaltiesLocked(); + } + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + /// @inheritdoc IUserCollection1155 + function contractURI() external view returns (string memory) { + return _contractURI; + } + + /// @inheritdoc IUserCollection1155 + function metadataLocked() external view returns (bool) { + return _metadataLocked; + } + + /// @inheritdoc IUserCollection1155 + function royaltiesLocked() external view returns (bool) { + return _royaltiesLocked; + } + + // ────────────────────────────────────────────── + // Required overrides + // ────────────────────────────────────────────── + + function _update(address from, address to, uint256[] memory ids, uint256[] memory values) + internal + override(ERC1155Upgradeable, ERC1155SupplyUpgradeable) + { + super._update(from, to, ids, values); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC1155Upgradeable, ERC2981Upgradeable, AccessControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} diff --git a/src/collections/UserCollection721.sol b/src/collections/UserCollection721.sol new file mode 100644 index 00000000..2bacdaa3 --- /dev/null +++ b/src/collections/UserCollection721.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import {ERC721URIStorageUpgradeable} from + "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; +import {ERC721BurnableUpgradeable} from + "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.sol"; +import {ERC2981Upgradeable} from "@openzeppelin/contracts-upgradeable/token/common/ERC2981Upgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +import {IUserCollection721} from "./interfaces/IUserCollection721.sol"; +import {CreateParams721} from "./interfaces/CollectionTypes.sol"; + +/** + * @title UserCollection721 + * @notice ERC-721 implementation deployed behind a per-collection `ERC1967Proxy` by `CollectionFactory`. + * @dev See `src/collections/doc/spec/user-collections-specification.md` (§3.5). + * + * Bytecode-permanence invariants (load-bearing for the §1.3 immutability + * guarantee — see §7.2 row 15 and the §8.2 unit test): + * - This contract contains no `selfdestruct`. + * - This contract performs no `delegatecall` to caller-provided addresses. + * - Implementation must be deployed via `CREATE`, not `CREATE2`. + * + * Token-URI resolution convention (see spec §7.2 row 7): this contract uses + * OZ `ERC721URIStorage` unmodified, so `tokenURI(id)` resolves to + * `baseURI() + perTokenSuffix` whenever `baseURI` is non-empty. Callers MUST + * therefore pass a *relative suffix* (not a full URI) to `mint`/`mintBatch`. + * The per-token suffix is fixed at mint, but the shared `baseURI` stays + * mutable until `lockMetadata`: changing it re-points the resolved URI of + * every already-minted token. Buyers get a freeze guarantee only from + * `metadataLocked`, never from the per-token suffix alone. + * + * Role finality (see spec §2.4): collections are deliberately created with + * NO `DEFAULT_ADMIN_ROLE` holder. `OWNER_ROLE` is its own non-transferable + * anchor — `OWNER_ROLE` admins `MINTER_ROLE`, but nothing admins + * `OWNER_ROLE`, so it can never be granted to a new address. Owner key loss + * permanently freezes owner-only functions (metadata/royalty/minter + * management); token transfers and existing minters are unaffected. + */ +contract UserCollection721 is + Initializable, + ERC721Upgradeable, + ERC721URIStorageUpgradeable, + ERC721BurnableUpgradeable, + ERC2981Upgradeable, + AccessControlUpgradeable, + IUserCollection721 +{ + // ────────────────────────────────────────────── + // Roles + // ────────────────────────────────────────────── + + bytes32 public constant OWNER_ROLE = keccak256("OWNER_ROLE"); + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + // ────────────────────────────────────────────── + // Constants + // ────────────────────────────────────────────── + + /// @notice Maximum number of items per `mintBatch` call. + uint256 public constant MAX_BATCH = 100; + + // ────────────────────────────────────────────── + // Storage (V1) — order matters; see §6.2 of the spec. + // The two booleans are declared adjacent so Solidity packs them into a + // single slot (bytes 0 and 1, 30 bytes free for future appended sub-word + // fields). Saves one __gap slot. + // ────────────────────────────────────────────── + + string private _baseTokenURI; + string private _contractURI; + uint256 private _nextTokenId; + bool private _metadataLocked; + bool private _royaltiesLocked; + + /// @dev Reserved storage slots for future appended fields. + uint256[46] private __gap; + + // ────────────────────────────────────────────── + // Constructor + // ────────────────────────────────────────────── + + /// @dev Disables initializers on the implementation so it cannot be + /// initialized directly. Each per-collection proxy calls `initialize` + /// exactly once via the factory's atomic constructor-frame deploy+init + /// flow. + constructor() { + _disableInitializers(); + } + + // ────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────── + + /// @inheritdoc IUserCollection721 + function initialize(CreateParams721 calldata p, address operatorMinter) external initializer { + if (p.owner == address(0) || operatorMinter == address(0)) revert ZeroAddress(); + + // Only the inits with non-empty bodies in OZ v5.6.1 are called. The + // remaining `___init` functions for ERC721URIStorage, Burnable, + // ERC2981, and AccessControl are empty in this version (kept by OZ as + // forward-compat shims). Re-add them if upgrading OZ. + __ERC721_init(p.name, p.symbol); + + _baseTokenURI = p.baseURI; + _contractURI = p.contractURI; + + if (p.royaltyBps > 0) { + _setDefaultRoyalty(p.royaltyRecipient, p.royaltyBps); + } + + _setRoleAdmin(MINTER_ROLE, OWNER_ROLE); + + _grantRole(OWNER_ROLE, p.owner); + _grantRole(MINTER_ROLE, p.owner); + _grantRole(MINTER_ROLE, operatorMinter); + + uint256 len = p.additionalMinters.length; + for (uint256 i = 0; i < len; ++i) { + _grantRole(MINTER_ROLE, p.additionalMinters[i]); + } + } + + // ────────────────────────────────────────────── + // Minting + // ────────────────────────────────────────────── + + /// @inheritdoc IUserCollection721 + function mint(address to, string calldata tokenURI_) + external + onlyRole(MINTER_ROLE) + returns (uint256 tokenId) + { + tokenId = _nextTokenId; + ++_nextTokenId; + _safeMint(to, tokenId); + _setTokenURI(tokenId, tokenURI_); + } + + /// @inheritdoc IUserCollection721 + function mintBatch(address[] calldata to, string[] calldata uris) + external + onlyRole(MINTER_ROLE) + returns (uint256[] memory tokenIds) + { + uint256 len = to.length; + if (len != uris.length) revert LengthMismatch(); + if (len > MAX_BATCH) revert BatchTooLarge(len, MAX_BATCH); + + tokenIds = new uint256[](len); + uint256 startId = _nextTokenId; + // Reserve the whole [startId, startId+len) range BEFORE the mint loop. + // `_safeMint` calls `onERC721Received`; reserving up front means a + // reentrant `mint`/`mintBatch` reads an already-advanced counter and + // can only take IDs at or beyond `startId+len`, so it can never collide + // with an ID this batch is about to assign. Without this, the counter + // would be stale during every callback and correctness would rest on + // OZ's duplicate-mint revert rather than on our own invariant. + _nextTokenId = startId + len; + for (uint256 i = 0; i < len; ++i) { + uint256 id = startId + i; + tokenIds[i] = id; + _safeMint(to[i], id); + _setTokenURI(id, uris[i]); + } + } + + // ────────────────────────────────────────────── + // Owner-mutable settings + // ────────────────────────────────────────────── + + /// @inheritdoc IUserCollection721 + function setBaseURI(string calldata newBase) external onlyRole(OWNER_ROLE) { + if (_metadataLocked) revert MetadataIsLocked(); + _baseTokenURI = newBase; + emit BaseURIUpdated(newBase); + } + + /// @inheritdoc IUserCollection721 + function setContractURI(string calldata newURI) external onlyRole(OWNER_ROLE) { + if (_metadataLocked) revert MetadataIsLocked(); + _contractURI = newURI; + emit ContractURIUpdated(newURI); + } + + /// @inheritdoc IUserCollection721 + function setDefaultRoyalty(address recipient, uint96 bps) external onlyRole(OWNER_ROLE) { + if (_royaltiesLocked) revert RoyaltiesAreLocked(); + if (bps == 0) { + _deleteDefaultRoyalty(); + } else { + _setDefaultRoyalty(recipient, bps); + } + emit DefaultRoyaltyUpdated(recipient, bps); + } + + /// @inheritdoc IUserCollection721 + function lockMetadata() external onlyRole(OWNER_ROLE) { + _metadataLocked = true; + emit MetadataLocked(); + } + + /// @inheritdoc IUserCollection721 + function lockRoyalties() external onlyRole(OWNER_ROLE) { + _royaltiesLocked = true; + emit RoyaltiesLocked(); + } + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + /// @inheritdoc IUserCollection721 + function contractURI() external view returns (string memory) { + return _contractURI; + } + + /// @inheritdoc IUserCollection721 + function nextTokenId() external view returns (uint256) { + return _nextTokenId; + } + + /// @inheritdoc IUserCollection721 + function metadataLocked() external view returns (bool) { + return _metadataLocked; + } + + /// @inheritdoc IUserCollection721 + function royaltiesLocked() external view returns (bool) { + return _royaltiesLocked; + } + + // ────────────────────────────────────────────── + // Required overrides + // ────────────────────────────────────────────── + + function _baseURI() internal view override returns (string memory) { + return _baseTokenURI; + } + + /// @dev `tokenURI` resolution lives in `ERC721URIStorageUpgradeable`; the + /// override here exists only to disambiguate the inheritance chain. + function tokenURI(uint256 tokenId) + public + view + override(ERC721Upgradeable, ERC721URIStorageUpgradeable) + returns (string memory) + { + return super.tokenURI(tokenId); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721Upgradeable, ERC721URIStorageUpgradeable, ERC2981Upgradeable, AccessControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} diff --git a/src/collections/doc/README.md b/src/collections/doc/README.md new file mode 100644 index 00000000..1146142e --- /dev/null +++ b/src/collections/doc/README.md @@ -0,0 +1,11 @@ +# User Collections — Documentation + +Operator-triggered NFT collection factory: users pay in fiat off-chain, a trusted backend deploys a fully-isolated per-collection `ERC1967Proxy` (ERC-721 or ERC-1155) on the user's behalf. + +## Contents + +| Document | Description | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| [spec/user-collections-specification.md](spec/user-collections-specification.md) | Full technical specification (architecture, roles, interfaces, flows, storage, security, testing, ops) | +| [spec/design-and-implementation.md](spec/design-and-implementation.md) | Design rationale & as-built implementation (ERC1967Proxy architecture, permanence proof, address pre-derivation, deploy/verify, upgrades) | +| [backend-integration.md](backend-integration.md) | Backend (operator) integration guide: responsibilities, params, and error handling for creating & minting collections | diff --git a/src/collections/doc/backend-integration.md b/src/collections/doc/backend-integration.md new file mode 100644 index 00000000..b1cd930b --- /dev/null +++ b/src/collections/doc/backend-integration.md @@ -0,0 +1,223 @@ +# User Collections — Backend Integration Guide + +This is the operational guide for the **backend service** — the holder of +`OPERATOR_ROLE` on `CollectionFactory`. It describes everything the backend is +responsible for when **creating** collections and **minting** items, plus the +gotchas, error handling, and reconciliation rules. + +- Authoritative contract behavior: [`spec/user-collections-specification.md`](spec/user-collections-specification.md) +- Architecture & rationale: [`spec/design-and-implementation.md`](spec/design-and-implementation.md) + +--- + +## 0. Mental model — what the backend is + +The backend is the **operator**: a trusted service key that holds `OPERATOR_ROLE` +on the factory. Fiat payments are collected off-chain; once a payment clears, the +backend triggers the matching on-chain action. + +| The operator **can** | The operator **cannot** | +|---|---| +| `createCollection721` / `createCollection1155` (it holds `OPERATOR_ROLE`) | Change a collection's metadata / royalties / URI (that's `OWNER_ROLE` = the creator) | +| `mint` / `mintBatch` into any collection where it holds `MINTER_ROLE` (auto-granted at creation) | Upgrade or alter an already-deployed collection (immutable by design) | +| Be revoked by a creator at any time (`OWNER_ROLE` can `revokeRole(MINTER_ROLE, operator)`) | Grant itself `OWNER_ROLE` (collections have no `DEFAULT_ADMIN_ROLE` holder) | + +Roles the backend should know: +- **Factory:** `OPERATOR_ROLE` (the backend), `DEFAULT_ADMIN_ROLE` (the admin Safe — not the backend). +- **Per collection:** `OWNER_ROLE` (the creator), `MINTER_ROLE` (creator + operator + any `additionalMinters`). + +--- + +## 1. Prerequisites + +| Item | Notes | +|---|---| +| Factory proxy address | The `CollectionFactory` ERC1967 proxy (per environment). Stored as `COLLECTION_FACTORY_PROXY`. | +| Operator key | Must hold `OPERATOR_ROLE` on the factory. Store in HSM/KMS. | +| L2 RPC | zkSync Era endpoint. | +| Gas | The operator pays L2 gas; seed it via `BondTreasuryPaymaster` (or fund the EOA). | +| Impl pointers (optional) | `erc721Implementation()` / `erc1155Implementation()` — needed only for address pre-derivation. | + +Sanity check before going live: +```bash +cast call $COLLECTION_FACTORY_PROXY "hasRole(bytes32,address)(bool)" \ + $(cast keccak "OPERATOR_ROLE") $OPERATOR_ADDR --rpc-url $L2_RPC # must be true +``` + +--- + +## 2. Responsibility A — Create a collection + +**Trigger:** a creator's fiat payment for collection creation has cleared. + +### 2.1 Build `externalId` +A `bytes32` that is your off-chain reconciliation key **and** the CREATE2 salt. +- Must be **non-zero** → else `InvalidExternalId`. +- Must be **unused** (shared namespace across 721 *and* 1155) → else `ExternalIdAlreadyUsed`. +- Convention: `externalId = keccak256(orderId)`. + +### 2.2 Choose the standard and assemble params + +**`createCollection721(CreateParams721 p, bytes32 externalId)`** + +| # | Field | Type | Backend responsibility | +|---|---|---|---| +| 1 | `owner` | `address` | The creator's wallet. **Non-zero** (else `ZeroAddress`). Receives `OWNER_ROLE` + `MINTER_ROLE`. | +| 2 | `name` | `string` | Collection name. | +| 3 | `symbol` | `string` | Collection symbol. | +| 4 | `baseURI` | `string` | See §4 (URI convention). Pass `""` if minting full per-token URIs. | +| 5 | `contractURI` | `string` | Collection-level metadata JSON (pin to IPFS). | +| 6 | `royaltyRecipient` | `address` | ERC-2981 recipient (usually the creator). | +| 7 | `royaltyBps` | `uint96` | Basis points, e.g. `500` = 5%. **Fail-closed:** `> 0` with zero recipient, or `> 10000`, reverts the whole create. Use `0` for none. | +| 8 | `additionalMinters` | `address[]` | Extra `MINTER_ROLE` grants (e.g. co-creator). May be `[]`. You do **not** need to add the operator here — it's auto-granted. | + +**`createCollection1155(CreateParams1155 p, bytes32 externalId)`** + +| # | Field | Type | Backend responsibility | +|---|---|---|---| +| 1 | `owner` | `address` | Creator wallet, non-zero. | +| 2 | `uri` | `string` | ERC-1155 URI, typically with an `{id}` placeholder. | +| 3 | `contractURI` | `string` | Collection-level metadata. | +| 4 | `royaltyRecipient` | `address` | | +| 5 | `royaltyBps` | `uint96` | Same fail-closed rules as 721. | +| 6 | `additionalMinters` | `address[]` | May be `[]`. (1155 has no name/symbol/baseURI.) | + +### 2.3 (Optional) Pre-derive the address +You can compute the collection address **before** broadcasting and show it to the +user. It's a pure CREATE2 function of `(factory, ERC1967Proxy zk bytecode hash, +externalId, abi.encode(impl, initData))`. Use the **current** `erc721Implementation()` +and the **current** operator address. See `design-and-implementation.md` §4 for the +`zksync-ethers utils.create2Address` recipe. + +### 2.4 Broadcast +`cast`: +```bash +cast send $COLLECTION_FACTORY_PROXY \ + "createCollection721((address,string,string,string,string,address,uint96,address[]),bytes32)" \ + "($OWNER,My Collection,MYC,ipfs://base/,ipfs://contract.json,$ROYALTY_RCV,500,[])" \ + $(cast keccak "order-123") \ + --rpc-url $L2_RPC --private-key $OPERATOR_KEY --zksync +``` +ethers / zksync-ethers: +```ts +const factory = new Contract(FACTORY, [ + "function createCollection721((address,string,string,string,string,address,uint96,address[]) p, bytes32 externalId) returns (address)", +], operatorWallet); + +const p = [owner, "My Collection", "MYC", "ipfs://base/", "ipfs://contract.json", royaltyRcv, 500, []]; +const externalId = ethers.id(`order-${orderId}`); // keccak256 +const tx = await factory.createCollection721(p, externalId); +await tx.wait(); +``` + +### 2.5 Reconcile +- The new address is returned, recorded in `collectionByExternalId[externalId]`, and emitted in `CollectionCreated(creator, collection, standard, externalId)`. +- **Confirm:** `factory.collectionByExternalId(externalId)` → the address. +- **Idempotency:** if a retry hits the same `externalId`, the tx reverts `ExternalIdAlreadyUsed` — treat that as "already created" and look up the existing address. Never let a duplicate trigger create a second collection. +- **State-loss recovery:** re-derive `externalId = keccak256(orderId)` and look it up on-chain. + +--- + +## 3. Responsibility B — Mint items + +**Trigger:** either a creator-driven mint, or a buyer's fiat payment for an item sale has cleared. + +The operator can mint only while it holds `MINTER_ROLE` on that collection +(auto-granted at creation; the creator may have revoked it — handle the revert). + +### 3.1 ERC-721 +```solidity +function mint(address to, string calldata tokenURI_) external returns (uint256 tokenId); +function mintBatch(address[] calldata to, string[] calldata uris) external returns (uint256[] memory tokenIds); +``` +- IDs are **auto-assigned, sequential** (start at `nextTokenId()`); `mint` returns the new id, `mintBatch` returns the contiguous range in `to` order. Use the return value for per-buyer attribution — don't parse `Transfer` logs or race concurrent minters. +- `tokenURI_` / each `uris[i]` is a **relative suffix** when `baseURI` is non-empty (see §4). +- `mintBatch`: `to.length == uris.length` (else `LengthMismatch`), and `≤ MAX_BATCH (100)` (else `BatchTooLarge`). + +```bash +# single +cast send $COLLECTION "mint(address,string)" $BUYER "42.json" \ + --rpc-url $L2_RPC --private-key $OPERATOR_KEY --zksync +``` + +### 3.2 ERC-1155 +```solidity +function mint(address to, uint256 id, uint256 amount, bytes calldata data) external; +function mintBatch(address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data) external; +``` +- IDs are **caller-chosen** (you pick `id`). `amount` is the quantity. `data` is usually `0x`. +- `mintBatch` is **single-recipient** (one `to`, many `(id, amount)` pairs). `ids.length == amounts.length` (else `LengthMismatch`), `≤ MAX_BATCH` (else `BatchTooLarge`). + +```bash +cast send $COLLECTION "mint(address,uint256,uint256,bytes)" $BUYER 7 3 0x \ + --rpc-url $L2_RPC --private-key $OPERATOR_KEY --zksync +``` + +### 3.3 If the operator's `MINTER_ROLE` was revoked +Mints revert `AccessControlUnauthorizedAccount(operator, MINTER_ROLE)`. Surface this +to ops — operator-driven sales for that collection are paused until the creator +re-grants the role. (After an operator-key rotation, the new key is auto-granted on +*future* collections only; pre-rotation collections need the creator to grant it.) + +--- + +## 4. Responsibility C — Metadata & URIs + +The backend pins metadata (IPFS) and chooses the URI scheme: + +- **ERC-721:** the resolved `tokenURI(id) = baseURI + perTokenSuffix`. So either + - set `baseURI = "ipfs:///"` at create and pass **relative suffixes** (`"42.json"`) to `mint`, **or** + - set `baseURI = ""` and pass **full URIs** (`"ipfs:///42.json"`) to `mint`. + Do **not** mix (a full URI with a non-empty base yields a broken double-prefixed URL). +- **ERC-1155:** set `uri` (usually with `{id}`); clients substitute the id. There is no per-token URI. +- **Important:** with a non-empty `baseURI`, the *base* is mutable until the creator calls `lockMetadata` — so already-minted tokens can be re-pointed. Only `metadataLocked` is a true freeze. Communicate this to creators/buyers; it is a creator decision, not the backend's. + +--- + +## 5. What the backend does NOT do (creator/`OWNER_ROLE` operations) + +These require `OWNER_ROLE`, which the **creator** holds — the operator cannot call them +(unless the backend custodies the creator's wallet, which is a separate product decision): + +`setBaseURI` / `setURI`, `setContractURI`, `setDefaultRoyalty`, `lockMetadata`, +`lockRoyalties`, and granting/revoking `MINTER_ROLE`. If the backend surfaces these +in a UI, it must sign them with the creator's key, not the operator key. + +--- + +## 6. Operator key management + +- Store the operator key in HSM/KMS; monitor creation/mint rate off-chain (there is no on-chain rate limit). +- **Rotation:** the admin Safe does `revokeRole(OPERATOR_ROLE, oldKey)` then `grantRole(OPERATOR_ROLE, newKey)`. Auto-grant of `MINTER_ROLE` then applies to *future* collections only; for continued operator-driven minting on pre-rotation collections, each creator must grant `MINTER_ROLE` to the new key. Track this in the rotation runbook. +- **Pause:** the admin can revoke all `OPERATOR_ROLE` holders; new creations revert, existing collections are unaffected. + +--- + +## 7. Error reference + +| Revert | Where | Meaning / action | +|---|---|---| +| `InvalidExternalId()` | create | `externalId == 0`. Use a non-zero key. | +| `ExternalIdAlreadyUsed(bytes32)` | create | Already created — look up the existing address (idempotent retry). | +| `ZeroAddress()` | create / init | `owner` (or another required address) is zero. | +| `AccessControlUnauthorizedAccount(addr, role)` | create | Caller lacks `OPERATOR_ROLE`. | +| `AccessControlUnauthorizedAccount(addr, MINTER_ROLE)` | mint | Operator's `MINTER_ROLE` was revoked. Pause sales for that collection. | +| `LengthMismatch()` | mintBatch | Array lengths differ. | +| `BatchTooLarge(len, max)` | mintBatch | `len > 100`. Split the batch. | +| ERC-2981 revert | create | `royaltyBps > 0` with zero recipient, or `> 10000`. Validate before sending. | + +--- + +## 8. End-to-end sequences + +**Create:** fiat clears → assign `orderId` → `externalId = keccak256(orderId)` → +(optional) pre-derive address → `createCollection721/1155(params, externalId)` → +confirm via `collectionByExternalId` / `CollectionCreated` → mark order complete, +return the address. + +**Operator-driven sale:** buyer pays fiat → backend `mint(buyer, ...)` on the +creator's collection (operator holds `MINTER_ROLE`) → `Transfer`/`TransferSingle` +emitted → mark order complete. + +**Creator-driven mint:** the creator signs `mint(...)` from their own wallet +(they hold `MINTER_ROLE`); the backend's role here is metadata pinning only. diff --git a/src/collections/doc/spec/design-and-implementation.md b/src/collections/doc/spec/design-and-implementation.md new file mode 100644 index 00000000..a57d1026 --- /dev/null +++ b/src/collections/doc/spec/design-and-implementation.md @@ -0,0 +1,219 @@ +# User Collections — Design & Implementation (ERC1967Proxy architecture) + +**Status:** Implemented and deployed (verified on zkSync Sepolia). +**Scope:** `src/collections/` — `CollectionFactory`, `UserCollection721`, `UserCollection1155`. +**Companion:** [`user-collections-specification.md`](user-collections-specification.md) is the authoritative spec (what the system *is*); this doc records *why it's built this way and how*. + +> This document consolidates the original `2026-05-08-clones-replacement-design.md` +> and `2026-05-08-clones-replacement-implementation-plan.md` into a single +> as-built reference. The task-by-task plan framing has been dropped (the work +> shipped); the design rationale and implementation details are kept and updated +> to the final state. + +--- + +## 1. Context & decision + +### 1.1 The problem we solved + +The product requires **per-collection isolation**: every creator's collection is a fully independent contract with its own address, owner, storage, and a **permanent guarantee about its behavior** (spec §1.3). The first design used OpenZeppelin `Clones.clone()` (EIP-1167 minimal proxies). + +That is **incompatible with zkSync Era**. Two independent confirmations: + +1. **Compiled-artifact evidence.** `Clones.clone()` builds the EIP-1167 runtime blob in memory at runtime, which zksolc never sees statically. The factory's `factoryDependencies` came up empty, so the EraVM `ContractDeployer` could not resolve the deploy. +2. **Matter Labs confirmation.** EraVM uses a different bytecode format and does not support EIP-1167; `Clones.clone()` reverts `ERC1167: create failed` at runtime. + +### 1.2 Decision + +**Each collection is a per-collection `ERC1967Proxy`, deployed via `new ERC1967Proxy{salt: externalId}(impl, initData)`, where the implementation contracts deliberately do *not* inherit `UUPSUpgradeable`.** + +This is the only OZ-canonical pattern that simultaneously: + +- **Preserves the immutability promise.** With no `UUPSUpgradeable` on the impl and no `ProxyAdmin`, the EIP-1967 implementation slot is constructor-fixed and unreachable for writes afterward (three independent gates, §3). +- **Compiles cleanly on EraVM.** zksolc statically resolves `ERC1967Proxy`, registers its bytecode hash in the factory's `factoryDependencies`, and lowers `new ERC1967Proxy{salt}(...)` to `ContractDeployer.create2`. +- **Has in-repo precedent.** The factory's *own* proxy is deployed the same way. +- **Enables off-chain address pre-derivation** (§4). + +### 1.3 Rejected alternatives + +| Pattern | Rejected because | +|---|---| +| `Clones` / EIP-1167 | Incompatible with EraVM (the original blocker). | +| `BeaconProxy` + `UpgradeableBeacon` | Beacon admin can upgrade all collections at once — violates immutability. | +| `TransparentUpgradeableProxy` | Has a `ProxyAdmin` with upgrade authority — violates immutability. | +| `ERC1967Proxy` **with** `UUPSUpgradeable` on the impl | A per-collection admin could upgrade — violates immutability. | +| Forking OZ for a custom minimal proxy | Breaks the OZ-standards-only constraint and burns audit posture. | + +--- + +## 2. Architecture + +``` +CollectionFactory (UUPS proxy, admin-upgradeable) + ├── erc721Implementation ─┐ shared, immutable-per-release impl contracts + ├── erc1155Implementation ─┘ (deployed once via CREATE, not CREATE2) + └── createCollection* ──▶ new ERC1967Proxy{salt: externalId}(impl, initData) + │ + ▼ + Per-collection ERC1967Proxy (one per collection) + delegatecall ─▶ UserCollection721 / UserCollection1155 +``` + +- **`CollectionFactory`** — UUPS-upgradeable (so new templates / fixes ship without disrupting existing creators). Operator-gated creation. Holds the two implementation pointers and the `externalId → collection` registry. +- **`UserCollection721` / `UserCollection1155`** — the shared implementation contracts. Deployed **once** via `CREATE` (sequential nonce, never `CREATE2`). Each per-collection proxy `delegatecall`s into one of them. +- **Per-collection proxies** — one canonical, unmodified OZ `ERC1967Proxy` per collection, with its own storage and address. + +### 2.1 Deploy + init flow (atomic) + +``` +new ERC1967Proxy{salt: externalId}(impl, initData) + └─ ERC1967Proxy constructor(logic, data) + └─ ERC1967Utils.upgradeToAndCall(logic, data) + ├─ SSTORE(EIP-1967 impl slot, logic) + ├─ emit Upgraded(logic) + └─ delegatecall logic.initialize(p, operator) // runs in the proxy's storage + ├─ grants OWNER_ROLE + MINTER_ROLE to creator + ├─ grants MINTER_ROLE to operator (msg.sender) + additionalMinters + ├─ sets baseURI/uri + contractURI + ├─ sets default royalty (if bps > 0) + └─ emit Initialized(1) +``` + +`initData = abi.encodeCall(IUserCollection721.initialize, (p, msg.sender))`, built by the factory. Deploy and init happen in a **single constructor frame** — the collection is never observable on-chain in an uninitialized state, so initialization cannot be front-run. + +The factory encodes against the **interface** (`IUserCollection721.initialize`), not the concrete impl, keeping it decoupled from the implementation's code. + +### 2.2 Why `salt = externalId` + +`externalId` is already the system's uniqueness key (`_checkExternalId` rejects zero and duplicates). Using it as the CREATE2 salt removes the sequential-nonce race for concurrent creations and makes the address a cryptographic commitment to all inputs. + +--- + +## 3. Bytecode permanence (the immutability proof) + +Two distinct argument chains, both load-bearing for spec §1.3: + +### 3.1 Implementation permanence (`UserCollection721` / `1155`) +- Deployed via **`CREATE` only** (sequential nonce) — never `CREATE2`, so the address can't be re-occupied via salt collision. +- Constructor calls `_disableInitializers()` — the impl singleton can never be initialized directly. +- No `SELFDESTRUCT` in own or inherited code; no `delegatecall` to caller-provided addresses. +- Verified by the opcode-walker tests (`UserCollection721.t.sol` / `UserCollection1155.t.sol`). + +### 3.2 Per-collection proxy permanence (`ERC1967Proxy`) +The impl pointer is set once at construction and is unreachable for writes — **three gates that must *all* fail** for an upgrade to slip through: +1. The impls do **not** inherit `UUPSUpgradeable` → no `upgradeToAndCall` / `proxiableUUID` selector exposed. +2. We use `ERC1967Proxy` directly, not `TransparentUpgradeableProxy` → no `ProxyAdmin`. +3. `ERC1967Utils.upgradeToAndCall` is `internal` — callable only from a delegatecall frame whose code is the impl, and by (1) no such frame exists. + +Plus: the deployed bytecode is **canonical, unmodified OZ `ERC1967Proxy`** (no `SELFDESTRUCT`), and on EraVM `ContractDeployer` enforces one-deployment-per-address. + +### 3.3 EVM-vs-EraVM verification note +The Foundry opcode-walker runs against **EVM** bytecode (Foundry's default backend), not the shipped **EraVM** artifact. That's acceptable because EraVM doesn't support `selfdestruct` at the VM level. As a VM-agnostic guard on the *deployed* artifact, the deploy script (`ops/deploy_collection_factory_zksync.sh`, `verify_implementation_permanence`) asserts the zksolc ABI of both impls exposes **no** `upgradeTo*`/`proxiableUUID` selector — catching any accidental future `UUPSUpgradeable` inheritance. + +--- + +## 4. Address determinism & pre-derivation + +Per-collection addresses are deterministic and **pre-derivable off-chain before creation** (validated end-to-end on Sepolia: predicted address == deployed address). The zkSync CREATE2 derivation is a pure function of: + +- `sender` = the factory proxy address +- `bytecodeHash` = `utils.hashBytecode(ERC1967Proxy)` — pin to the zksolc version used at deploy time +- `salt` = `externalId` +- `input` = `abi.encode(impl, initData)`, where `initData = initialize(CreateParams, operator)` + +Backend flow (`zksync-ethers` `utils.create2Address`): +1. Read `erc721Implementation()` (cache; refresh after admin swaps). +2. Pin `ERC1967Proxy` zk bytecode hash from the factory deploy artifacts. +3. Fix `params`, choose the **current** `OPERATOR_ROLE` holder, generate `externalId`. +4. Compute the address; show it to the user before broadcasting. + +**Caveats:** the address is sensitive to *every* input — different params, operator, or impl → different address. The on-chain `collectionByExternalId` mapping stays canonical; pre-derivation is a convenience, not a replacement. + +--- + +## 5. Implementation details (as built) + +### 5.1 Roles +- **Factory:** `DEFAULT_ADMIN_ROLE` (admin Safe — upgrades, role admin, impl-pointer swaps), `OPERATOR_ROLE` (backend — `createCollection*`). +- **Per collection:** `OWNER_ROLE` (creator — metadata/royalty/minter management), `MINTER_ROLE` (creator + operator + `additionalMinters`). `OWNER_ROLE` admins `MINTER_ROLE`. +- **Operator auto-grant (§2.3):** the factory passes `msg.sender` into `initialize`, which unconditionally grants it `MINTER_ROLE` — a contract-level invariant, not a backend convention. +- **Role finality (§2.4):** collections are created with **no `DEFAULT_ADMIN_ROLE` holder**. `OWNER_ROLE` is its own non-transferable anchor; owner key loss permanently freezes owner-only functions (tokens/minting unaffected). Intentional. + +### 5.2 Custom surface vs OZ +The contracts inherit the full OZ `*Upgradeable` stack (ERC721/1155 + URIStorage/Supply + Burnable + ERC2981 + AccessControl). OZ ships **only `internal` hooks** (`_mint`, `_setURI`, `_baseURI`, `_setDefaultRoyalty`); the public `mint`/`mintBatch`/`setBaseURI`/`setURI`/`setContractURI`/`setDefaultRoyalty`/`lock*`/views are **our** access-gated wrappers. These (plus custom errors/events) are what the interfaces declare — the interfaces never re-declare OZ-provided public methods. `initialize` stays in `IUserCollection721/1155` (the factory encodes against it) but was removed from `ICollectionFactory` (no consumer; it's the `Initializable` deploy hook). + +The contracts remain **fully ERC-721 / ERC-1155 compliant** — the custom functions are additive. + +### 5.3 Owner-controlled anti-rug locks +`lockMetadata()` / `lockRoyalties()` are one-way, independent switches (emit events for indexers). After `lockMetadata`, `setBaseURI`/`setURI`/`setContractURI` revert; after `lockRoyalties`, `setDefaultRoyalty` reverts. **Unlocked by default** — locking is a deliberate, credible on-chain commitment the creator makes when ready (default-locked would make the setters dead and break reveals/fixes). Not part of any ERC — a custom buyer-protection mechanism. + +### 5.4 Metadata-URI convention (option b) +With a non-empty `baseURI`, OZ `ERC721URIStorage` resolves `tokenURI(id) = baseURI + perTokenSuffix`. Callers pass a **relative suffix** to `mint`/`mintBatch`. The per-token suffix is fixed at mint, but the shared `baseURI` is mutable until `lockMetadata` — so **only `metadataLocked` provides a freeze guarantee**, not the per-token suffix alone (validated on-chain: `tokenURI(0) = "ipfs://smoke/1.json"`). + +### 5.5 Security hardening (review findings, all applied) +- **Royalty observability** — `setDefaultRoyalty` emits `DefaultRoyaltyUpdated` (ERC-2981 is event-less) so indexers can track royalty changes. +- **`mintBatch` reentrancy** — the 721 batch reserves its ID range *before* the `_safeMint` loop, so a reentrant mint takes a fresh ID instead of relying on OZ's duplicate-mint revert (regression test included). +- **Factory invariant** — the `externalId → collection` registry write trails the deploy+init and is reentrancy-safe *only* while `initialize` makes no external calls; pinned in a code comment for future impls. +- **Fail-closed inputs** — `royaltyBps > 0` with a zero recipient, or `> 10000`, revert the whole `createCollection*` (OZ ERC-2981). + +### 5.6 Storage layout +Under OZ v5 ERC-7201 namespaced storage, the inherited mixins occupy keccak-derived slots; the contracts' own variables start at slot 0. The two lock bools pack into one slot. `__gap` reserves headroom for future appended fields when an admin swaps the impl pointer for future collections. + +--- + +## 6. Testing + +- **85 tests**, one file per contract + an integration test + the proxy-permanence test. ~97% line coverage on `src/collections/`. +- **Permanence** — opcode-walker (no `0xff`) over both impls and the canonical `ERC1967Proxy`; no-upgrade-selector ABI checks. +- **Address determinism** — `test_createCollection*_addressMatchesCreate2Derivation` (EVM CREATE2 formula on the Foundry backend; the EraVM formula is covered by the on-chain smoke test). +- **Atomicity** — `Upgraded(impl)` then `Initialized(1)` asserted inside the same `createCollection*` tx; immediate same-tx mint via the operator auto-grant. +- **Gap coverage** — initialize guard branches, cross-standard `externalId` collision, `mintBatch` boundary (exactly `MAX_BATCH`) + empty, reentrancy regression. + +--- + +## 7. Deployment & verification + +Two deploy paths exist; **Hardhat is preferred** because it source-verifies the factory. + +### 7.1 Foundry path +`ops/deploy_collection_factory_zksync.sh [--broadcast]` — `--zksync` compile (with L1-file move/restore), `factoryDependencies` gate, ABI-permanence gate, asserting post-deploy checks, mainnet confirmation, and a post-broadcast `createCollection721` smoke test. Source verification via `ops/verify_zksync_contracts.py`. + +### 7.2 Hardhat path (preferred for verification) +`yarn hardhat deploy-zksync --script DeployCollectionFactory.ts --network zkSyncSepoliaTestnet` — deploys all four (721 impl → 1155 impl → factory logic → factory `ERC1967Proxy`) and verifies via `hre.run("verify:verify")`. + +**Run a clean build first** (`yarn hardhat clean && yarn hardhat compile`) so the deploy and the verify step use the *same* fresh bytecode — stale artifacts otherwise cause a self-mismatch at verification time. + +### 7.3 The verification tooling split (important) +Neither tool verifies all four contracts alone: +- **Python standard-JSON helper** verifies plain contracts and the bare `ERC1967Proxy`, but **cannot verify `CollectionFactory`** — it carries `factoryDependencies` (the `ERC1967Proxy` bytecode hash) which the standard-JSON payload doesn't convey. +- **Hardhat `hardhat-zksync-verify`** conveys `factoryDeps`, so it verifies `CollectionFactory` + both impls, and (on a clean build) the proxy too via its proxy auto-detection. + +They **can't be mixed** on one deployment: hardhat and foundry must use the same zksolc, and the toolchains diverge (zksolc/zkvm-solc minor versions). `hardhat.config.ts` is pinned to **zksolc 1.5.15** to match foundry-zksync and the explorer verification settings. `ERC1967Proxy` must be loaded/verified by its **fully-qualified** `@openzeppelin/contracts/...` name (the upgradable plugin ships a second one → HH701). + +**Result on Sepolia:** all four contracts deploy and source-verify; smoke test (`createCollection721` → mint) passes end-to-end. + +### 7.4 Environment +`DEPLOYER_PRIVATE_KEY`, `N_FACTORY_ADMIN`, `N_FACTORY_OPERATOR` (admin should be a multisig and operator a separate backend key on mainnet; an all-in-one EOA is fine for testnet). + +--- + +## 8. Upgrades & storage-layout safety + +| Operation | How | +|---|---| +| Upgrade factory logic | `ops/upgrade_collection_factory_zksync.sh UPGRADE_FACTORY --broadcast` (admin key) — UUPS `upgradeToAndCall`. | +| Ship a new 721/1155 template | `… SET_IMPL_721` / `SET_IMPL_1155` — affects *future* collections only. | +| Rotate operator | admin `revokeRole`/`grantRole` on `OPERATOR_ROLE`. | +| Pause creation | admin revokes all `OPERATOR_ROLE` holders. | + +The upgrade wrapper runs the `--zksync` compile, artifact gates, an admin-key pre-check, mainnet guard, post-broadcast asserts (slot/role/pointer preservation), and source verification. + +**Storage-layout safety is on-demand, not a committed baseline.** We do not keep static `*.v1.json` snapshots (they go stale and only mirror git). Before a factory upgrade, regenerate the previous layout from the released ref and diff it (`forge inspect storageLayout --json`, projecting `label/slot/offset/type`); only appended fields consuming `__gap` are safe. For stronger guarantees, wire up the OZ / zkSync upgradable plugin's automated validation. There is no rollback path for already-deployed collections — that is the immutability guarantee. + +--- + +## 9. Audit posture + +- New contracts inherit only OZ-audited `*Upgradeable` primitives; the custom surface is small (factory glue, lock flags, role wiring, batch caps). +- Audit must confirm `import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"` resolves to canonical OZ at the lockfile-pinned version (a fork/remap would invalidate the permanence proof). +- Recommended focus: `CollectionFactory.createCollection*` and both `initialize` flows. diff --git a/src/collections/doc/spec/user-collections-specification.md b/src/collections/doc/spec/user-collections-specification.md new file mode 100644 index 00000000..38c4d031 --- /dev/null +++ b/src/collections/doc/spec/user-collections-specification.md @@ -0,0 +1,872 @@ +--- +title: "Nodle User Collections — Technical Specification" +subtitle: "Operator-Triggered NFT Collection Factory with Per-Collection ERC1967Proxy Isolation" +date: "April 2026" +version: "1.0" +--- + +
+ +# Nodle User Collections + +## Technical Specification + +**Operator-Triggered NFT Collection Factory with Per-Collection ERC1967Proxy Isolation** + +Version 1.0 — April 2026 + +
+ +
+ +## Table of Contents + +1. [Introduction & Architecture](#1-introduction--architecture) +2. [Roles & Access Control](#2-roles--access-control) +3. [Contract Interfaces](#3-contract-interfaces) +4. [Collection Creation Flow](#4-collection-creation-flow) +5. [Item Minting Flows](#5-item-minting-flows) +6. [Storage Layout](#6-storage-layout) +7. [Security Model](#7-security-model) +8. [Testing Strategy](#8-testing-strategy) +9. [Deployment & Operations](#9-deployment--operations) +10. [File Layout](#10-file-layout) +11. [Open Considerations](#11-open-considerations) + +
+ +## 1. Introduction & Architecture + +### 1.1 System Overview + +The User Collections system lets users create their own ERC-721 or ERC-1155 NFT collections on the Nodle zkSync L2. Creation is paid in fiat off-chain; the on-chain deployment is triggered by a trusted backend after the fiat payment clears. Each collection is a fully-isolated per-collection ERC1967Proxy with its own address, owner, and metadata. + +The on-chain layer provides: + +- A single upgradeable factory that deploys per-collection `ERC1967Proxy` instances pointing at fixed-behavior implementation contracts. +- Two implementation templates (ERC-721 and ERC-1155), both inheriting OpenZeppelin's audited upgradeable primitives. +- Role-scoped permissions: a backend operator can trigger creation and mint into any collection, while creators retain ownership and minting rights on their own collection. +- Reconciliation hooks (`externalId` events and lookup map) so the off-chain payment ledger can deterministically locate the on-chain artifact for every order. + +### 1.2 Architecture + +```mermaid +graph TB + subgraph Off-chain["Off-chain (out of scope)"] + APP(("App /
Frontend")) + BE(("Backend
Service")) + PAY(("Fiat
Processor")) + end + + subgraph Factory_Layer["Factory Layer (UUPS Upgradeable)"] + FAC["CollectionFactory
UUPS proxy
operator-only creation"] + end + + subgraph Impl["Implementation Templates (immutable per release)"] + I721["UserCollection721
ERC-721 logic"] + I1155["UserCollection1155
ERC-1155 logic"] + end + + subgraph Collections["User Collections (ERC1967Proxy per collection)"] + C1["Collection A
creator α"] + C2["Collection B
creator β"] + C3["Collection C
creator γ"] + end + + APP -- "create / mint" --> BE + BE -- "fiat charge" --> PAY + BE -- "createCollection*" --> FAC + FAC -- "new ERC1967Proxy{salt}" --> C1 + FAC -- "new ERC1967Proxy{salt}" --> C2 + FAC -- "new ERC1967Proxy{salt}" --> C3 + C1 -. "delegatecall" .-> I721 + C2 -. "delegatecall" .-> I1155 + C3 -. "delegatecall" .-> I721 + + style FAC fill:#ff9f43,color:#fff + style I721 fill:#4a9eff,color:#fff + style I1155 fill:#4a9eff,color:#fff + style C1 fill:#2ecc71,color:#fff + style C2 fill:#2ecc71,color:#fff + style C3 fill:#2ecc71,color:#fff + style APP fill:#95a5a6,color:#fff + style BE fill:#95a5a6,color:#fff + style PAY fill:#95a5a6,color:#fff +``` + +### 1.3 Core Components + +| Contract | Role | Pattern | Upgradeability | +| :-------------------- | :------------------------------------------------------------- | :----------------------- | :---------------------------------------- | +| `CollectionFactory` | Operator-triggered factory; emits creation events | UUPS proxy | Admin-upgradeable | +| `UserCollection721` | ERC-721 implementation behind a per-collection ERC1967Proxy | `ERC1967Proxy` implementation | Immutable per collection | +| `UserCollection1155` | ERC-1155 implementation behind a per-collection ERC1967Proxy | `ERC1967Proxy` implementation | Immutable per collection | + +The factory is upgradeable so new implementation templates and bug fixes can be shipped without disrupting existing creators. Already-deployed collections cannot be upgraded — buyers and creators retain a permanent guarantee about each collection's behavior. + +### 1.4 Design Decisions + +| # | Decision | Choice | +| :- | :----------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Token standards | Both ERC-721 and ERC-1155, selected per-collection | +| 2 | Deployment model | Per-collection `ERC1967Proxy` deployed via `CREATE2` with `externalId` salt; implementations deployed via `CREATE` only | +| 3 | Payment model | Fiat, off-chain; on-chain creation is purely authorization-gated | +| 4 | Authorization | Operator-deployed: backend holds `OPERATOR_ROLE`, creator never signs creation | +| 5 | Item minting rights | Creator and operator both hold `MINTER_ROLE` on every collection — operator grant is enforced on-chain by the factory (see §2.3), not by backend convention | +| 6 | Per-collection mutability | `baseURI`/`uri`, `contractURI`, royalties are owner-mutable until owner locks them one-way | +| 7 | Upgradeability | Factory: UUPS-upgradeable. Per-collection proxies: immutable (impls do not inherit `UUPSUpgradeable`, no admin slot); admin can swap implementation pointer for *future* collections only [^upgradeability] | +| 8 | Inheritance | Direct from OpenZeppelin `*Upgradeable` contracts. No reuse of `BaseContentSign` (constructor-based, non-upgradeable, structurally incompatible with proxy initialization) | +| 9 | External-ID dedup | On-chain map `bytes32 externalId → address collection`; reverts on reuse | +| 10 | Per-creator on-chain limit | None (backend rate-limits if needed) | +| 11 | Royalty ceiling | None beyond OpenZeppelin's ERC-2981 100% (10000 bps) bound. Creators have full autonomy over `royaltyBps` until they call `lockRoyalties`. Marketplace-norm enforcement (e.g. ≤10%) is deliberately out of scope — buyers and frontends are expected to inspect the on-chain value | +| 12 | OZ alignment | Stay aligned with OpenZeppelin's `*Upgradeable` shapes; avoid overriding inherited methods or diverging from canonical signatures unless strictly required (e.g. role gating, lock checks). Custom batch / utility helpers ship as net-new functions, not overrides — keeps the audit surface small and lets us track upstream OZ patches without merge friction | + +[^upgradeability]: We use `ERC1967Proxy` directly (not `TransparentUpgradeableProxy`, not `BeaconProxy`) because the implementation contracts deliberately do not inherit `UUPSUpgradeable`. With no upgrade selector exposed and no `ProxyAdmin` slot pattern, the proxy's implementation pointer is constructor-fixed and the per-collection immutability promise is enforced by code that already exists in the OZ canonical libraries — no custom upgrade gating needed. We migrated away from the EIP-1167 minimal-proxy pattern because it is incompatible with zkSync Era's `ContractDeployer` factoryDeps model (see `design-and-implementation.md`). + +### 1.5 Non-Goals + +- On-chain payment collection (fee paid in fiat off-chain). +- App, frontend, or off-chain backend implementation. +- Marketplace, secondary sales, or royalty enforcement beyond ERC-2981 declarations. +- Modifications to the existing `ContentSign` contract family. +- KYC, tax, or content moderation (off-chain concerns). + +
+ +## 2. Roles & Access Control + +### 2.1 Role Map + +| Role | Held by | Scope | Capabilities | +| :-------------------- | :----------------------- | :------------ | :------------------------------------------------------------------------------------------------- | +| `DEFAULT_ADMIN_ROLE` | L2 admin Safe (multisig) | Factory | Upgrade factory; swap implementation pointers; grant/revoke `OPERATOR_ROLE` | +| `OPERATOR_ROLE` | Backend service key | Factory | Call `createCollection721` / `createCollection1155` | +| `OWNER_ROLE` | Collection creator | Each collection | Set/lock `baseURI`/`uri`, `contractURI`, royalties; grant/revoke `MINTER_ROLE` on their collection | +| `MINTER_ROLE` | Creator + operator | Each collection | Mint items into the collection | + +### 2.2 Role Admin Hierarchy + +```mermaid +graph LR + DAR["DEFAULT_ADMIN_ROLE
(factory)"] --> OPR["OPERATOR_ROLE
(factory)"] + OWN["OWNER_ROLE
(per-collection)"] --> MIN["MINTER_ROLE
(per-collection)"] + + style DAR fill:#ff9f43,color:#fff + style OPR fill:#ff9f43,color:#fff + style OWN fill:#2ecc71,color:#fff + style MIN fill:#2ecc71,color:#fff +``` + +On the factory, `DEFAULT_ADMIN_ROLE` administers `OPERATOR_ROLE`. On each collection, `OWNER_ROLE` administers `MINTER_ROLE` (so the creator can grant additional minters or revoke the operator's minting rights if they wish). + +### 2.3 Operator Minter Auto-Grant + +When the factory creates a collection, it passes `msg.sender` (the `OPERATOR_ROLE` holder that triggered creation) to the collection's `initialize` as a guaranteed minter. The collection unconditionally grants that address `MINTER_ROLE` during initialization, alongside any addresses listed in `additionalMinters`. + +This makes operator-driven minting a **contract-level invariant** rather than a backend convention: it is impossible to deploy a collection through the factory that the calling operator cannot mint into. After creation, creators retain full control via `OWNER_ROLE` and may revoke the operator's `MINTER_ROLE` at any time. + +**Operator key rotation.** When admin grants `OPERATOR_ROLE` to a new address, all *future* collections auto-grant `MINTER_ROLE` to the new operator. Existing collections are unaffected (immutable per release); creators must grant `MINTER_ROLE` to the new operator individually if they want operator-driven minting to continue on collections deployed before the rotation. The backend should track this as part of any rotation runbook. + +`additionalMinters` remains available for creators who want extra minters seeded at creation (e.g. a co-creator wallet) and is orthogonal to the operator auto-grant. + +### 2.4 Collection Role Finality (intentional) + +Per-collection role wiring is deliberately minimal and final: + +- **Collections have no `DEFAULT_ADMIN_ROLE` holder.** `initialize` grants only `OWNER_ROLE` + `MINTER_ROLE` (to the owner), `MINTER_ROLE` (to the operator and `additionalMinters`), and sets `_setRoleAdmin(MINTER_ROLE, OWNER_ROLE)`. It never grants `DEFAULT_ADMIN_ROLE` to anyone. This is a feature: there is no super-admin that could later seize a collection, reinforcing the §1.3 immutability promise. +- **`OWNER_ROLE` is non-transferable.** Its role-admin is the default `0x00` (`DEFAULT_ADMIN_ROLE`), which has no members — so `OWNER_ROLE` can never be granted to a new address. The creator is the permanent owner; they can self-manage `MINTER_ROLE` (grant co-minters, revoke the operator) but cannot hand off ownership on-chain. +- **Consequence — owner key loss is unrecoverable.** If the creator loses their key, all owner-only functions (`setBaseURI`/`setURI`, `setContractURI`, `setDefaultRoyalty`, `lockMetadata`, `lockRoyalties`, minter management) are permanently frozen. Token transfers, burns, and any already-granted minters are unaffected — the collection keeps functioning, it just can no longer be reconfigured. A creator may also `renounceRole(OWNER_ROLE, self)`, intentionally bricking owner governance (e.g. to credibly signal "settings are final" without using the lock flags). Backends should surface owner-key custody as a one-way decision to creators. + +
+ +## 3. Contract Interfaces + +### 3.1 Public Interfaces + +| Interface | Description | +| :-------------------------------- | :------------------------------------------------------- | +| `interfaces/ICollectionFactory.sol` | Factory public API | +| `interfaces/IUserCollection721.sol` | ERC-721 implementation public API | +| `interfaces/IUserCollection1155.sol`| ERC-1155 implementation public API | +| `interfaces/CollectionTypes.sol` | Shared enums and structs (`Standard`, `CreateParams*`) | + +### 3.2 Contract Classes + +```mermaid +classDiagram + class CollectionFactory { + +address erc721Implementation + +address erc1155Implementation + +mapping collectionByExternalId : bytes32 → address + -- + +initialize(admin, operator, impl721, impl1155) + +createCollection721(p, externalId) → address + +createCollection1155(p, externalId) → address + +setImplementation721(impl) + +setImplementation1155(impl) + +_authorizeUpgrade(newImpl) onlyAdmin + } + + class UserCollection721 { + +string contractURI + +uint256 nextTokenId + +bool metadataLocked + +bool royaltiesLocked + +uint256 MAX_BATCH = 100 + -- + +initialize(p, operatorMinter) + +mint(to, tokenURI_) → tokenId + +mintBatch(to[], uris[]) → tokenIds[] + +setBaseURI(newBase) + +setContractURI(newURI) + +setDefaultRoyalty(recipient, bps) + +lockMetadata() + +lockRoyalties() + } + + class UserCollection1155 { + +string contractURI + +bool metadataLocked + +bool royaltiesLocked + +uint256 MAX_BATCH = 100 + -- + +initialize(p, operatorMinter) + +mint(to, id, amount, data) + +mintBatch(to, ids[], amounts[], data) + +setURI(newURI) + +setContractURI(newURI) + +setDefaultRoyalty(recipient, bps) + +lockMetadata() + +lockRoyalties() + } + + CollectionFactory --> UserCollection721 : deploys proxy + CollectionFactory --> UserCollection1155 : deploys proxy +``` + +### 3.3 Shared Types + +```solidity +enum Standard { ERC721, ERC1155 } + +struct CreateParams721 { + address owner; + string name; + string symbol; + string baseURI; + string contractURI; + address royaltyRecipient; + uint96 royaltyBps; + address[] additionalMinters; +} + +struct CreateParams1155 { + address owner; + string uri; // ERC-1155 URI (typically with {id} placeholder) + string contractURI; + address royaltyRecipient; + uint96 royaltyBps; + address[] additionalMinters; +} +``` + +ERC-1155 has no on-chain `name`/`symbol` convention; the collection display name lives in `contractURI` JSON metadata. + +### 3.4 `CollectionFactory` + +```solidity +interface ICollectionFactory { + event CollectionCreated( + address indexed creator, + address indexed collection, + Standard standard, + bytes32 indexed externalId + ); + event ImplementationUpdated(Standard standard, address newImpl); + + error ExternalIdAlreadyUsed(bytes32 externalId); + error InvalidExternalId(); + error ZeroAddress(); + error NotAContract(address impl); + + function initialize( + address admin, + address operator, + address impl721, + address impl1155 + ) external; + + function createCollection721(CreateParams721 calldata p, bytes32 externalId) + external returns (address collection); + + function createCollection1155(CreateParams1155 calldata p, bytes32 externalId) + external returns (address collection); + + function setImplementation721(address impl) external; + function setImplementation1155(address impl) external; + + function collectionByExternalId(bytes32 externalId) external view returns (address); + function erc721Implementation() external view returns (address); + function erc1155Implementation() external view returns (address); +} +``` + +#### Behavior + +- `initialize` is callable once. Grants `admin → DEFAULT_ADMIN_ROLE`, `operator → OPERATOR_ROLE`. Reverts `ZeroAddress` if any of `admin`, `operator`, `impl721`, `impl1155` is zero. Reverts `NotAContract(impl)` if either implementation address has zero bytecode (`impl.code.length == 0`). +- `createCollection*`: + - Restricted to `OPERATOR_ROLE`. + - Reverts `InvalidExternalId` if `externalId == bytes32(0)` (forces a non-trivial ID). + - Reverts `ExternalIdAlreadyUsed` if the ID has already been used. + - Atomic flow: `abi.encodeCall(initialize, (p, msg.sender))` → `new ERC1967Proxy{salt: externalId}(impl, initData)` (deploy + delegatecall init in a single constructor frame) → `collectionByExternalId[externalId] = collection` → `emit CollectionCreated`. Passing `msg.sender` into `initData` ensures the calling operator is auto-granted `MINTER_ROLE` on the new collection (see §2.3). + - Returns the collection address. +- `setImplementation*` is restricted to `DEFAULT_ADMIN_ROLE` and affects future collections only. Existing collections continue to delegatecall their original implementation. Reverts `ZeroAddress` if `impl == address(0)`. Reverts `NotAContract(impl)` if `impl.code.length == 0` (defends against EOA paste / unset env var). The setter does **not** verify the implementation matches the expected token standard (e.g. a 1155 implementation pasted into `setImplementation721`); the post-upgrade `cast` checks in §9.4 are the runbook layer that catches this. +- `_authorizeUpgrade(address)` is `onlyRole(DEFAULT_ADMIN_ROLE)`. + +### 3.5 `UserCollection721` + +```solidity +interface IUserCollection721 { + event MetadataLocked(); + event RoyaltiesLocked(); + event ContractURIUpdated(string newURI); + event BaseURIUpdated(string newBase); + event DefaultRoyaltyUpdated(address recipient, uint96 bps); + + error MetadataIsLocked(); + error RoyaltiesAreLocked(); + error BatchTooLarge(uint256 length, uint256 max); + error LengthMismatch(); + + function initialize(CreateParams721 calldata p, address operatorMinter) external; + + function mint(address to, string calldata tokenURI_) external returns (uint256 tokenId); + function mintBatch(address[] calldata to, string[] calldata uris) + external returns (uint256[] memory tokenIds); + + function setBaseURI(string calldata newBase) external; + function setContractURI(string calldata newURI) external; + function setDefaultRoyalty(address recipient, uint96 bps) external; + function lockMetadata() external; + function lockRoyalties() external; + + function contractURI() external view returns (string memory); + function nextTokenId() external view returns (uint256); + function metadataLocked() external view returns (bool); + function royaltiesLocked() external view returns (bool); +} +``` + +#### Behavior + +- Inherits `Initializable`, `ERC721Upgradeable`, `ERC721URIStorageUpgradeable`, `ERC721BurnableUpgradeable`, `ERC2981Upgradeable`, `AccessControlUpgradeable`. +- Implementation contract calls `_disableInitializers()` in its constructor so the implementation itself can never be initialized directly. +- **Bytecode-permanence invariants** (load-bearing for the §1.3 immutability promise): the implementation contains no `SELFDESTRUCT` opcode (no `selfdestruct(...)` calls in the implementation's own code or in any inherited contract), and performs no `delegatecall` to caller-provided addresses. Both properties are asserted by the unit test in §8.2 and reviewed by the auditor. +- `initialize` (initializer-gated): sets name/symbol via `__ERC721_init`, sets `baseURI` and `contractURI`, sets default royalty if `royaltyBps > 0`, grants `OWNER_ROLE` and `MINTER_ROLE` to `owner`, grants `MINTER_ROLE` to `operatorMinter` (passed by the factory; see §2.3), grants `MINTER_ROLE` to each `additionalMinters` entry, and calls `_setRoleAdmin(MINTER_ROLE, OWNER_ROLE)`. Reverts `ZeroAddress` if `operatorMinter == address(0)`. Re-granting an already-held role is a no-op (OZ `grantRole` is idempotent), so duplicates between `owner`, `operatorMinter`, and `additionalMinters` are safe. +- `mint`: `MINTER_ROLE`-gated. Increments `nextTokenId`, calls `_safeMint`, sets per-token URI via `ERC721URIStorage._setTokenURI`. Returns the new token ID. Callers pass a *relative suffix* for `tokenURI_` (see §7.2 row 7 / the URI convention): the resolved `tokenURI` is `baseURI + tokenURI_` when `baseURI` is non-empty. +- `mintBatch`: `MINTER_ROLE`-gated. Reverts `LengthMismatch` if `to.length != uris.length`. Reverts `BatchTooLarge` if `to.length > MAX_BATCH` (100). Returns `uint256[] tokenIds` in the same order as `to`; the values are a contiguous range starting at the value of `nextTokenId` at call entry. The return lets backends reconcile per-buyer attribution synchronously without parsing `Transfer` logs or racing against concurrent minters. +- `setBaseURI`: `OWNER_ROLE`-gated. Reverts `MetadataIsLocked` when `metadataLocked == true`. +- `setContractURI`: `OWNER_ROLE`-gated. Reverts `MetadataIsLocked` when `metadataLocked == true`. The single `metadataLocked` flag covers both per-collection (`baseURI`) and collection-level (`contractURI`) metadata so that buyers see one verifiable "metadata is frozen" signal across the whole collection. (Per-token *suffixes* set via `ERC721URIStorage._setTokenURI` are fixed at mint, but the resolved `tokenURI` is `baseURI + suffix` — so it is **not** stable while `baseURI` remains mutable. Only `metadataLocked` provides the freeze guarantee — see §7.2 row 7.) +- `setDefaultRoyalty`: `OWNER_ROLE`-gated. Reverts `RoyaltiesAreLocked` when `royaltiesLocked == true`. No additional cap beyond OZ's ERC-2981 100% bound (see §1.4 row 11) — creators may set any value up to 10000 bps. Emits `DefaultRoyaltyUpdated(recipient, bps)` (a `bps == 0` emission signals the royalty was cleared) — ERC-2981 itself is event-less, so this is the only on-chain signal indexers can use to track royalty changes for buyer due-diligence. +- `lockMetadata` / `lockRoyalties`: `OWNER_ROLE`-gated, one-way; emit events for indexers. + +### 3.6 `UserCollection1155` + +Mirrors §3.5 with ERC-1155 mechanics: + +- Inherits `Initializable`, `ERC1155Upgradeable`, `ERC1155SupplyUpgradeable`, `ERC1155BurnableUpgradeable`, `ERC2981Upgradeable`, `AccessControlUpgradeable`. +- `initialize(CreateParams1155 calldata p, address operatorMinter)` — same role-grant semantics as §3.5: `OWNER_ROLE` + `MINTER_ROLE` to `owner`, `MINTER_ROLE` to `operatorMinter` (factory-passed; see §2.3), `MINTER_ROLE` to each `additionalMinters` entry, `_setRoleAdmin(MINTER_ROLE, OWNER_ROLE)`. Reverts `ZeroAddress` if `operatorMinter == address(0)`. +- `mint(address to, uint256 id, uint256 amount, bytes data)` — `MINTER_ROLE`-gated. +- `mintBatch(address to, uint256[] ids, uint256[] amounts, bytes data)` — single recipient, matching OZ's `_mintBatch`. Reverts `BatchTooLarge` when `ids.length > MAX_BATCH` and `LengthMismatch` when `ids.length != amounts.length`. Multi-recipient batching (one tx, N different buyers) is intentionally out of scope for v1: it has no native OZ primitive, would require a custom loop emitting N `TransferSingle` events, and the dominant fiat-paid 1:1 flow (§5.2) doesn't naturally batch (per-buyer payments clear independently). If airdrop or allowlist-drop flows become a product requirement, a `mintBatchMulti` can be added in a future implementation pointer (admin swap, future collections only) without breaking existing callers — see §11. +- `setURI(string newURI)` instead of `setBaseURI`. Subject to `metadataLocked`. +- No `nextTokenId` (1155 IDs are caller-chosen). +- **Bytecode-permanence invariants** apply identically to §3.5: implementation constructor calls `_disableInitializers()`; implementation contains no `SELFDESTRUCT` opcode and no `delegatecall` to caller-provided addresses. Asserted by the §8.3 unit test. + +
+ +## 4. Collection Creation Flow + +### 4.1 End-to-End Sequence + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant App as App + participant BE as Backend + participant Pay as Fiat Processor + participant FAC as CollectionFactory + participant CL as New Collection + + U->>App: Submit collection params + App->>BE: createCollection request + payment authorization + BE->>Pay: Charge fiat + Pay-->>BE: Payment cleared + BE->>BE: Assign orderId, externalId = keccak256(orderId) + BE->>FAC: createCollection721(params, externalId) + Note over FAC: only OPERATOR_ROLE may call + FAC->>FAC: require externalId != 0 + FAC->>FAC: require collectionByExternalId[externalId] == 0 + FAC->>CL: new ERC1967Proxy{salt: externalId}(erc721Implementation, encodeCall(initialize, (p, msg.sender))) + Note over CL: emit Upgraded(impl), emit Initialized(1) inside constructor + Note over CL: auto-grants MINTER_ROLE to operator + FAC->>FAC: collectionByExternalId[externalId] = collection + FAC-->>BE: emit CollectionCreated(creator, collection, ERC721, externalId) + BE->>App: Mark order completed; return collection address + App-->>U: Display collection address +``` + +### 4.2 Atomicity & Front-Running + +The collection is deployed and initialized inside the same transaction. The collection is never visible on-chain in an uninitialized state, so initialization cannot be front-run by an external observer. Deploy and initialize occur in a single constructor frame; there is no transient window where the proxy exists in an uninitialized state. + +### 4.3 Reconciliation + +`externalId` is emitted as an indexed event topic and stored in `collectionByExternalId`. The backend can: + +- Confirm a collection exists for an order: `factory.collectionByExternalId(externalId)`. +- Detect duplicate triggers: revert on reuse prevents double-creation. +- Recover from local state loss: re-derive `externalId = keccak256(orderId)` and look up the collection on-chain. + +### 4.4 Gas Profile + +A successful `createCollection721`: + +- On zkSync Era, per-collection deploy is dominated by `ContractDeployer.create2` plus the constructor's delegatecall init. Gas measured by `Collections.integration.t.sol` and quoted from the test output (target: < 1.5M gas on zkSync Sepolia for a typical `createCollection721`). The previous EIP-1167 baseline (~45k gas on EVM L1) is no longer applicable because we don't deploy minimal proxies. +- Calls `initialize(...)` via the proxy constructor — variable, dominated by string storage (`baseURI`, `contractURI`). +- Writes the `collectionByExternalId` mapping (one warm SSTORE). +- Emits the event. + +On zkSync Era the absolute cost is dominated by L1 calldata fees and is well under one cent at typical L1 gas prices. + +### 4.5 Address Determinism + +Per-collection addresses are deterministic on-chain because the factory uses `new ERC1967Proxy{salt: externalId}(...)`. The address is a pure function of: + +- `factory` proxy address (constant per environment) +- `externalId` (the salt; supplied by the operator) +- `_erc721Implementation` / `_erc1155Implementation` (read once via `erc721Implementation()` / `erc1155Implementation()`; refresh after admin upgrades) +- `initData = abi.encodeCall(initialize, (params, operatorAddress))` +- `ERC1967Proxy` zk bytecode hash (constant per zksolc release; pin in backend artifacts at the version used at factory deploy time) + +Backends can pre-derive the collection address before broadcasting `createCollection*` using `zksync-ethers` `utils.create2Address`. The on-chain `_collectionByExternalId[externalId]` mapping remains the canonical registry — pre-derivation is a redundant off-chain lookup path, not a replacement. + +**Caveats:** +1. Address is sensitive to every input — different `params` or different operator → different address. +2. Operator rotation (§2.3): pre-derive using the *current* OPERATOR_ROLE holder. +3. `_collectionByExternalId` mapping stays canonical and enforces uniqueness via `_checkExternalId`. +4. Pin the `ERC1967Proxy` zk bytecode hash; refresh only during a coordinated zksolc bump. + +
+ +## 5. Item Minting Flows + +### 5.1 Creator-Driven Mint + +```mermaid +sequenceDiagram + autonumber + participant CR as Creator Wallet + participant App as App + participant IPFS as IPFS / Pinning + participant CL as User Collection + participant PM as BondTreasuryPaymaster + + CR->>App: Select "Mint item" + App->>IPFS: Pin metadata + IPFS-->>App: CID + App->>CR: Sign tx → mint(creator, "ipfs://CID/metadata.json") + CR->>PM: Submit tx (paymaster sponsors gas) + PM->>CL: mint(creator, uri) + CL-->>App: emit Transfer(0x0, creator, tokenId) +``` + +The creator holds `MINTER_ROLE` on their own collection by default. If the creator's wallet has bond allowance under `BondTreasuryPaymaster`, gas is sponsored; otherwise the creator pays gas directly. + +### 5.2 Operator-Driven Mint (Fiat-Priced Item Sale) + +```mermaid +sequenceDiagram + autonumber + participant B as Buyer + participant App as App + participant BE as Backend + participant Pay as Fiat Processor + participant CL as User Collection + + B->>App: Buy item (creator's collection) + App->>BE: Purchase request + BE->>Pay: Charge fiat + Pay-->>BE: Payment cleared + BE->>CL: mint(buyerAddress, uri) + Note over CL: backend holds MINTER_ROLE if creator allowed it + CL-->>BE: emit Transfer(0x0, buyer, tokenId) + BE-->>App: Mark order completed + App-->>B: Item delivered +``` + +The operator's `MINTER_ROLE` on each collection is established at creation time (via `additionalMinters`). Creators can revoke this role at any time, in which case operator-driven mints into that collection will revert until the role is re-granted. + +
+ +## 6. Storage Layout + +### 6.1 Factory Storage + +``` +[OZ AccessControlUpgradeable storage] +[OZ UUPSUpgradeable storage] +slot N+0 : erc721Implementation (address) +slot N+1 : erc1155Implementation (address) +slot N+2 : collectionByExternalId (mapping bytes32 → address) +slot N+3 : __gap[47] (reserved for future fields) +``` + +Actual slot indices are determined by the inheritance chain. Storage layout is verified **manually** against the previous release before every factory upgrade — see §9.4 for the pre-upgrade checklist. + +### 6.2 Per-Collection Storage + +Each collection owns its full storage independently (`ERC1967Proxy` `delegatecall`s logic at the address in the EIP-1967 implementation slot but persists state in the proxy's own address). + +``` +[OZ ERC721Upgradeable / ERC1155Upgradeable storage] +[OZ ERC721URIStorageUpgradeable storage (721 only)] +[OZ ERC1155SupplyUpgradeable storage (1155 only)] +[OZ ERC2981Upgradeable storage] +[OZ AccessControlUpgradeable storage] +slot M+0 : contractURI (string) +slot M+1 : nextTokenId (uint256, 721 only — omitted on 1155, gap shifts up by one) +slot M+2 : metadataLocked (bool, byte 0) | royaltiesLocked (bool, byte 1) | 30 bytes free +slot M+3 : __gap[N] (reserved) +``` + +The two lock booleans share one slot via Solidity's automatic packing (declared adjacent in source order after the `string`/`uint256` fields above). This saves one slot of `__gap`, which materially extends the headroom for future appended fields when admin swaps the implementation pointer for *future* collections. + +`__gap` is a defensive reservation. Per-collection proxies are immutable per release, but if admin swaps the implementation pointer for *future* collections, the gap allows the new implementation to extend storage without conflict for those future collections. + +### 6.3 Storage-Layout Discipline + +All upgradeable contracts in this package follow the same conventions used by `src/swarms/`: + +- No state variables in inherited contracts shifted between releases. +- New variables appended only; gap reduced by the number of new slots. +- For packed slots (e.g. the `metadataLocked` / `royaltiesLocked` slot in §6.2), the manual diff must verify both the slot index **and** the byte offset of each sub-word field — Solidity's layout is sensitive to source order within a packed slot, and a reordering moves bytes without moving slots. +- Before each release that ships a factory upgrade, the engineer running the upgrade snapshots the previous and new layouts via `forge inspect ... storageLayout` and manually verifies that all prior slots remain at the same offsets. The full pre-upgrade checklist lives in §9.4. + +
+ +## 7. Security Model + +### 7.1 Trust Assumptions + +| Principal | Trusted to | Compromise impact | +| :-------------------- | :---------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------- | +| Backend operator key | Trigger creation only after fiat payment clears; not mint maliciously | Free collections; mass minting into any collection where operator holds `MINTER_ROLE` | +| Factory admin (Safe) | Upgrade factory benignly; rotate operator role responsibly | Affects *future* creations only; already-deployed collections are immutable | +| Collection creators | Trusted by their own buyers (not by the platform) | Creator-side rugs (metadata / royalty) mitigated by opt-in lock flags | + +### 7.2 Risks & Mitigations + +| # | Risk | Mitigation | +| :-- | :---------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | +| 1 | Operator key compromise → free collections / mass mint | HSM/KMS storage; role rotation via `grantRole`/`revokeRole`; off-chain monitoring on creation rate | +| 2 | External-ID replay (double-creation) | On-chain `collectionByExternalId` map; revert on reuse | +| 3 | Implementation contract initialized directly | `_disableInitializers()` in implementation constructor | +| 4 | Initialization front-run on a fresh collection | Atomic deploy+init inside the proxy constructor; collection never observable uninitialized | +| 5 | Storage-layout corruption on factory upgrade | `__gap` reserved slots; manual pre-upgrade `forge inspect storageLayout` diff against previous release (§9.4) | +| 6 | Royalty rug (creator sets 100% post-mint) | `royaltiesLocked` opt-in; `RoyaltiesLocked` event indexed for buyer due-diligence | +| 7 | Metadata rug (creator changes baseURI mid-mint) | `metadataLocked` opt-in, one-way. **Important:** with a non-empty `baseURI`, OZ `ERC721URIStorage` resolves `tokenURI(id) = baseURI + perTokenSuffix`. The per-token *suffix* is fixed at mint, but the shared `baseURI` stays mutable until `lockMetadata` — so changing `baseURI` re-points the resolved URI of **every already-minted token**. Buyers therefore get a freeze guarantee **only** from `metadataLocked`; the per-token suffix alone is not sufficient. Backend convention (option b): pass a *relative suffix* (not a full URI) to `mint`/`mintBatch`, since a full URI would be double-prefixed by `baseURI`. | +| 8 | Reentrancy on `_safeMint` callback | OZ default ordering: state writes precede `onERC721Received`; no post-callback reads in our code | +| 9 | DoS via huge `mintBatch` arrays | `MAX_BATCH = 100`; revert `BatchTooLarge` if exceeded | +| 10 | UUPS bricking via mis-set `_authorizeUpgrade` | Standard OZ pattern; unit test asserts non-admin cannot upgrade | +| 11 | Operator granted to wrong address at init | `initialize` requires explicit `operator` arg, not `msg.sender` | +| 12 | Creator revokes operator's `MINTER_ROLE` mid-flow | Operator-driven mints revert cleanly; backend surfaces error to operations | +| 13 | Operator key rotation leaves old collections without the new operator | Auto-grant only applies to *future* collections; runbook step requires creators to grant `MINTER_ROLE` to the new key for continued operator-driven sales on pre-rotation collections | +| 14 | Admin sets factory implementation pointer to zero / EOA / non-contract | `setImplementation*` and `initialize` reject zero addresses (`ZeroAddress`) and addresses with no bytecode (`NotAContract`); wrong-standard pointers caught by the §9.4 post-upgrade `cast` checks | +| 15a | Implementation bytecode permanence | Implementations deployed via `CREATE` only (sequential nonce, never `CREATE2`); no `SELFDESTRUCT` in own/inherited code; no `delegatecall` to caller-provided addresses; verified by opcode-walker test. **EVM vs EraVM:** the opcode-walker runs against the **EVM**-compiled bytecode (Foundry default backend), so it does not directly cover the zksolc/**EraVM** artifact that ships to zkSync. On EraVM this is acceptable because `selfdestruct` is unsupported at the VM level (the impl cannot be wiped on the target chain by construction). The deploy script adds a VM-agnostic gate that *does* cover the deployed artifact: `verify_implementation_permanence` (in `ops/deploy_collection_factory_zksync.sh`) asserts the zksolc-emitted ABI of both implementations exposes no `upgradeTo`/`upgradeToAndCall`/`proxiableUUID` selector, catching any accidental future `UUPSUpgradeable` inheritance that would break the §1.3 promise. | +| 15b | Per-collection proxy permanence | Deployed via `CREATE2` with `externalId` salt using canonical OZ `ERC1967Proxy` unmodified; impls do not inherit `UUPSUpgradeable`; no `ProxyAdmin` pattern; therefore the EIP-1967 impl slot is constructor-fixed and the proxy bytecode is permanent. Verified by: (i) lockfile-pinned OZ import, (ii) opcode-walker test on `ERC1967Proxy` runtime, (iii) unit test asserting impls have no `upgradeTo*` selectors | +| 16 | Audit posture for OZ proxy import | Audit must verify `import {ERC1967Proxy} from '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol'` resolves to canonical OZ at the lockfile-pinned version; remappings or forks of OZ proxy contracts are out of band and would invalidate the bytecode-permanence proof | + +### 7.3 Out of Scope + +- Royalty enforcement on secondary markets — ERC-2981 is informational, on-chain enforcement is widely abandoned and breaks marketplace compatibility. +- Content moderation, KYC, and tax compliance — handled off-chain. +- Front-end phishing or wallet UX — out of scope for the on-chain layer. + +### 7.4 Audit Posture + +- New contracts inherit only OZ-audited `*Upgradeable` primitives. +- Custom code surface is small: factory glue, lock flags, role wiring, batch caps. +- Recommended: focused audit on `CollectionFactory.createCollection*` and both `initialize` flows before mainnet deployment. + +
+ +## 8. Testing Strategy + +Unit tests live under `test/collections/`, one file per contract plus an integration test: + +### 8.1 `CollectionFactory.t.sol` + +- `initialize` succeeds once; second call reverts `InvalidInitialization`. +- `initialize` reverts on zero addresses. +- Only `OPERATOR_ROLE` can call `createCollection*`. +- Atomic deploy + initialize → resulting collection has expected name/symbol, owner, base URI, contract URI, royalties, minters. +- After `createCollection*`, the calling operator (`msg.sender`) holds `MINTER_ROLE` on the new collection even when `additionalMinters` is empty (auto-grant invariant, §2.3). +- After admin rotates `OPERATOR_ROLE` to a new address, a collection created by the new operator auto-grants `MINTER_ROLE` to the new key; collections created by the previous operator are unaffected. +- `externalId == bytes32(0)` reverts `InvalidExternalId`. +- Reused `externalId` reverts `ExternalIdAlreadyUsed`. +- `collectionByExternalId(externalId)` returns the collection address after success. +- `setImplementation*` callable only by admin; affects future collections only (existing collections unchanged when verified by `EXTCODEHASH` or behavior probe). +- `setImplementation*` reverts `ZeroAddress` when called with `address(0)`. +- `setImplementation*` reverts `NotAContract` when called with an address that has no bytecode (e.g. an EOA). +- `initialize` reverts `ZeroAddress` for any zero arg and `NotAContract` for either implementation address with empty code. +- UUPS upgrade succeeds for admin and changes the EIP-1967 implementation slot to the new address (read the slot pre/post via `vm.load(proxy, bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1))`). +- UUPS upgrade reverts when called by an account holding `OPERATOR_ROLE` only (no role-escalation path from operator → admin) and reverts when called by a fresh EOA with no roles. Both assertions exercise OZ's `AccessControlUnauthorizedAccount` revert. +- UUPS upgrade preserves storage: a `CollectionFactoryV2Mock` (test-only fixture) is deployed; pre-upgrade state — admin/operator role grants, `erc721Implementation` / `erc1155Implementation` pointers, and at least one `collectionByExternalId` entry seeded by a prior `createCollection*` — must read correctly through the upgraded proxy. The mock adds a new public function whose presence post-upgrade is also asserted, confirming the upgrade path is genuinely exercised. +- UUPS upgrade to a non-UUPS implementation reverts via OZ's `proxiableUUID` check (`ERC1967InvalidImplementation`). Test deploys a plain `ERC721Upgradeable`-based contract that does not inherit `UUPSUpgradeable` and asserts the revert. +- `CollectionCreated` event fields are correct. + +### 8.2 `UserCollection721.t.sol` + +- Initialize sets all fields and roles correctly, including `MINTER_ROLE` for `operatorMinter`. +- `initialize` reverts with `ZeroAddress` when called with `operatorMinter == address(0)`. +- `_disableInitializers` blocks direct initialization on the implementation contract. +- `mint` requires `MINTER_ROLE`; emits `Transfer`; sets correct `tokenURI`; increments `nextTokenId`. +- `mintBatch` length-mismatch and oversize-batch reverts. +- `mintBatch` returns the array of newly-minted token IDs; values match the `Transfer` events emitted in-order and form a contiguous range starting at the pre-call `nextTokenId`. +- `setBaseURI` / `setContractURI` / `setDefaultRoyalty` permission and lock semantics. +- `lockMetadata` / `lockRoyalties` are one-way; subsequent setters revert with the corresponding error. +- Owner can grant and revoke `MINTER_ROLE` (verifies `_setRoleAdmin(MINTER_ROLE, OWNER_ROLE)`). +- ERC-2981 returns expected royalty info. +- `supportsInterface` returns true for ERC-721, ERC-721 metadata, ERC-2981, ERC-165, AccessControl. +- **Bytecode permanence**: scan `address(impl721).code` and assert no byte equals `0xff` (`SELFDESTRUCT` opcode) at any reachable position. Foundry's `bytecode_hash = "none"` setting (already pinned in `foundry.toml`, see §9.1) strips the metadata trailer that would otherwise produce false positives. Same scan asserts no `0xf4` (`DELEGATECALL`) appears in our own logic ranges; OZ inherited code contains none in the imported set. + +### 8.3 `UserCollection1155.t.sol` + +Analogous coverage adapted to ERC-1155 mechanics: per-ID supply tracking, single-recipient `mintBatch`, `setURI` lock semantics, ERC-1155 interface assertions. Includes the same **bytecode-permanence scan** as §8.2 against the 1155 implementation's runtime code. + +### 8.4 `Collections.integration.t.sol` + +End-to-end happy path: + +1. Admin deploys factory + both implementations via UUPS proxy. +2. Operator creates an ERC-721 collection for creator α. +3. Operator creates an ERC-1155 collection for creator β. +4. Operator mints into both on behalf of fiat buyers. +5. Creator α transfers an item to a third party. +6. Creator α locks metadata and royalties. +7. Subsequent setter calls revert with lock errors. +8. Admin upgrades the factory and ships a new ERC-721 implementation. +9. New ERC-721 collection deploys with new implementation; old collections remain on the previous implementation (verified via `EXTCODEHASH`). + +### 8.5 Coverage Target + +≥ 95% line coverage on the new contracts, enforced by CI. + +**What CI does** (`.github/workflows/checks.yml`): + +- The `Tests` job runs `yarn spellcheck`, `yarn lint`, and `forge test` on every push and PR. Tests under `test/collections/` are picked up automatically by `forge test`. +- The `Coverage` job runs `forge coverage` on PRs to `main`. The `--match-path` filter includes `test/collections/*` alongside the existing swarms test files; a dedicated **"Check line coverage threshold (collections)"** step parses the lcov report for `src/collections/` and fails the build if line coverage falls below 95%. The swarms gate continues to enforce the same threshold against `src/swarms/`. +- While `src/collections/` is documentation-only (this PR), the collections threshold step skips cleanly with a GitHub Actions warning and a non-failing exit. The gate begins enforcing the moment Solidity sources land under `src/collections/`. No follow-up workflow change is required. + +
+ +## 9. Deployment & Operations + +### 9.1 Deployment Script + +Two artifacts, mirroring the swarms pattern: + +- `script/DeployCollectionFactoryZkSync.s.sol` — Forge script that performs the on-chain deployment work. +- `ops/deploy_collection_factory_zksync.sh` — orchestration shell script analogous to `ops/deploy_swarm_contracts_zksync.sh`: runs preflight checks, compiles with `forge build --zksync`, calls the Forge script, performs post-deploy `cast` checks, and invokes `ops/verify_zksync_contracts.py` for source-code verification on the zkSync explorer. + +Environment variables (prefixed `N_` per repo convention; consumed by the Forge script): + +| Variable | Description | +| :------------------- | :------------------------------------------------------------- | +| `N_FACTORY_ADMIN` | Multisig address that will hold `DEFAULT_ADMIN_ROLE` | +| `N_FACTORY_OPERATOR` | Backend service address that will hold `OPERATOR_ROLE` | + +Steps performed by the Forge script: + +1. Deploy `UserCollection721` implementation via `CREATE` (sequential nonce, **never** `CREATE2`). Constructor calls `_disableInitializers()`. Deploying via `CREATE` ensures that even if the Cancun `selfdestruct` semantics on zkSync Era are looser than on L1, the implementation address can never be re-occupied with different bytecode via salt collision (see §7.2 row 15). +2. Deploy `UserCollection1155` implementation. Same constraints (CREATE-only, `_disableInitializers()`). +3. Deploy `CollectionFactory` logic. +4. Deploy `ERC1967Proxy` pointing at `CollectionFactory`, with init data calling `initialize(N_FACTORY_ADMIN, N_FACTORY_OPERATOR, impl721, impl1155)`. Note: this `ERC1967Proxy(factoryImpl, ...)` is the *factory's own* proxy. The per-collection `ERC1967Proxy` instances are deployed by the factory itself at `createCollection*` time, not by this script. +5. Log all four addresses (implementation 721, implementation 1155, factory logic, factory proxy) in the same `: 0x...` format that the orchestration script greps for. + +Steps performed by the orchestration shell script (after the Forge script broadcasts): + +6. Sanity-check the deployment with `cast` (admin role granted, operator role granted, both implementation pointers set, UUPS implementation slot points at the factory logic). +7. Verify source code on the zkSync block explorer via `python3 ops/verify_zksync_contracts.py --broadcast broadcast/DeployCollectionFactoryZkSync.s.sol//run-latest.json --verifier-url $VERIFIER_URL --compiler-version 0.8.26 --zksolc-version v1.5.15 --project-root "$PROJECT_ROOT"`. Verifier URLs follow the swarms convention: + - **Mainnet**: `https://zksync2-mainnet-explorer.zksync.io/contract_verification` (explorer at `https://explorer.zksync.io`) + - **Testnet**: `https://explorer.sepolia.era.zksync.dev/contract_verification` (explorer at `https://sepolia.explorer.zksync.io`) +8. Append the deployed addresses to the appropriate `.env-test` / `.env-prod` file (same pattern as the swarms script's `update_env_file` step). +9. Add a usage example to `README.md` under the existing deployment section. + +> **Note on tooling.** The repo's `README.md` still mentions Etherscan as the verification target; that wording predates the zkSync-era flow. The actual operational pattern (used by `ops/deploy_swarm_contracts_zksync.sh`) is: do **not** use `forge script --verify` (it sends absolute paths the zkSync verifier rejects) and do **not** rely on `forge verify-contract` directly (it sends `../` traversal imports the zkSync verifier rejects). Use `ops/verify_zksync_contracts.py`, which generates standard JSON via Forge, rewrites relative imports to project-rooted paths, and submits to the zkSync verifier API. With `bytecode_hash = "none"` already set in `foundry.toml`, this achieves full verification. + +### 9.2 Indexing + +Subquery (`subquery/` package) extension required: + +- **Top-level source**: factory address. Handler on `CollectionCreated` writes a `Collection` entity and dynamically registers the new collection address as a `Transfer`-listening source (subquery dynamic-source pattern). +- **Per-collection handlers**: `Transfer` writes `Token` and `Owner` entities scoped by collection. Lock events (`MetadataLocked`, `RoyaltiesLocked`) update the corresponding `Collection` entity flags for buyer due-diligence. + +Indexer wiring is out of this repo's contract scope but is referenced here so the implementation plan can include a tracking task for the subquery package. + +### 9.3 Paymaster Integration + +The operator account uses the existing `BondTreasuryPaymaster` for L2 gas. The operator must be seeded with bond allowance before first creation; subsequent adjustments use the paymaster's standard admin path. No new paymaster contract is required for this feature. + +### 9.4 Upgrade & Rollback + +All three on-chain operations below are driven by the orchestration wrapper +`ops/upgrade_collection_factory_zksync.sh [--broadcast]` +(ACTION ∈ `UPGRADE_FACTORY` / `SET_IMPL_721` / `SET_IMPL_1155`). The wrapper +runs the `--zksync` compile (with the L1-file move/restore), the artifact gates +(factoryDeps for the factory; no-upgrade-selector for collection impls), a +**storage-layout reminder** (manual — see below; we do not commit static layout +baselines), an admin-key pre-check, the mainnet confirmation guard, and the +post-broadcast asserts (slot/role/pointer preservation) plus source verification. + +| Operation | Procedure | +| :----------------------------------- | :------------------------------------------------------------------------------------------------------------ | +| Upgrade factory logic | Run pre-upgrade checklist (below), then `upgrade_collection_factory_zksync.sh UPGRADE_FACTORY --broadcast` (admin key); wraps `upgradeToAndCall` (UUPS) | +| Ship a new ERC-721 template | `upgrade_collection_factory_zksync.sh SET_IMPL_721 --broadcast`; wraps `setImplementation721`; affects *future* collections only | +| Ship a new ERC-1155 template | `upgrade_collection_factory_zksync.sh SET_IMPL_1155 --broadcast`; wraps `setImplementation1155`; affects *future* collections only | +| Rotate operator key | Admin calls `revokeRole(OPERATOR_ROLE, oldKey)` then `grantRole(OPERATOR_ROLE, newKey)` | +| Pause new creations | Admin revokes all addresses from `OPERATOR_ROLE`. Existing creations unaffected; new requests revert | +| Rollback a faulty template | Admin calls `setImplementation*` pointing back to the previous implementation; affects future collections only| + +There is no rollback path for already-deployed collections — that is the explicit immutability guarantee. Bug fixes that require touching deployed collections must take the form of off-chain workarounds or new collections. + +#### Pre-Upgrade Checklist (factory only) + +CI does not currently diff storage layouts. Before any factory upgrade is broadcast, the engineer running the upgrade must execute the following manually, mirroring the procedure used by `src/swarms/` (see `src/swarms/doc/upgradeable-contracts.md`): + +1. **Verify storage compatibility:** + + ```bash + forge inspect CollectionFactory storageLayout > v1-layout.json + forge inspect CollectionFactoryV2 storageLayout > v2-layout.json + # Manually compare: ensure every V1 storage slot is preserved at the same slot index AND byte + # offset in V2 (byte offsets matter for sub-word fields packed into the same slot, e.g. the + # bool flags in per-collection storage; see §6.3). Only appended fields (consuming __gap slots) are + # acceptable. + ``` + + **No committed layout baselines.** We deliberately do not keep static + `*.v1.json` layout snapshots in the repo — they go stale silently and only + mirror what git already records. Instead, regenerate the previous layout from + the released ref at upgrade time and diff it against the new one: + + ```bash + git stash; git checkout + forge inspect CollectionFactory storageLayout --json > /tmp/old.json + git checkout -; git stash pop + forge inspect CollectionFactory storageLayout --json > /tmp/new.json + diff <(jq -S '.storage|map({label,slot,offset,type})' /tmp/old.json) \ + <(jq -S '.storage|map({label,slot,offset,type})' /tmp/new.json) + ``` + + Only appended fields (consuming `__gap`) are acceptable. The upgrade wrapper + prints this reminder; for stronger guarantees, wire up the OZ / zkSync + upgradable plugin's automated storage-layout validation. Same applies to + `UserCollection721` / `UserCollection1155` on a `setImplementation*` swap. + +2. **Run all tests:** + + ```bash + forge test --match-path "test/collections/**" + ``` + +3. **Test on a fork:** + + ```bash + forge script script/UpgradeCollectionFactory.s.sol \ + --fork-url $RPC_URL \ + --sender $ADMIN + ``` + +4. **Post-upgrade verification (after broadcast):** + + ```bash + # Implementation pointer changed + cast implementation $FACTORY_PROXY --rpc-url $RPC_URL + + # Admin role unchanged + cast call $FACTORY_PROXY "hasRole(bytes32,address)(bool)" \ + $(cast keccak "DEFAULT_ADMIN_ROLE") $ADMIN --rpc-url $RPC_URL + + # Stored implementation pointers unchanged (existing collections unaffected) + cast call $FACTORY_PROXY "erc721Implementation()(address)" --rpc-url $RPC_URL + cast call $FACTORY_PROXY "erc1155Implementation()(address)" --rpc-url $RPC_URL + ``` + + Per-collection EIP-1967 implementation slot check (run after each `createCollection*` to + confirm the proxy points at the expected implementation): + + ```bash + EIP1967_IMPL_SLOT=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc + cast storage "$COLLECTION_ADDR" "$EIP1967_IMPL_SLOT" --rpc-url "$L2_RPC" + # expected: padded address of UserCollection721 (or 1155) impl + ``` + +Promoting any of these checks into a CI job is tracked under §11. + +
+ +## 10. File Layout + +``` +src/collections/ + CollectionFactory.sol + UserCollection721.sol + UserCollection1155.sol + interfaces/ + ICollectionFactory.sol + IUserCollection721.sol + IUserCollection1155.sol + CollectionTypes.sol + doc/ + README.md + spec/ + user-collections-specification.md + design-and-implementation.md +test/collections/ + CollectionFactory.t.sol + UserCollection721.t.sol + UserCollection1155.t.sol + Collections.integration.t.sol + mocks/ + CollectionFactoryV2Mock.sol (test-only; UUPS upgrade-target fixture, see §8.1) + NonUUPSImplementationMock.sol (test-only; non-UUPS contract for proxiableUUID revert test) +script/ + DeployCollectionFactoryZkSync.s.sol + UpgradeCollectionFactory.s.sol +ops/ + deploy_collection_factory_zksync.sh (mirrors deploy_swarm_contracts_zksync.sh) + upgrade_collection_factory_zksync.sh (UPGRADE_FACTORY / SET_IMPL_721 / SET_IMPL_1155 wrapper; §9.4) + verify_zksync_contracts.py (source-code verification helper) +``` + +License header on every Solidity file: `// SPDX-License-Identifier: BSD-3-Clause-Clear`. + +Solidity pragma: `^0.8.26` (matches existing contracts). + +
+ +## 11. Open Considerations + +These are not blocking for v1; recorded for future iteration. + +| Item | Status | Notes | +| :----------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------- | +| Deterministic collection addresses | Done | `new ERC1967Proxy{salt: externalId}(...)` already gives deterministic per-collection addresses; backends pre-derive via `zksync-ethers` `utils.create2Address` (see §4.5) | +| Voucher-style non-custodial creation | Deferred | Adds an EIP-712 signed-voucher path for power users; can ship as a parallel `createCollectionWithVoucher` without breaking v1 | +| Per-creator on-chain rate limit | Deferred | Backend rate-limits today; can add `mapping(address => uint256) collectionsByCreator` and a configurable cap later | +| Soulbound / non-transferable variant | Deferred | Ship as a third implementation pointer; selected via a new `createCollectionSoulbound*` factory method | +| Per-token-URI mutability after lock | Deferred | If creators ever need to update individual token URIs after locking the collection, would require a `tokenLocked` map | +| CI storage-layout diff job | Deferred — required before factory upgrade | No upgrade has shipped yet, so there is nothing to gate against. Trigger to wire it: opening the PR for `CollectionFactoryV2`. The job (or the OZ/zkSync upgradable plugin's built-in validation) compares the new `forge inspect storageLayout` against the previous released ref — see the §9.4 reminder — and fails on any slot/offset mutation. | +| Multi-recipient ERC-1155 mint batch | Deferred | v1 keeps OZ's single-recipient `_mintBatch` shape (see §3.6). Trigger to add `mintBatchMulti`: airdrops or allowlist drops on the product roadmap. Ships as a non-breaking addition via a new implementation pointer (admin swap, future collections only) | diff --git a/src/collections/interfaces/CollectionTypes.sol b/src/collections/interfaces/CollectionTypes.sol new file mode 100644 index 00000000..8e093284 --- /dev/null +++ b/src/collections/interfaces/CollectionTypes.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +/** + * @title CollectionTypes + * @notice Shared enums and structs for the User Collections system. + * @dev Solidity interfaces cannot define enums, so shared types live here. + * Import this file alongside the collection interfaces. + */ + +/// @notice Token standard selected per-collection at creation time. +enum Standard { + ERC721, + ERC1155 +} + +/// @notice Parameters supplied by the operator when creating an ERC-721 collection. +/// @dev `additionalMinters` is orthogonal to the operator auto-grant: the calling +/// operator (`msg.sender` on the factory) is auto-granted `MINTER_ROLE` by the +/// collection's `initialize` regardless of this list. Use `additionalMinters` +/// for creator-seeded extras (e.g. a co-creator wallet). +struct CreateParams721 { + address owner; + string name; + string symbol; + string baseURI; + string contractURI; + address royaltyRecipient; + uint96 royaltyBps; + address[] additionalMinters; +} + +/// @notice Parameters supplied by the operator when creating an ERC-1155 collection. +/// @dev ERC-1155 has no on-chain `name`/`symbol` convention; the collection display +/// name lives in `contractURI` JSON metadata. +struct CreateParams1155 { + address owner; + string uri; + string contractURI; + address royaltyRecipient; + uint96 royaltyBps; + address[] additionalMinters; +} diff --git a/src/collections/interfaces/ICollectionFactory.sol b/src/collections/interfaces/ICollectionFactory.sol new file mode 100644 index 00000000..6e56194a --- /dev/null +++ b/src/collections/interfaces/ICollectionFactory.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Standard, CreateParams721, CreateParams1155} from "./CollectionTypes.sol"; + +/** + * @title ICollectionFactory + * @notice Public API for the operator-triggered NFT collection factory. + * @dev See `src/collections/doc/spec/user-collections-specification.md` for the + * full architectural specification. + */ +interface ICollectionFactory { + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + /// @notice Emitted when a new per-collection `ERC1967Proxy` is deployed and initialized. + /// @param creator The address that received `OWNER_ROLE` on the new collection. + /// @param collection The address of the newly-deployed per-collection proxy. + /// @param standard The token standard (ERC721 or ERC1155). + /// @param externalId The off-chain reconciliation identifier supplied by the operator. + event CollectionCreated( + address indexed creator, + address indexed collection, + Standard standard, + bytes32 indexed externalId + ); + + /// @notice Emitted when admin updates an implementation pointer for future per-collection proxies. + event ImplementationUpdated(Standard standard, address newImpl); + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── + + /// @notice Thrown when an `externalId` has already been consumed by a prior creation. + error ExternalIdAlreadyUsed(bytes32 externalId); + + /// @notice Thrown when `externalId == bytes32(0)`. + error InvalidExternalId(); + + /// @notice Thrown when a required address argument is the zero address. + error ZeroAddress(); + + /// @notice Thrown when an implementation argument has no contract bytecode. + error NotAContract(address impl); + + // ────────────────────────────────────────────── + // Creation + // ────────────────────────────────────────────── + + function createCollection721(CreateParams721 calldata p, bytes32 externalId) + external + returns (address collection); + + function createCollection1155(CreateParams1155 calldata p, bytes32 externalId) + external + returns (address collection); + + // ────────────────────────────────────────────── + // Admin + // ────────────────────────────────────────────── + + function setImplementation721(address impl) external; + + function setImplementation1155(address impl) external; + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + function collectionByExternalId(bytes32 externalId) external view returns (address); + + function erc721Implementation() external view returns (address); + + function erc1155Implementation() external view returns (address); +} diff --git a/src/collections/interfaces/IUserCollection1155.sol b/src/collections/interfaces/IUserCollection1155.sol new file mode 100644 index 00000000..d2567264 --- /dev/null +++ b/src/collections/interfaces/IUserCollection1155.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {CreateParams1155} from "./CollectionTypes.sol"; + +/** + * @title IUserCollection1155 + * @notice Public API for the ERC-1155 implementation deployed behind a per-collection `ERC1967Proxy`. + * @dev See `src/collections/doc/spec/user-collections-specification.md` (§3.6). + */ +interface IUserCollection1155 { + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + event MetadataLocked(); + event RoyaltiesLocked(); + event ContractURIUpdated(string newURI); + event URIUpdated(string newURI); + + /// @notice Emitted whenever the default royalty is set, updated, or cleared + /// via `setDefaultRoyalty`. ERC-2981 itself emits no event, so this + /// is the only on-chain signal indexers can use to track royalty + /// changes for buyer due-diligence. A `bps == 0` emission means the + /// royalty was cleared. + event DefaultRoyaltyUpdated(address recipient, uint96 bps); + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── + + error MetadataIsLocked(); + error RoyaltiesAreLocked(); + error BatchTooLarge(uint256 length, uint256 max); + error LengthMismatch(); + error ZeroAddress(); + + // ────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────── + + function initialize(CreateParams1155 calldata p, address operatorMinter) external; + + // ────────────────────────────────────────────── + // Minting + // ────────────────────────────────────────────── + + function mint(address to, uint256 id, uint256 amount, bytes calldata data) external; + + function mintBatch( + address to, + uint256[] calldata ids, + uint256[] calldata amounts, + bytes calldata data + ) external; + + // ────────────────────────────────────────────── + // Owner-mutable settings + // ────────────────────────────────────────────── + + function setURI(string calldata newURI) external; + + function setContractURI(string calldata newURI) external; + + function setDefaultRoyalty(address recipient, uint96 bps) external; + + function lockMetadata() external; + + function lockRoyalties() external; + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + function contractURI() external view returns (string memory); + + function metadataLocked() external view returns (bool); + + function royaltiesLocked() external view returns (bool); +} diff --git a/src/collections/interfaces/IUserCollection721.sol b/src/collections/interfaces/IUserCollection721.sol new file mode 100644 index 00000000..2a2bf103 --- /dev/null +++ b/src/collections/interfaces/IUserCollection721.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {CreateParams721} from "./CollectionTypes.sol"; + +/** + * @title IUserCollection721 + * @notice Public API for the ERC-721 implementation deployed behind a per-collection `ERC1967Proxy`. + * @dev See `src/collections/doc/spec/user-collections-specification.md` (§3.5). + */ +interface IUserCollection721 { + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + event MetadataLocked(); + event RoyaltiesLocked(); + event ContractURIUpdated(string newURI); + event BaseURIUpdated(string newBase); + + /// @notice Emitted whenever the default royalty is set, updated, or cleared + /// via `setDefaultRoyalty`. ERC-2981 itself emits no event, so this + /// is the only on-chain signal indexers can use to track royalty + /// changes for buyer due-diligence. A `bps == 0` emission means the + /// royalty was cleared. + event DefaultRoyaltyUpdated(address recipient, uint96 bps); + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── + + error MetadataIsLocked(); + error RoyaltiesAreLocked(); + error BatchTooLarge(uint256 length, uint256 max); + error LengthMismatch(); + error ZeroAddress(); + + // ────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────── + + function initialize(CreateParams721 calldata p, address operatorMinter) external; + + // ────────────────────────────────────────────── + // Minting + // ────────────────────────────────────────────── + + function mint(address to, string calldata tokenURI_) external returns (uint256 tokenId); + + function mintBatch(address[] calldata to, string[] calldata uris) + external + returns (uint256[] memory tokenIds); + + // ────────────────────────────────────────────── + // Owner-mutable settings + // ────────────────────────────────────────────── + + function setBaseURI(string calldata newBase) external; + + function setContractURI(string calldata newURI) external; + + function setDefaultRoyalty(address recipient, uint96 bps) external; + + function lockMetadata() external; + + function lockRoyalties() external; + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + function contractURI() external view returns (string memory); + + function nextTokenId() external view returns (uint256); + + function metadataLocked() external view returns (bool); + + function royaltiesLocked() external view returns (bool); +} diff --git a/test/collections/CollectionFactory.t.sol b/test/collections/CollectionFactory.t.sol new file mode 100644 index 00000000..74735fa1 --- /dev/null +++ b/test/collections/CollectionFactory.t.sol @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {CollectionFactory} from "../../src/collections/CollectionFactory.sol"; +import {ICollectionFactory} from "../../src/collections/interfaces/ICollectionFactory.sol"; +import {UserCollection721} from "../../src/collections/UserCollection721.sol"; +import {UserCollection1155} from "../../src/collections/UserCollection1155.sol"; +import {IUserCollection721} from "../../src/collections/interfaces/IUserCollection721.sol"; +import {IUserCollection1155} from "../../src/collections/interfaces/IUserCollection1155.sol"; +import {Standard, CreateParams721, CreateParams1155} from "../../src/collections/interfaces/CollectionTypes.sol"; + +import {CollectionFactoryV2Mock} from "./mocks/CollectionFactoryV2Mock.sol"; +import {NonUUPSImplementationMock} from "./mocks/NonUUPSImplementationMock.sol"; + +contract CollectionFactoryTest is Test { + CollectionFactory internal factory; + UserCollection721 internal impl721; + UserCollection1155 internal impl1155; + + address internal constant ADMIN = address(0xAD); + address internal constant OPERATOR = address(0x09); + address internal constant CREATOR = address(0xCAFE); + address internal constant STRANGER = address(0xDEAD); + address internal constant ALICE = address(0xA1); + + bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + bytes32 internal constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 internal constant DEFAULT_ADMIN_ROLE = 0x00; + + event CollectionCreated( + address indexed creator, address indexed collection, Standard standard, bytes32 indexed externalId + ); + event ImplementationUpdated(Standard standard, address newImpl); + event Upgraded(address indexed implementation); + event Initialized(uint64 version); + + function setUp() public { + impl721 = new UserCollection721(); + impl1155 = new UserCollection1155(); + + CollectionFactory logic = new CollectionFactory(); + bytes memory init = abi.encodeCall( + CollectionFactory.initialize, (ADMIN, OPERATOR, address(impl721), address(impl1155)) + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(logic), init); + factory = CollectionFactory(address(proxy)); + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + function _params721(address owner) internal pure returns (CreateParams721 memory) { + return CreateParams721({ + owner: owner, + name: "C", + symbol: "C", + baseURI: "ipfs://b/", + contractURI: "ipfs://c.json", + royaltyRecipient: owner, + royaltyBps: 500, + additionalMinters: new address[](0) + }); + } + + function _params1155(address owner) internal pure returns (CreateParams1155 memory) { + return CreateParams1155({ + owner: owner, + uri: "ipfs://1155/{id}.json", + contractURI: "ipfs://c.json", + royaltyRecipient: owner, + royaltyBps: 500, + additionalMinters: new address[](0) + }); + } + + // ────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────── + + function test_initialize_grantsRolesAndSetsImplementations() public view { + assertTrue(factory.hasRole(DEFAULT_ADMIN_ROLE, ADMIN)); + assertTrue(factory.hasRole(OPERATOR_ROLE, OPERATOR)); + assertEq(factory.erc721Implementation(), address(impl721)); + assertEq(factory.erc1155Implementation(), address(impl1155)); + } + + function test_initialize_revertsOnSecondCall() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + factory.initialize(ADMIN, OPERATOR, address(impl721), address(impl1155)); + } + + function test_initialize_revertsOnZeroAddresses() public { + CollectionFactory logic = new CollectionFactory(); + bytes memory bad = abi.encodeCall( + CollectionFactory.initialize, (address(0), OPERATOR, address(impl721), address(impl1155)) + ); + vm.expectRevert(); + new ERC1967Proxy(address(logic), bad); + } + + function test_initialize_revertsOnNonContractImpl() public { + CollectionFactory logic = new CollectionFactory(); + bytes memory bad = abi.encodeCall( + CollectionFactory.initialize, (ADMIN, OPERATOR, address(0xBEEF), address(impl1155)) + ); + vm.expectRevert(); + new ERC1967Proxy(address(logic), bad); + } + + function test_initialize_revertsOnZeroOperator() public { + CollectionFactory logic = new CollectionFactory(); + bytes memory bad = abi.encodeCall( + CollectionFactory.initialize, (ADMIN, address(0), address(impl721), address(impl1155)) + ); + vm.expectRevert(); + new ERC1967Proxy(address(logic), bad); + } + + function test_initialize_revertsOnZeroImpl1155() public { + CollectionFactory logic = new CollectionFactory(); + bytes memory bad = abi.encodeCall( + CollectionFactory.initialize, (ADMIN, OPERATOR, address(impl721), address(0)) + ); + vm.expectRevert(); + new ERC1967Proxy(address(logic), bad); + } + + function test_initialize_revertsOnNonContractImpl1155() public { + CollectionFactory logic = new CollectionFactory(); + bytes memory bad = abi.encodeCall( + CollectionFactory.initialize, (ADMIN, OPERATOR, address(impl721), address(0xBEEF)) + ); + vm.expectRevert(); + new ERC1967Proxy(address(logic), bad); + } + + // ────────────────────────────────────────────── + // Creation + // ────────────────────────────────────────────── + + function test_createCollection721_atomicAndEmits() public { + bytes32 externalId = keccak256("order-1"); + + // Order: Upgraded(impl) → Initialized(1) → ... role grants ... → CollectionCreated + vm.expectEmit(true, false, false, false); + emit Upgraded(address(impl721)); + + vm.expectEmit(false, false, false, true); + emit Initialized(1); + + // CollectionCreated indexed topics: (creator, collection, externalId). + // We don't know the collection address up front, so leave its topic unchecked. + vm.expectEmit(true, false, true, true); + emit CollectionCreated(CREATOR, address(0), Standard.ERC721, externalId); + + vm.prank(OPERATOR); + address collection = factory.createCollection721(_params721(CREATOR), externalId); + + assertEq(factory.collectionByExternalId(externalId), collection); + UserCollection721 c = UserCollection721(collection); + assertEq(c.name(), "C"); + assertEq(c.contractURI(), "ipfs://c.json"); + assertTrue(c.hasRole(keccak256("OWNER_ROLE"), CREATOR)); + // Operator auto-grant invariant — see §2.3. + assertTrue(c.hasRole(MINTER_ROLE, OPERATOR)); + } + + function test_createCollection721_addressMatchesCreate2Derivation() public { + bytes32 externalId = keccak256("derivation-test-721"); + CreateParams721 memory p = _params721(CREATOR); + + bytes memory initData = abi.encodeCall( + IUserCollection721.initialize, + (p, OPERATOR) + ); + + bytes32 initCodeHash = keccak256( + abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode(address(impl721), initData) + ) + ); + + address predicted = Create2.computeAddress( + externalId, + initCodeHash, + address(factory) + ); + + vm.prank(OPERATOR); + address actual = factory.createCollection721(p, externalId); + + assertEq(actual, predicted, "deployed address must match CREATE2 derivation"); + } + + function test_createCollection1155_addressMatchesCreate2Derivation() public { + bytes32 externalId = keccak256("derivation-test-1155"); + CreateParams1155 memory p = _params1155(CREATOR); + + bytes memory initData = abi.encodeCall( + IUserCollection1155.initialize, + (p, OPERATOR) + ); + + bytes32 initCodeHash = keccak256( + abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode(address(impl1155), initData) + ) + ); + + address predicted = Create2.computeAddress( + externalId, + initCodeHash, + address(factory) + ); + + vm.prank(OPERATOR); + address actual = factory.createCollection1155(p, externalId); + + assertEq(actual, predicted, "deployed 1155 address must match CREATE2 derivation"); + } + + function test_createCollection1155_atomicAndEmits() public { + bytes32 externalId = keccak256("order-1155"); + + // Order: Upgraded(impl) → Initialized(1) → ... role grants ... → CollectionCreated + vm.expectEmit(true, false, false, false); + emit Upgraded(address(impl1155)); + + vm.expectEmit(false, false, false, true); + emit Initialized(1); + + // CollectionCreated indexed topics: (creator, collection, externalId). + // We don't know the collection address up front, so leave its topic unchecked. + vm.expectEmit(true, false, true, true); + emit CollectionCreated(CREATOR, address(0), Standard.ERC1155, externalId); + + vm.prank(OPERATOR); + address collection = factory.createCollection1155(_params1155(CREATOR), externalId); + + assertEq(factory.collectionByExternalId(externalId), collection); + UserCollection1155 c = UserCollection1155(collection); + assertTrue(c.hasRole(keccak256("OWNER_ROLE"), CREATOR)); + assertTrue(c.hasRole(MINTER_ROLE, OPERATOR)); + } + + function test_createCollection_onlyOperator() public { + vm.prank(STRANGER); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, STRANGER, OPERATOR_ROLE) + ); + factory.createCollection721(_params721(CREATOR), keccak256("x")); + } + + function test_createCollection1155_onlyOperator() public { + vm.prank(STRANGER); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, STRANGER, OPERATOR_ROLE) + ); + factory.createCollection1155(_params1155(CREATOR), keccak256("x")); + } + + function test_createCollection_externalIdSharedAcrossStandards() public { + bytes32 externalId = keccak256("shared-namespace"); + vm.prank(OPERATOR); + factory.createCollection721(_params721(CREATOR), externalId); + + // `_collectionByExternalId` is a single namespace across both standards — + // a 721 id must collide when reused on the 1155 path. + vm.prank(OPERATOR); + vm.expectRevert(abi.encodeWithSelector(ICollectionFactory.ExternalIdAlreadyUsed.selector, externalId)); + factory.createCollection1155(_params1155(CREATOR), externalId); + } + + function test_createCollection_revertsZeroExternalId() public { + vm.prank(OPERATOR); + vm.expectRevert(ICollectionFactory.InvalidExternalId.selector); + factory.createCollection721(_params721(CREATOR), bytes32(0)); + } + + function test_createCollection_revertsReusedExternalId() public { + bytes32 externalId = keccak256("dup"); + vm.prank(OPERATOR); + factory.createCollection721(_params721(CREATOR), externalId); + + vm.prank(OPERATOR); + vm.expectRevert(abi.encodeWithSelector(ICollectionFactory.ExternalIdAlreadyUsed.selector, externalId)); + factory.createCollection721(_params721(CREATOR), externalId); + } + + function test_createCollection_operatorAutoGrantWithEmptyAdditionalMinters() public { + bytes32 externalId = keccak256("empty-minters"); + vm.prank(OPERATOR); + address collection = factory.createCollection721(_params721(CREATOR), externalId); + // additionalMinters is empty — operator must still be a minter via auto-grant. + assertTrue(UserCollection721(collection).hasRole(MINTER_ROLE, OPERATOR)); + } + + function test_createCollection721_canMintImmediatelyInSameTx() public { + bytes32 externalId = keccak256("immediate-mint-721"); + + vm.startPrank(OPERATOR); + address collection = factory.createCollection721(_params721(CREATOR), externalId); + // Operator was auto-granted MINTER_ROLE during constructor delegatecall — + // can mint without any extra setup transactions. + uint256 tokenId = UserCollection721(collection).mint(ALICE, "ipfs://token-0.json"); + vm.stopPrank(); + + assertEq(UserCollection721(collection).ownerOf(tokenId), ALICE); + } + + // ────────────────────────────────────────────── + // setImplementation* + // ────────────────────────────────────────────── + + function test_setImplementation_onlyAdmin() public { + UserCollection721 newImpl = new UserCollection721(); + vm.prank(STRANGER); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, STRANGER, DEFAULT_ADMIN_ROLE) + ); + factory.setImplementation721(address(newImpl)); + } + + function test_setImplementation_revertsZeroAddress() public { + vm.prank(ADMIN); + vm.expectRevert(ICollectionFactory.ZeroAddress.selector); + factory.setImplementation721(address(0)); + } + + function test_setImplementation_revertsNonContract() public { + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(ICollectionFactory.NotAContract.selector, address(0xBEEF))); + factory.setImplementation721(address(0xBEEF)); + } + + function test_setImplementation1155_updatesPointerAndEmits() public { + UserCollection1155 newImpl = new UserCollection1155(); + vm.expectEmit(true, true, true, true); + emit ImplementationUpdated(Standard.ERC1155, address(newImpl)); + vm.prank(ADMIN); + factory.setImplementation1155(address(newImpl)); + assertEq(factory.erc1155Implementation(), address(newImpl)); + } + + function test_setImplementation1155_revertsZeroAndNonContract() public { + vm.prank(ADMIN); + vm.expectRevert(ICollectionFactory.ZeroAddress.selector); + factory.setImplementation1155(address(0)); + + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(ICollectionFactory.NotAContract.selector, address(0xBEEF))); + factory.setImplementation1155(address(0xBEEF)); + } + + function test_setImplementation_affectsFutureCollectionsOnly() public { + bytes32 firstId = keccak256("first"); + vm.prank(OPERATOR); + address oldCollection = factory.createCollection721(_params721(CREATOR), firstId); + bytes32 oldHash = oldCollection.codehash; + + UserCollection721 newImpl = new UserCollection721(); + vm.expectEmit(true, true, true, true); + emit ImplementationUpdated(Standard.ERC721, address(newImpl)); + vm.prank(ADMIN); + factory.setImplementation721(address(newImpl)); + + bytes32 secondId = keccak256("second"); + vm.prank(OPERATOR); + address newCollection = factory.createCollection721(_params721(CREATOR), secondId); + + // Old collection unchanged; new collection points at the new implementation + // via its ERC1967 proxy. Verify by reading the factory's stored pointer + // post-set. + assertEq(oldCollection.codehash, oldHash); + assertEq(factory.erc721Implementation(), address(newImpl)); + assertTrue(newCollection != oldCollection); + } + + // ────────────────────────────────────────────── + // UUPS upgrade — §8.1 four assertions + // ────────────────────────────────────────────── + + bytes32 internal constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + function test_uups_adminUpgradeChangesImplementationSlot() public { + CollectionFactoryV2Mock v2Logic = new CollectionFactoryV2Mock(); + address pre = address(uint160(uint256(vm.load(address(factory), IMPL_SLOT)))); + assertTrue(pre != address(v2Logic)); + + vm.prank(ADMIN); + factory.upgradeToAndCall(address(v2Logic), ""); + + address post = address(uint160(uint256(vm.load(address(factory), IMPL_SLOT)))); + assertEq(post, address(v2Logic)); + } + + function test_uups_revertsForOperatorOnly() public { + CollectionFactoryV2Mock v2Logic = new CollectionFactoryV2Mock(); + // OPERATOR holds OPERATOR_ROLE but NOT DEFAULT_ADMIN_ROLE — must not escalate. + vm.prank(OPERATOR); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, OPERATOR, DEFAULT_ADMIN_ROLE) + ); + factory.upgradeToAndCall(address(v2Logic), ""); + } + + function test_uups_revertsForFreshEoa() public { + CollectionFactoryV2Mock v2Logic = new CollectionFactoryV2Mock(); + vm.prank(STRANGER); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, STRANGER, DEFAULT_ADMIN_ROLE) + ); + factory.upgradeToAndCall(address(v2Logic), ""); + } + + function test_uups_preservesStorageThroughUpgrade() public { + // Seed pre-upgrade state. + bytes32 externalId = keccak256("pre-upgrade"); + vm.prank(OPERATOR); + address seededCollection = factory.createCollection721(_params721(CREATOR), externalId); + + CollectionFactoryV2Mock v2Logic = new CollectionFactoryV2Mock(); + vm.prank(ADMIN); + factory.upgradeToAndCall(address(v2Logic), ""); + + // Roles preserved. + assertTrue(factory.hasRole(DEFAULT_ADMIN_ROLE, ADMIN)); + assertTrue(factory.hasRole(OPERATOR_ROLE, OPERATOR)); + // Implementation pointers preserved. + assertEq(factory.erc721Implementation(), address(impl721)); + assertEq(factory.erc1155Implementation(), address(impl1155)); + // Pre-upgrade collection mapping preserved. + assertEq(factory.collectionByExternalId(externalId), seededCollection); + // V2-only function callable on the upgraded proxy — proves real delegation. + assertEq(CollectionFactoryV2Mock(address(factory)).v2Sentinel(), 4242); + } + + function test_uups_revertsOnNonUUPSImplementation() public { + NonUUPSImplementationMock nonUups = new NonUUPSImplementationMock(); + vm.prank(ADMIN); + // OZ wraps the failed proxiableUUID call in ERC1967InvalidImplementation. + vm.expectRevert( + abi.encodeWithSelector(ERC1967Utils.ERC1967InvalidImplementation.selector, address(nonUups)) + ); + factory.upgradeToAndCall(address(nonUups), ""); + } +} diff --git a/test/collections/Collections.integration.t.sol b/test/collections/Collections.integration.t.sol new file mode 100644 index 00000000..e40383e2 --- /dev/null +++ b/test/collections/Collections.integration.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {CollectionFactory} from "../../src/collections/CollectionFactory.sol"; +import {UserCollection721} from "../../src/collections/UserCollection721.sol"; +import {UserCollection1155} from "../../src/collections/UserCollection1155.sol"; +import {IUserCollection721} from "../../src/collections/interfaces/IUserCollection721.sol"; +import {IUserCollection1155} from "../../src/collections/interfaces/IUserCollection1155.sol"; +import {Standard, CreateParams721, CreateParams1155} from "../../src/collections/interfaces/CollectionTypes.sol"; + +import {CollectionFactoryV2Mock} from "./mocks/CollectionFactoryV2Mock.sol"; + +/** + * @title Collections.integration.t.sol + * @notice End-to-end happy-path scenario from spec §8.4. + */ +contract CollectionsIntegrationTest is Test { + CollectionFactory internal factory; + UserCollection721 internal impl721; + UserCollection1155 internal impl1155; + + address internal constant ADMIN = address(0xAD); + address internal constant OPERATOR = address(0x09); + address internal constant CREATOR_ALPHA = address(0xA1); + address internal constant CREATOR_BETA = address(0xB1); + address internal constant BUYER_1 = address(0xB1A1); + address internal constant BUYER_2 = address(0xB1A2); + address internal constant THIRD_PARTY = address(0xC1); + + bytes32 internal constant OWNER_ROLE = keccak256("OWNER_ROLE"); + + function setUp() public { + impl721 = new UserCollection721(); + impl1155 = new UserCollection1155(); + CollectionFactory logic = new CollectionFactory(); + bytes memory init = abi.encodeCall( + CollectionFactory.initialize, (ADMIN, OPERATOR, address(impl721), address(impl1155)) + ); + factory = CollectionFactory(address(new ERC1967Proxy(address(logic), init))); + } + + function test_endToEnd_happyPath() public { + // 1. Operator creates an ERC-721 collection for creator α. + vm.prank(OPERATOR); + address c721 = factory.createCollection721( + CreateParams721({ + owner: CREATOR_ALPHA, + name: "Alpha", + symbol: "ALP", + baseURI: "ipfs://alpha/", + contractURI: "ipfs://alpha-contract.json", + royaltyRecipient: CREATOR_ALPHA, + royaltyBps: 500, + additionalMinters: new address[](0) + }), + keccak256("order-alpha") + ); + UserCollection721 col721 = UserCollection721(c721); + assertTrue(col721.hasRole(OWNER_ROLE, CREATOR_ALPHA)); + + // 2. Operator creates an ERC-1155 collection for creator β. + vm.prank(OPERATOR); + address c1155 = factory.createCollection1155( + CreateParams1155({ + owner: CREATOR_BETA, + uri: "ipfs://beta/{id}.json", + contractURI: "ipfs://beta-contract.json", + royaltyRecipient: CREATOR_BETA, + royaltyBps: 250, + additionalMinters: new address[](0) + }), + keccak256("order-beta") + ); + UserCollection1155 col1155 = UserCollection1155(c1155); + assertTrue(col1155.hasRole(OWNER_ROLE, CREATOR_BETA)); + + // 3. Operator mints into both on behalf of fiat buyers. + vm.prank(OPERATOR); + uint256 alphaTokenId = col721.mint(BUYER_1, "1.json"); + assertEq(col721.ownerOf(alphaTokenId), BUYER_1); + + vm.prank(OPERATOR); + col1155.mint(BUYER_2, 7, 3, ""); + assertEq(col1155.balanceOf(BUYER_2, 7), 3); + + // 4. Creator α transfers an item to a third party. + vm.prank(BUYER_1); + col721.transferFrom(BUYER_1, THIRD_PARTY, alphaTokenId); + assertEq(col721.ownerOf(alphaTokenId), THIRD_PARTY); + + // 5. Creator α locks metadata and royalties. + vm.prank(CREATOR_ALPHA); + col721.lockMetadata(); + vm.prank(CREATOR_ALPHA); + col721.lockRoyalties(); + assertTrue(col721.metadataLocked()); + assertTrue(col721.royaltiesLocked()); + + // 6. Subsequent setter calls revert. + vm.prank(CREATOR_ALPHA); + vm.expectRevert(IUserCollection721.MetadataIsLocked.selector); + col721.setBaseURI("ipfs://changed/"); + + vm.prank(CREATOR_ALPHA); + vm.expectRevert(IUserCollection721.RoyaltiesAreLocked.selector); + col721.setDefaultRoyalty(CREATOR_ALPHA, 100); + + // 7. Admin upgrades the factory and ships a new ERC-721 implementation. + CollectionFactoryV2Mock v2Logic = new CollectionFactoryV2Mock(); + vm.prank(ADMIN); + factory.upgradeToAndCall(address(v2Logic), ""); + assertEq(CollectionFactoryV2Mock(address(factory)).v2Sentinel(), 4242); + + UserCollection721 newImpl721 = new UserCollection721(); + vm.prank(ADMIN); + factory.setImplementation721(address(newImpl721)); + + // 8. New ERC-721 collection deploys with new implementation; old + // collection remains on the previous implementation. + vm.prank(OPERATOR); + address c721b = factory.createCollection721( + CreateParams721({ + owner: CREATOR_ALPHA, + name: "Alpha2", + symbol: "ALP2", + baseURI: "ipfs://alpha2/", + contractURI: "ipfs://alpha2-contract.json", + royaltyRecipient: CREATOR_ALPHA, + royaltyBps: 500, + additionalMinters: new address[](0) + }), + keccak256("order-alpha-v2") + ); + // Each per-collection ERC1967Proxy delegates to the factory's + // `_erc721Implementation` / `_erc1155Implementation` via the EIP-1967 + // implementation slot, captured at deploy time. The factory pointer + // is the observable state that proves the upgrade took effect for + // newly deployed collections; existing collections keep delegating + // to whichever implementation address was written into their slot + // when they were created. + assertEq(factory.erc721Implementation(), address(newImpl721)); + // Old collection still operates normally. + assertEq(col721.ownerOf(alphaTokenId), THIRD_PARTY); + // New collection initialized correctly under new implementation. + assertTrue(UserCollection721(c721b).hasRole(OWNER_ROLE, CREATOR_ALPHA)); + } +} diff --git a/test/collections/ERC1967Proxy.permanence.t.sol b/test/collections/ERC1967Proxy.permanence.t.sol new file mode 100644 index 00000000..3f0bf509 --- /dev/null +++ b/test/collections/ERC1967Proxy.permanence.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +/// @notice Bytecode-permanence proof for canonical OZ ERC1967Proxy. +/// Codifies design §3.5.2 (1): no SELFDESTRUCT, no caller-controlled +/// delegatecall. Defense-in-depth audit gate. +contract ERC1967ProxyPermanenceTest is Test { + /// @dev Deploy a real ERC1967Proxy and read its runtime bytecode. + /// Empty initData skips the constructor delegatecall — we just want + /// the deployed runtime, not a working instance. + function _runtime() internal returns (bytes memory) { + // Use any non-zero implementation; the runtime is the same regardless. + ERC1967Proxy p = new ERC1967Proxy(address(this), ""); + return address(p).code; + } + + function test_runtimeContainsNoSelfdestruct() public { + bytes memory code = _runtime(); + require(code.length > 0, "no runtime"); + + for (uint256 i = 0; i < code.length; ) { + uint8 op = uint8(code[i]); + + // PUSH1..PUSH32 — skip the immediate bytes (op 0x60..0x7f). + if (op >= 0x60 && op <= 0x7f) { + uint256 imm = uint256(op) - 0x5f; + i += 1 + imm; + continue; + } + + // SELFDESTRUCT (0xff) is the EVM mnemonic; canonical OZ + // ERC1967Proxy must not contain it. + assertTrue(op != 0xff, "ERC1967Proxy contains SELFDESTRUCT"); + + i += 1; + } + } + + function test_proxyImplementationDelegatecallTargetIsConstructorFixed() public { + // The only delegatecall in ERC1967Proxy's runtime targets _implementation() + // which reads from the EIP-1967 slot. The slot is written exclusively by + // ERC1967Utils.upgradeToAndCall (called only from the proxy's own + // constructor since the impl does not inherit UUPSUpgradeable). This test + // exercises the property by deploying with one impl and asserting the + // EIP-1967 slot equals that impl, then asserting that no external call + // can change it. + address impl = address(this); + ERC1967Proxy p = new ERC1967Proxy(impl, ""); + + bytes32 IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + bytes32 stored = vm.load(address(p), IMPL_SLOT); + assertEq(address(uint160(uint256(stored))), impl, "EIP-1967 slot mismatch"); + + // No external selector exposed by the proxy can write IMPL_SLOT — the + // proxy's only entry point is the fallback, which delegatecalls the + // current impl. Since `address(this)` (the test contract) has no + // upgradeToAndCall selector, any call to mutate the slot reverts/no-ops. + // We assert by replaying upgradeToAndCall through the proxy and showing + // the slot is unchanged. + bytes memory ignored = abi.encodeWithSelector( + 0x4f1ef286, address(0xdeadbeef), bytes("") + ); + // staticcall to avoid mutating; the call should not return data that + // reflects a successful upgrade. + (bool ok, ) = address(p).staticcall(ignored); + // Whether `ok` is true or false depends on the test contract's fallback; + // either way the slot must not have changed. + ok; // silence unused warning + bytes32 storedAfter = vm.load(address(p), IMPL_SLOT); + assertEq(stored, storedAfter, "EIP-1967 slot was mutated"); + } +} diff --git a/test/collections/UserCollection1155.t.sol b/test/collections/UserCollection1155.t.sol new file mode 100644 index 00000000..513ffac5 --- /dev/null +++ b/test/collections/UserCollection1155.t.sol @@ -0,0 +1,407 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import {IERC1155MetadataURI} from "@openzeppelin/contracts/token/ERC1155/extensions/IERC1155MetadataURI.sol"; +import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {UserCollection1155} from "../../src/collections/UserCollection1155.sol"; +import {IUserCollection1155} from "../../src/collections/interfaces/IUserCollection1155.sol"; +import {CreateParams1155} from "../../src/collections/interfaces/CollectionTypes.sol"; + +contract UserCollection1155Test is Test { + UserCollection1155 internal impl; + + address internal constant OWNER = address(0xA11CE); + address internal constant OPERATOR_MINTER = address(0xB0B); + address internal constant ROYALTY_RECIPIENT = address(0xCAFE); + address internal constant ALICE = address(0xA1); + address internal constant STRANGER = address(0xDEAD); + + bytes32 internal constant OWNER_ROLE = keccak256("OWNER_ROLE"); + bytes32 internal constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + event TransferSingle( + address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value + ); + event TransferBatch( + address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values + ); + event MetadataLocked(); + event RoyaltiesLocked(); + event ContractURIUpdated(string newURI); + event URIUpdated(string newURI); + event DefaultRoyaltyUpdated(address recipient, uint96 bps); + + function setUp() public { + impl = new UserCollection1155(); + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + function _deployClone(uint96 royaltyBps, address[] memory additionalMinters) + internal + returns (UserCollection1155 clone) + { + address cloneAddr = address(new ERC1967Proxy(address(impl), "")); + clone = UserCollection1155(cloneAddr); + clone.initialize( + CreateParams1155({ + owner: OWNER, + uri: "ipfs://1155/{id}.json", + contractURI: "ipfs://contract.json", + royaltyRecipient: ROYALTY_RECIPIENT, + royaltyBps: royaltyBps, + additionalMinters: additionalMinters + }), + OPERATOR_MINTER + ); + } + + function _deployCloneDefault() internal returns (UserCollection1155) { + address[] memory empty = new address[](0); + return _deployClone(500, empty); + } + + // ────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────── + + function test_initialize_setsAllFieldsAndRoles() public { + address[] memory extras = new address[](1); + extras[0] = ALICE; + UserCollection1155 clone = _deployClone(750, extras); + + assertEq(clone.uri(0), "ipfs://1155/{id}.json"); + assertEq(clone.contractURI(), "ipfs://contract.json"); + assertFalse(clone.metadataLocked()); + assertFalse(clone.royaltiesLocked()); + + assertTrue(clone.hasRole(OWNER_ROLE, OWNER)); + assertTrue(clone.hasRole(MINTER_ROLE, OWNER)); + assertTrue(clone.hasRole(MINTER_ROLE, OPERATOR_MINTER)); + assertTrue(clone.hasRole(MINTER_ROLE, ALICE)); + assertEq(clone.getRoleAdmin(MINTER_ROLE), OWNER_ROLE); + + (address recv, uint256 amount) = clone.royaltyInfo(0, 10_000); + assertEq(recv, ROYALTY_RECIPIENT); + assertEq(amount, 750); + } + + function test_initialize_revertsOnZeroOwner() public { + address cloneAddr = address(new ERC1967Proxy(address(impl), "")); + address[] memory empty = new address[](0); + vm.expectRevert(IUserCollection1155.ZeroAddress.selector); + UserCollection1155(cloneAddr).initialize( + CreateParams1155({ + owner: address(0), + uri: "", + contractURI: "", + royaltyRecipient: address(0), + royaltyBps: 0, + additionalMinters: empty + }), + OPERATOR_MINTER + ); + } + + function test_initialize_revertsOnZeroOperatorMinter() public { + address cloneAddr = address(new ERC1967Proxy(address(impl), "")); + address[] memory empty = new address[](0); + vm.expectRevert(IUserCollection1155.ZeroAddress.selector); + UserCollection1155(cloneAddr).initialize( + CreateParams1155({ + owner: OWNER, + uri: "", + contractURI: "", + royaltyRecipient: address(0), + royaltyBps: 0, + additionalMinters: empty + }), + address(0) + ); + } + + function test_implementation_disablesInitializers() public { + address[] memory empty = new address[](0); + vm.expectRevert(Initializable.InvalidInitialization.selector); + impl.initialize( + CreateParams1155({ + owner: OWNER, + uri: "", + contractURI: "", + royaltyRecipient: address(0), + royaltyBps: 0, + additionalMinters: empty + }), + OPERATOR_MINTER + ); + } + + // ────────────────────────────────────────────── + // Mint + // ────────────────────────────────────────────── + + function test_mint_assignsBalanceAndEmits() public { + UserCollection1155 clone = _deployCloneDefault(); + + vm.expectEmit(true, true, true, true); + emit TransferSingle(OPERATOR_MINTER, address(0), ALICE, 42, 5); + + vm.prank(OPERATOR_MINTER); + clone.mint(ALICE, 42, 5, ""); + assertEq(clone.balanceOf(ALICE, 42), 5); + assertEq(clone.totalSupply(42), 5); + } + + function test_mint_revertsForNonMinter() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, STRANGER, MINTER_ROLE) + ); + vm.prank(STRANGER); + clone.mint(ALICE, 0, 1, ""); + } + + function test_mintBatch_singleRecipientUpdatesBalances() public { + UserCollection1155 clone = _deployCloneDefault(); + uint256[] memory ids = new uint256[](3); + uint256[] memory amounts = new uint256[](3); + ids[0] = 1; ids[1] = 2; ids[2] = 3; + amounts[0] = 10; amounts[1] = 20; amounts[2] = 30; + + vm.expectEmit(true, true, true, true); + emit TransferBatch(OPERATOR_MINTER, address(0), ALICE, ids, amounts); + + vm.prank(OPERATOR_MINTER); + clone.mintBatch(ALICE, ids, amounts, ""); + + assertEq(clone.balanceOf(ALICE, 1), 10); + assertEq(clone.balanceOf(ALICE, 2), 20); + assertEq(clone.balanceOf(ALICE, 3), 30); + } + + function test_mintBatch_revertsLengthMismatch() public { + UserCollection1155 clone = _deployCloneDefault(); + uint256[] memory ids = new uint256[](2); + uint256[] memory amounts = new uint256[](1); + ids[0] = 1; ids[1] = 2; amounts[0] = 1; + vm.expectRevert(IUserCollection1155.LengthMismatch.selector); + vm.prank(OPERATOR_MINTER); + clone.mintBatch(ALICE, ids, amounts, ""); + } + + function test_mintBatch_revertsOversize() public { + UserCollection1155 clone = _deployCloneDefault(); + uint256[] memory ids = new uint256[](101); + uint256[] memory amounts = new uint256[](101); + for (uint256 i = 0; i < 101; ++i) { ids[i] = i; amounts[i] = 1; } + vm.expectRevert(abi.encodeWithSelector(IUserCollection1155.BatchTooLarge.selector, 101, 100)); + vm.prank(OPERATOR_MINTER); + clone.mintBatch(ALICE, ids, amounts, ""); + } + + function test_mintBatch_atMaxBatchSucceeds() public { + // Boundary: exactly MAX_BATCH (100) must succeed — the oversize test + // covers 101, this pins the inclusive upper bound. + UserCollection1155 clone = _deployCloneDefault(); + uint256[] memory ids = new uint256[](100); + uint256[] memory amounts = new uint256[](100); + for (uint256 i = 0; i < 100; ++i) { ids[i] = i; amounts[i] = 2; } + + vm.prank(OPERATOR_MINTER); + clone.mintBatch(ALICE, ids, amounts, ""); + + assertEq(clone.balanceOf(ALICE, 99), 2); + assertEq(clone.totalSupply(99), 2); + } + + function test_mintBatch_emptyIsNoOp() public { + UserCollection1155 clone = _deployCloneDefault(); + uint256[] memory ids = new uint256[](0); + uint256[] memory amounts = new uint256[](0); + + vm.prank(OPERATOR_MINTER); + clone.mintBatch(ALICE, ids, amounts, ""); + + assertEq(clone.balanceOf(ALICE, 0), 0); + } + + // ────────────────────────────────────────────── + // Owner-mutable settings + locks + // ────────────────────────────────────────────── + + function test_setURI_updatesAndEmits() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit URIUpdated("ipfs://new/{id}.json"); + vm.prank(OWNER); + clone.setURI("ipfs://new/{id}.json"); + assertEq(clone.uri(123), "ipfs://new/{id}.json"); + } + + function test_lockMetadata_blocksSubsequentSetters() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit MetadataLocked(); + vm.prank(OWNER); + clone.lockMetadata(); + + vm.prank(OWNER); + vm.expectRevert(IUserCollection1155.MetadataIsLocked.selector); + clone.setURI("x"); + + vm.prank(OWNER); + vm.expectRevert(IUserCollection1155.MetadataIsLocked.selector); + clone.setContractURI("x"); + } + + function test_lockRoyalties_blocksSubsequentSetters() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit RoyaltiesLocked(); + vm.prank(OWNER); + clone.lockRoyalties(); + + vm.prank(OWNER); + vm.expectRevert(IUserCollection1155.RoyaltiesAreLocked.selector); + clone.setDefaultRoyalty(ALICE, 100); + } + + function test_setContractURI_emitsAndUpdates() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit ContractURIUpdated("ipfs://newcontract.json"); + vm.prank(OWNER); + clone.setContractURI("ipfs://newcontract.json"); + assertEq(clone.contractURI(), "ipfs://newcontract.json"); + } + + function test_setDefaultRoyalty_zeroBpsClears() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit DefaultRoyaltyUpdated(address(0), 0); + vm.prank(OWNER); + clone.setDefaultRoyalty(address(0), 0); + (address recv, uint256 amount) = clone.royaltyInfo(0, 10_000); + assertEq(recv, address(0)); + assertEq(amount, 0); + } + + function test_setDefaultRoyalty_nonZeroBpsUpdates() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit DefaultRoyaltyUpdated(ALICE, 1000); + vm.prank(OWNER); + clone.setDefaultRoyalty(ALICE, 1000); + (address recv, uint256 amount) = clone.royaltyInfo(0, 10_000); + assertEq(recv, ALICE); + assertEq(amount, 1000); + } + + function test_owner_canRevokeOperatorMinter() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.prank(OWNER); + clone.revokeRole(MINTER_ROLE, OPERATOR_MINTER); + + vm.prank(OPERATOR_MINTER); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, OPERATOR_MINTER, MINTER_ROLE + ) + ); + clone.mint(ALICE, 1, 1, ""); + } + + // ────────────────────────────────────────────── + // ERC-2981 + supportsInterface + // ────────────────────────────────────────────── + + function test_supportsInterface_advertisesAllExpectedIds() public { + UserCollection1155 clone = _deployCloneDefault(); + assertTrue(clone.supportsInterface(type(IERC165).interfaceId)); + assertTrue(clone.supportsInterface(type(IERC1155).interfaceId)); + assertTrue(clone.supportsInterface(type(IERC1155MetadataURI).interfaceId)); + assertTrue(clone.supportsInterface(type(IERC2981).interfaceId)); + assertTrue(clone.supportsInterface(type(IAccessControl).interfaceId)); + } + + // ────────────────────────────────────────────── + // Burn + supply (ERC1155Burnable + ERC1155Supply) + // ────────────────────────────────────────────── + + function test_burn_decrementsSupplyAndBalance() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.prank(OPERATOR_MINTER); + clone.mint(ALICE, 1, 5, ""); + assertEq(clone.totalSupply(1), 5); + + vm.prank(ALICE); + clone.burn(ALICE, 1, 2); + assertEq(clone.balanceOf(ALICE, 1), 3); + assertEq(clone.totalSupply(1), 3); + } + + function test_burn_revertsForUnauthorized() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.prank(OPERATOR_MINTER); + clone.mint(ALICE, 1, 5, ""); + + vm.prank(STRANGER); + vm.expectRevert(); + clone.burn(ALICE, 1, 1); + } + + function test_supply_tracksAcrossMintAndMintBatch() public { + UserCollection1155 clone = _deployCloneDefault(); + vm.prank(OPERATOR_MINTER); + clone.mint(ALICE, 1, 10, ""); + + uint256[] memory ids = new uint256[](2); + uint256[] memory amounts = new uint256[](2); + ids[0] = 1; ids[1] = 2; + amounts[0] = 5; amounts[1] = 7; + vm.prank(OPERATOR_MINTER); + clone.mintBatch(ALICE, ids, amounts, ""); + + assertEq(clone.totalSupply(1), 15); + assertEq(clone.totalSupply(2), 7); + } + + // ────────────────────────────────────────────── + // Bytecode permanence (§7.2 row 15, §8.3) + // ────────────────────────────────────────────── + + function test_implementation_runtimeCode_containsNoSelfdestruct() public view { + bytes memory code = address(impl).code; + uint256 i = 0; + while (i < code.length) { + uint8 op = uint8(code[i]); + assertTrue(op != 0xff, "SELFDESTRUCT opcode at runtime position"); + if (op >= 0x60 && op <= 0x7f) { + i += 1 + (op - 0x5f); + } else { + i += 1; + } + } + } + + function test_implementationHasNoUpgradeSelectors() public view { + // proxiableUUID() — selector 0x52d1902d + (bool ok1, ) = address(impl).staticcall(abi.encodeWithSelector(0x52d1902d)); + assertFalse(ok1, "impl must not expose proxiableUUID"); + + // upgradeToAndCall(address,bytes) — selector 0x4f1ef286 + (bool ok2, ) = address(impl).staticcall( + abi.encodeWithSelector(0x4f1ef286, address(0), bytes("")) + ); + assertFalse(ok2, "impl must not expose upgradeToAndCall"); + } +} diff --git a/test/collections/UserCollection721.t.sol b/test/collections/UserCollection721.t.sol new file mode 100644 index 00000000..d5c9cac2 --- /dev/null +++ b/test/collections/UserCollection721.t.sol @@ -0,0 +1,564 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC721Metadata} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {UserCollection721} from "../../src/collections/UserCollection721.sol"; +import {IUserCollection721} from "../../src/collections/interfaces/IUserCollection721.sol"; +import {CreateParams721} from "../../src/collections/interfaces/CollectionTypes.sol"; +import {ReentrantERC721Receiver, IMintable721} from "./mocks/ReentrantERC721Receiver.sol"; + +contract UserCollection721Test is Test { + UserCollection721 internal impl; + + address internal constant OWNER = address(0xA11CE); + address internal constant OPERATOR_MINTER = address(0xB0B); + address internal constant ROYALTY_RECIPIENT = address(0xCAFE); + address internal constant ALICE = address(0xA1); + address internal constant BOB = address(0xB2); + address internal constant STRANGER = address(0xDEAD); + + bytes32 internal constant OWNER_ROLE = keccak256("OWNER_ROLE"); + bytes32 internal constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event MetadataLocked(); + event RoyaltiesLocked(); + event ContractURIUpdated(string newURI); + event BaseURIUpdated(string newBase); + event DefaultRoyaltyUpdated(address recipient, uint96 bps); + + function setUp() public { + impl = new UserCollection721(); + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + function _deployClone(uint96 royaltyBps, address[] memory additionalMinters) + internal + returns (UserCollection721 clone) + { + address cloneAddr = address(new ERC1967Proxy(address(impl), "")); + clone = UserCollection721(cloneAddr); + clone.initialize( + CreateParams721({ + owner: OWNER, + name: "Test Collection", + symbol: "TC", + baseURI: "ipfs://base/", + contractURI: "ipfs://contract.json", + royaltyRecipient: ROYALTY_RECIPIENT, + royaltyBps: royaltyBps, + additionalMinters: additionalMinters + }), + OPERATOR_MINTER + ); + } + + function _deployCloneDefault() internal returns (UserCollection721) { + address[] memory empty = new address[](0); + return _deployClone(500, empty); + } + + // ────────────────────────────────────────────── + // Initialization + // ────────────────────────────────────────────── + + function test_initialize_setsAllFieldsAndRoles() public { + address[] memory extras = new address[](1); + extras[0] = ALICE; + UserCollection721 clone = _deployClone(750, extras); + + assertEq(clone.name(), "Test Collection"); + assertEq(clone.symbol(), "TC"); + assertEq(clone.contractURI(), "ipfs://contract.json"); + assertEq(clone.nextTokenId(), 0); + assertFalse(clone.metadataLocked()); + assertFalse(clone.royaltiesLocked()); + + assertTrue(clone.hasRole(OWNER_ROLE, OWNER)); + assertTrue(clone.hasRole(MINTER_ROLE, OWNER)); + assertTrue(clone.hasRole(MINTER_ROLE, OPERATOR_MINTER)); + assertTrue(clone.hasRole(MINTER_ROLE, ALICE)); + + assertEq(clone.getRoleAdmin(MINTER_ROLE), OWNER_ROLE); + + (address recv, uint256 amount) = clone.royaltyInfo(0, 10_000); + assertEq(recv, ROYALTY_RECIPIENT); + assertEq(amount, 750); + } + + function test_initialize_revertsOnZeroOwner() public { + address cloneAddr = address(new ERC1967Proxy(address(impl), "")); + address[] memory empty = new address[](0); + vm.expectRevert(IUserCollection721.ZeroAddress.selector); + UserCollection721(cloneAddr).initialize( + CreateParams721({ + owner: address(0), + name: "X", + symbol: "X", + baseURI: "", + contractURI: "", + royaltyRecipient: address(0), + royaltyBps: 0, + additionalMinters: empty + }), + OPERATOR_MINTER + ); + } + + function test_initialize_revertsOnZeroOperatorMinter() public { + address cloneAddr = address(new ERC1967Proxy(address(impl), "")); + address[] memory empty = new address[](0); + vm.expectRevert(IUserCollection721.ZeroAddress.selector); + UserCollection721(cloneAddr).initialize( + CreateParams721({ + owner: OWNER, + name: "X", + symbol: "X", + baseURI: "", + contractURI: "", + royaltyRecipient: address(0), + royaltyBps: 0, + additionalMinters: empty + }), + address(0) + ); + } + + function test_initialize_skipsRoyaltyWhenBpsZero() public { + address[] memory empty = new address[](0); + UserCollection721 clone = _deployClone(0, empty); + (address recv, uint256 amount) = clone.royaltyInfo(0, 10_000); + assertEq(recv, address(0)); + assertEq(amount, 0); + } + + function test_implementation_disablesInitializers() public { + // The implementation contract itself must not be initializable. + address[] memory empty = new address[](0); + vm.expectRevert(Initializable.InvalidInitialization.selector); + impl.initialize( + CreateParams721({ + owner: OWNER, + name: "X", + symbol: "X", + baseURI: "", + contractURI: "", + royaltyRecipient: address(0), + royaltyBps: 0, + additionalMinters: empty + }), + OPERATOR_MINTER + ); + } + + function test_initialize_revertsOnSecondCall() public { + UserCollection721 clone = _deployCloneDefault(); + address[] memory empty = new address[](0); + vm.expectRevert(Initializable.InvalidInitialization.selector); + clone.initialize( + CreateParams721({ + owner: OWNER, + name: "X", + symbol: "X", + baseURI: "", + contractURI: "", + royaltyRecipient: address(0), + royaltyBps: 0, + additionalMinters: empty + }), + OPERATOR_MINTER + ); + } + + // ────────────────────────────────────────────── + // Mint + // ────────────────────────────────────────────── + + function test_mint_assignsIdAndUriAndIncrementsCounter() public { + UserCollection721 clone = _deployCloneDefault(); + + vm.expectEmit(true, true, true, true); + emit Transfer(address(0), ALICE, 0); + + // Convention (spec §7.2 row 7, option b): with a non-empty baseURI the + // caller passes a RELATIVE SUFFIX, and OZ ERC721URIStorage resolves + // tokenURI to `baseURI + suffix`. Passing a full URI here would yield a + // broken double-prefixed value. + vm.prank(OPERATOR_MINTER); + uint256 id = clone.mint(ALICE, "0.json"); + + assertEq(id, 0); + assertEq(clone.nextTokenId(), 1); + assertEq(clone.ownerOf(0), ALICE); + assertEq(clone.tokenURI(0), "ipfs://base/0.json"); + } + + function test_tokenURI_baseUriChangeRepointsExistingToken() public { + // Documents the F1/§7.2-row-7 property: the per-token suffix is fixed at + // mint, but the shared baseURI is mutable until lockMetadata, so changing + // it re-points an ALREADY-MINTED token's resolved URI. Buyers get a freeze + // guarantee only from metadataLocked. + UserCollection721 clone = _deployCloneDefault(); + vm.prank(OPERATOR_MINTER); + uint256 id = clone.mint(ALICE, "0.json"); + assertEq(clone.tokenURI(id), "ipfs://base/0.json"); + + vm.prank(OWNER); + clone.setBaseURI("ipfs://moved/"); + assertEq(clone.tokenURI(id), "ipfs://moved/0.json"); + + // After locking, the base can no longer move — the URI is frozen. + vm.prank(OWNER); + clone.lockMetadata(); + vm.prank(OWNER); + vm.expectRevert(IUserCollection721.MetadataIsLocked.selector); + clone.setBaseURI("ipfs://again/"); + } + + function test_mint_revertsForNonMinter() public { + UserCollection721 clone = _deployCloneDefault(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, STRANGER, MINTER_ROLE) + ); + vm.prank(STRANGER); + clone.mint(ALICE, "ipfs://0.json"); + } + + // ────────────────────────────────────────────── + // mintBatch + // ────────────────────────────────────────────── + + function test_mintBatch_returnsContiguousIdsAndMatchesTransfers() public { + UserCollection721 clone = _deployCloneDefault(); + + // Pre-seed one token so the batch starts at id 1. + vm.prank(OPERATOR_MINTER); + clone.mint(ALICE, "first.json"); + + address[] memory recipients = new address[](3); + recipients[0] = ALICE; + recipients[1] = BOB; + recipients[2] = ALICE; + string[] memory uris = new string[](3); + uris[0] = "1.json"; + uris[1] = "2.json"; + uris[2] = "3.json"; + + vm.expectEmit(true, true, true, true); + emit Transfer(address(0), ALICE, 1); + vm.expectEmit(true, true, true, true); + emit Transfer(address(0), BOB, 2); + vm.expectEmit(true, true, true, true); + emit Transfer(address(0), ALICE, 3); + + vm.prank(OPERATOR_MINTER); + uint256[] memory ids = clone.mintBatch(recipients, uris); + + assertEq(ids.length, 3); + assertEq(ids[0], 1); + assertEq(ids[1], 2); + assertEq(ids[2], 3); + assertEq(clone.nextTokenId(), 4); + } + + function test_mintBatch_revertsLengthMismatch() public { + UserCollection721 clone = _deployCloneDefault(); + address[] memory recipients = new address[](2); + recipients[0] = ALICE; + recipients[1] = BOB; + string[] memory uris = new string[](1); + uris[0] = "x"; + vm.expectRevert(IUserCollection721.LengthMismatch.selector); + vm.prank(OPERATOR_MINTER); + clone.mintBatch(recipients, uris); + } + + function test_mintBatch_revertsOversize() public { + UserCollection721 clone = _deployCloneDefault(); + address[] memory recipients = new address[](101); + string[] memory uris = new string[](101); + for (uint256 i = 0; i < 101; ++i) { + recipients[i] = ALICE; + uris[i] = "x"; + } + vm.expectRevert(abi.encodeWithSelector(IUserCollection721.BatchTooLarge.selector, 101, 100)); + vm.prank(OPERATOR_MINTER); + clone.mintBatch(recipients, uris); + } + + function test_mintBatch_atMaxBatchSucceeds() public { + // Boundary: exactly MAX_BATCH (100) must succeed — the oversize test + // covers 101, this pins the inclusive upper bound. + UserCollection721 clone = _deployCloneDefault(); + address[] memory recipients = new address[](100); + string[] memory uris = new string[](100); + for (uint256 i = 0; i < 100; ++i) { + recipients[i] = ALICE; + uris[i] = "x"; + } + vm.prank(OPERATOR_MINTER); + uint256[] memory ids = clone.mintBatch(recipients, uris); + + assertEq(ids.length, 100); + assertEq(ids[99], 99); + assertEq(clone.nextTokenId(), 100); + assertEq(clone.ownerOf(99), ALICE); + } + + function test_mintBatch_emptyIsNoOp() public { + UserCollection721 clone = _deployCloneDefault(); + address[] memory recipients = new address[](0); + string[] memory uris = new string[](0); + + vm.prank(OPERATOR_MINTER); + uint256[] memory ids = clone.mintBatch(recipients, uris); + + assertEq(ids.length, 0); + assertEq(clone.nextTokenId(), 0); + } + + function test_mintBatch_reentrantMintGetsFreshIdNoCollision() public { + // F5 regression: mintBatch reserves [startId, startId+len) BEFORE the + // _safeMint loop, so a mint reentered from an onERC721Received callback + // takes a fresh ID (startId+len) and cannot collide with a batch ID. + // Under the old post-loop counter write this reentrancy reverted the + // whole batch ("token already minted"); now it succeeds cleanly. + UserCollection721 clone = _deployCloneDefault(); + ReentrantERC721Receiver receiver = new ReentrantERC721Receiver(); + receiver.setCollection(IMintable721(address(clone))); + vm.prank(OWNER); + clone.grantRole(MINTER_ROLE, address(receiver)); + + address[] memory to = new address[](2); + to[0] = address(receiver); // first mint triggers the reentrant mint + to[1] = BOB; + string[] memory uris = new string[](2); + uris[0] = "a"; + uris[1] = "b"; + + vm.prank(OPERATOR_MINTER); + uint256[] memory ids = clone.mintBatch(to, uris); + + assertEq(ids[0], 0); + assertEq(ids[1], 1); + assertEq(receiver.reentrantTokenId(), 2, "reentrant mint must take the reserved-beyond ID"); + assertEq(clone.nextTokenId(), 3); + assertEq(clone.ownerOf(0), address(receiver)); + assertEq(clone.ownerOf(1), BOB); + assertEq(clone.ownerOf(2), address(receiver)); + } + + // ────────────────────────────────────────────── + // Owner-mutable settings + locks + // ────────────────────────────────────────────── + + function test_setBaseURI_emitsAndUpdates() public { + UserCollection721 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit BaseURIUpdated("ipfs://newbase/"); + vm.prank(OWNER); + clone.setBaseURI("ipfs://newbase/"); + + vm.prank(OPERATOR_MINTER); + uint256 id = clone.mint(ALICE, "0.json"); + assertEq(clone.tokenURI(id), "ipfs://newbase/0.json"); + } + + function test_setBaseURI_revertsForNonOwner() public { + UserCollection721 clone = _deployCloneDefault(); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, STRANGER, OWNER_ROLE) + ); + vm.prank(STRANGER); + clone.setBaseURI("x"); + } + + function test_lockMetadata_blocksSubsequentSetters() public { + UserCollection721 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit MetadataLocked(); + vm.prank(OWNER); + clone.lockMetadata(); + assertTrue(clone.metadataLocked()); + + vm.prank(OWNER); + vm.expectRevert(IUserCollection721.MetadataIsLocked.selector); + clone.setBaseURI("x"); + + vm.prank(OWNER); + vm.expectRevert(IUserCollection721.MetadataIsLocked.selector); + clone.setContractURI("x"); + } + + function test_lockRoyalties_blocksSubsequentSetters() public { + UserCollection721 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit RoyaltiesLocked(); + vm.prank(OWNER); + clone.lockRoyalties(); + assertTrue(clone.royaltiesLocked()); + + vm.prank(OWNER); + vm.expectRevert(IUserCollection721.RoyaltiesAreLocked.selector); + clone.setDefaultRoyalty(ALICE, 100); + } + + function test_setDefaultRoyalty_zeroBpsClears() public { + UserCollection721 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit DefaultRoyaltyUpdated(address(0), 0); + vm.prank(OWNER); + clone.setDefaultRoyalty(address(0), 0); + (address recv, uint256 amount) = clone.royaltyInfo(0, 10_000); + assertEq(recv, address(0)); + assertEq(amount, 0); + } + + function test_setDefaultRoyalty_nonZeroBpsUpdates() public { + UserCollection721 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit DefaultRoyaltyUpdated(ALICE, 1000); + vm.prank(OWNER); + clone.setDefaultRoyalty(ALICE, 1000); + (address recv, uint256 amount) = clone.royaltyInfo(0, 10_000); + assertEq(recv, ALICE); + assertEq(amount, 1000); + } + + function test_setContractURI_emitsAndUpdates() public { + UserCollection721 clone = _deployCloneDefault(); + vm.expectEmit(true, true, true, true); + emit ContractURIUpdated("ipfs://newcontract.json"); + vm.prank(OWNER); + clone.setContractURI("ipfs://newcontract.json"); + assertEq(clone.contractURI(), "ipfs://newcontract.json"); + } + + // ────────────────────────────────────────────── + // Role admin + // ────────────────────────────────────────────── + + function test_owner_canGrantAndRevokeMinterRole() public { + UserCollection721 clone = _deployCloneDefault(); + vm.prank(OWNER); + clone.grantRole(MINTER_ROLE, ALICE); + assertTrue(clone.hasRole(MINTER_ROLE, ALICE)); + + vm.prank(OWNER); + clone.revokeRole(MINTER_ROLE, OPERATOR_MINTER); + assertFalse(clone.hasRole(MINTER_ROLE, OPERATOR_MINTER)); + + vm.prank(OPERATOR_MINTER); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, OPERATOR_MINTER, MINTER_ROLE) + ); + clone.mint(ALICE, "x"); + } + + // ────────────────────────────────────────────── + // ERC-2981 + supportsInterface + // ────────────────────────────────────────────── + + function test_supportsInterface_advertisesAllExpectedIds() public { + UserCollection721 clone = _deployCloneDefault(); + assertTrue(clone.supportsInterface(type(IERC165).interfaceId)); + assertTrue(clone.supportsInterface(type(IERC721).interfaceId)); + assertTrue(clone.supportsInterface(type(IERC721Metadata).interfaceId)); + assertTrue(clone.supportsInterface(type(IERC2981).interfaceId)); + assertTrue(clone.supportsInterface(type(IAccessControl).interfaceId)); + } + + // ────────────────────────────────────────────── + // Burn (ERC721Burnable) + // ────────────────────────────────────────────── + + function test_burn_byOwnerRemovesToken() public { + UserCollection721 clone = _deployCloneDefault(); + vm.prank(OPERATOR_MINTER); + uint256 id = clone.mint(ALICE, "0.json"); + + vm.prank(ALICE); + clone.burn(id); + + vm.expectRevert(); + clone.ownerOf(id); + } + + function test_burn_revertsForUnauthorized() public { + UserCollection721 clone = _deployCloneDefault(); + vm.prank(OPERATOR_MINTER); + uint256 id = clone.mint(ALICE, "0.json"); + + vm.prank(STRANGER); + vm.expectRevert(); + clone.burn(id); + } + + function test_nextTokenId_isMonotonicAcrossSingleAndBatch() public { + UserCollection721 clone = _deployCloneDefault(); + vm.prank(OPERATOR_MINTER); + clone.mint(ALICE, "a"); + assertEq(clone.nextTokenId(), 1); + + address[] memory recipients = new address[](2); + recipients[0] = ALICE; + recipients[1] = BOB; + string[] memory uris = new string[](2); + uris[0] = "b"; uris[1] = "c"; + vm.prank(OPERATOR_MINTER); + clone.mintBatch(recipients, uris); + assertEq(clone.nextTokenId(), 3); + + vm.prank(OPERATOR_MINTER); + clone.mint(ALICE, "d"); + assertEq(clone.nextTokenId(), 4); + } + + // ────────────────────────────────────────────── + // Bytecode permanence (§7.2 row 15, §8.2) + // ────────────────────────────────────────────── + + function test_implementation_runtimeCode_containsNoSelfdestruct() public view { + // Walk EVM opcodes, skipping PUSH1..PUSH32 immediates (where 0xff can + // legitimately appear as constant data). Any 0xff byte found at an + // opcode position is a SELFDESTRUCT and would let the implementation be + // wiped — see §7.2 row 15. `bytecode_hash = "none"` in foundry.toml + // strips the metadata trailer that would otherwise produce false + // positives at the end of runtime code. + bytes memory code = address(impl).code; + uint256 i = 0; + while (i < code.length) { + uint8 op = uint8(code[i]); + assertTrue(op != 0xff, "SELFDESTRUCT opcode at runtime position"); + if (op >= 0x60 && op <= 0x7f) { + // PUSH1..PUSH32: skip (op - 0x5f) immediate bytes. + i += 1 + (op - 0x5f); + } else { + i += 1; + } + } + } + + function test_implementationHasNoUpgradeSelectors() public view { + // proxiableUUID() — selector 0x52d1902d + (bool ok1, ) = address(impl).staticcall(abi.encodeWithSelector(0x52d1902d)); + assertFalse(ok1, "impl must not expose proxiableUUID"); + + // upgradeToAndCall(address,bytes) — selector 0x4f1ef286 + (bool ok2, ) = address(impl).staticcall( + abi.encodeWithSelector(0x4f1ef286, address(0), bytes("")) + ); + assertFalse(ok2, "impl must not expose upgradeToAndCall"); + } +} diff --git a/test/collections/mocks/CollectionFactoryV2Mock.sol b/test/collections/mocks/CollectionFactoryV2Mock.sol new file mode 100644 index 00000000..be1e8f15 --- /dev/null +++ b/test/collections/mocks/CollectionFactoryV2Mock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {CollectionFactory} from "../../../src/collections/CollectionFactory.sol"; + +/** + * @title CollectionFactoryV2Mock + * @notice UUPS upgrade target used by `CollectionFactory.t.sol` to verify that: + * (a) the upgrade actually changes the EIP-1967 implementation slot, + * (b) pre-upgrade storage (admin/operator roles, impl pointers, + * collectionByExternalId entries) reads correctly post-upgrade. + * @dev Adds one trivial public function whose presence post-upgrade proves + * the proxy genuinely delegated to new code rather than no-opping. + */ +contract CollectionFactoryV2Mock is CollectionFactory { + function v2Sentinel() external pure returns (uint256) { + return 4242; + } +} diff --git a/test/collections/mocks/NonUUPSImplementationMock.sol b/test/collections/mocks/NonUUPSImplementationMock.sol new file mode 100644 index 00000000..7f3dc730 --- /dev/null +++ b/test/collections/mocks/NonUUPSImplementationMock.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/** + * @title NonUUPSImplementationMock + * @notice A bare contract used as an upgrade target to verify that + * `CollectionFactory.upgradeToAndCall` reverts with OZ's + * `ERC1967InvalidImplementation` (no `proxiableUUID`). + */ +contract NonUUPSImplementationMock { + uint256 public sentinel = 1; +} diff --git a/test/collections/mocks/ReentrantERC721Receiver.sol b/test/collections/mocks/ReentrantERC721Receiver.sol new file mode 100644 index 00000000..238137e1 --- /dev/null +++ b/test/collections/mocks/ReentrantERC721Receiver.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; + +interface IMintable721 { + function mint(address to, string calldata uri) external returns (uint256); +} + +/// @notice Test-only ERC-721 receiver that reenters `mint` exactly once on its +/// first `onERC721Received` callback. Used to prove that +/// `UserCollection721.mintBatch` reserves its ID range BEFORE the mint +/// loop, so a reentrant mint takes a fresh ID instead of colliding (see +/// F5 hardening). Under the old stale-counter ordering the reentrant +/// mint would have reverted with "token already minted", reverting the +/// whole batch. +contract ReentrantERC721Receiver is IERC721Receiver { + IMintable721 public collection; + bool public reentered; + uint256 public reentrantTokenId; + + function setCollection(IMintable721 c) external { + collection = c; + } + + function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) { + if (!reentered && address(collection) != address(0)) { + reentered = true; + reentrantTokenId = collection.mint(address(this), "reentrant.json"); + } + return IERC721Receiver.onERC721Received.selector; + } +}