Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
42889fa
docs(collections): add user collections technical specification
Douglasacost Apr 29, 2026
93e660b
chore(spellcheck): allow EXTCODEHASH, autonumber, dedup
Douglasacost Apr 29, 2026
841491a
docs(collections): refine spec with auto-grant, validation, OZ alignment
Douglasacost May 7, 2026
9700f69
feat(collections): scaffold factory, ERC-721/1155 implementations, in…
Douglasacost May 7, 2026
025d6af
test(collections): add unit and integration suites; drop OZ v5 no-op …
Douglasacost May 7, 2026
b93a9a4
chore(collections): deploy and upgrade scripts, ops orchestration, la…
Douglasacost May 7, 2026
1e305a2
docs(collections): design doc for ERC1967Proxy replacement of Clones.…
Douglasacost May 8, 2026
638536c
docs(collections): implementation plan for ERC1967Proxy replacement o…
Douglasacost May 8, 2026
d4e95cc
chore(ops): temp-move L1-incompatible files for collections zksync build
Douglasacost May 8, 2026
8a396c5
test(collections): add CREATE2 derivation test for createCollection72…
Douglasacost May 8, 2026
5845b24
feat(collections): replace Clones.clone() with ERC1967Proxy{salt} in …
Douglasacost May 8, 2026
ca7cd62
feat(collections): replace Clones.clone() with ERC1967Proxy{salt} in …
Douglasacost May 8, 2026
c144929
test(collections): assert Upgraded+Initialized emit order; add immedi…
Douglasacost May 8, 2026
a3649cd
test(collections): switch impl unit-test helpers from Clones to ERC19…
Douglasacost May 8, 2026
bfee509
test(collections): assert impls expose no UUPS upgrade selectors
Douglasacost May 8, 2026
c500afd
test(collections): bytecode-permanence proof for canonical OZ ERC1967…
Douglasacost May 8, 2026
9c2058b
test(collections): integration emit-order and vocabulary updates for …
Douglasacost May 8, 2026
f48ec1c
test(collections): vocabulary pass — clones → collections in factory …
Douglasacost May 8, 2026
b189d26
docs(collections): refit spec for ERC1967Proxy per-collection deploy
Douglasacost May 8, 2026
ff3fa4e
docs(collections): vocab pass for source NatSpec — clones → collections
Douglasacost May 8, 2026
e472de4
ops(collections): gate deploy on populated factoryDependencies
Douglasacost May 8, 2026
fc95ebe
ops(collections): harden factoryDeps gate against malformed jq output
Douglasacost May 8, 2026
f552557
ops(collections): post-broadcast createCollection721 smoke test on zk…
Douglasacost May 8, 2026
d44d737
ops(collections): fix smoke test cast args and operator key resolution
Douglasacost May 8, 2026
af9cca6
ops(collections): fix stale clone references in deploy script header
Douglasacost May 8, 2026
4976cbb
ops(collections): fix CreateParams721 field order in smoke test
Douglasacost May 8, 2026
104ee88
Merge remote-tracking branch 'origin/main' into feat/user-collections…
Douglasacost Jun 1, 2026
54dd98f
ops(collections): add cspell ignore words for collections docs/tests
Douglasacost Jun 1, 2026
e18aa95
feat(collections): security-review hardening + test-gap coverage
Douglasacost Jun 1, 2026
0aec6e2
ops(collections): harden deploy/verify tooling and fix verifier wiring
Douglasacost Jun 1, 2026
982de47
ops(collections): add upgrade orchestration wrapper
Douglasacost Jun 1, 2026
a932ffd
ops(collections): add hardhat deploy+verify script
Douglasacost Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"Reentrancy",
"SFID",
"EXTCODECOPY",
"EXTCODEHASH",
"solady",
"SLOAD",
"Bitmask",
Expand Down Expand Up @@ -105,6 +106,18 @@
"repoint",
"repointed",
"cutover",
"autonumber",
"dedup",
"runbook",
"selfdestruct",
"SELFDESTRUCT",
"proxiable",
"codehash",
"codehashes",
"immediates",
"newbase",
"newcontract",
"opping",
"Axelar",
"IEIP",
"calldataload",
Expand Down Expand Up @@ -133,6 +146,15 @@
"remy",
"aabbcc",
"mfas",
"reqs"
"reqs",
"impls",
"zkout",
"pushable",
"remappings",
"staticcall",
"agentic",
"delegatecalls",
"repoints",
"reentrant"
]
}
40 changes: 38 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down
178 changes: 178 additions & 0 deletions hardhat-deploy/DeployCollectionFactory.ts
Original file line number Diff line number Diff line change
@@ -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.",
);
}
};
Loading
Loading