This document covers deploying the MozaikPay smart contracts to Base Sepolia (testnet) and Base mainnet.
- Foundry installed (
forge,cast) - A deployer wallet with ETH on the target chain (for gas)
- A sponsor wallet (hot wallet) whose private key the API will use to sign paymaster approvals
- An RPC endpoint for the target chain. The Makefile defaults to the public Base endpoints
(
https://sepolia.base.org,https://mainnet.base.org); setBASE_SEPOLIA_RPC/BASE_MAINNET_RPCto use your own (recommended for mainnet)
| Parameter | Base Sepolia (testnet) | Base Mainnet |
|---|---|---|
| Chain ID | 84532 |
8453 |
| RPC (Infura) | https://base-sepolia.infura.io/v3/<API_KEY> |
https://base-mainnet.infura.io/v3/<API_KEY> |
| EntryPoint v0.9 | 0x433709009B8330FDa32311DF1C2AFA402eD8D009 |
0x433709009B8330FDa32311DF1C2AFA402eD8D009 |
| USDC | 0x036CbD53842c5426634e7929541eC2318f3dCF7e |
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| Block explorer | https://sepolia.basescan.org |
https://basescan.org |
The EntryPoint v0.9 is already deployed at the canonical address on both chains via deterministic CREATE2. You do not need to deploy it.
Four contracts are deployed per environment:
- MozaikAccountFactory -- CREATE2 factory for deploying ERC-4337 smart accounts. The canonical EntryPoint v0.9 is hardcoded (not a constructor argument), matching the account, so deployment and runtime authority cannot diverge.
- MozaikVerifyingPaymaster -- Sponsors gas for UserOperations. Constructor takes the sponsor wallet address; the canonical EntryPoint v0.9 is hardcoded.
- MozaikAccount (implementation) -- Deployed automatically by the factory constructor. Not called directly.
- MozaikPaylinks -- Non-upgradeable USDC escrow for payment links. Deployed by
01_Deploy.s.solalongside the account/paymaster stack, bound to the chain's USDC. Constructor takes the USDC token address; immutable thereafter. Lifecycle:create(sender locks USDC under an ephemeralclaimSigner),claim(recipient redeems with an EIP-712 signature over the claim payload),reclaim(sender returns funds; sender-only pre-expiry, permissionless at or after expiry).
Create a .env file in the repo root (copy from .env.example):
# RPC endpoints (used by foundry.toml rpc_endpoints)
BASE_SEPOLIA_RPC=https://base-sepolia.infura.io/v3/<YOUR_INFURA_KEY>
BASE_MAINNET_RPC=https://base-mainnet.infura.io/v3/<YOUR_INFURA_KEY>
# 01_Deploy.s.sol -- the address that will sign paymaster approvals
# This is the PUBLIC address of your sponsor hot wallet
SPONSOR_ADDRESS=0x<your_sponsor_public_address>
# 02_FundPaymaster.s.sol (set after deployment)
PAYMASTER_ADDRESS=0x<deployed_paymaster_address>
DEPOSIT_AMOUNT_WEI=100000000000000000 # 0.1 ETH (adjust for testnet vs mainnet)
# 03_TransferOwnership.s.sol (optional, set if transferring ownership)
NEW_OWNER_ADDRESS=0x<new_owner_address>
# VerifyDeploy.s.sol (set after deployment)
FACTORY_ADDRESS=0x<deployed_factory_address>
PAYLINKS_ADDRESS=0x<deployed_paylinks_address>
# 01_Deploy.s.sol (optional) -- override the paylinks USDC token. Defaults to the
# canonical USDC for the target chain (Base mainnet/sepolia) when unset.
# USDC_ADDRESS=0x<usdc_address_for_chain>
# Source verification on Basescan (see "Etherscan (Basescan) Source Verification").
# One Etherscan V2 API key covers both Base mainnet (8453) and Sepolia (84532).
ETHERSCAN_API_KEY=<your_etherscan_v2_api_key>| Variable | Required By | Description |
|---|---|---|
BASE_SEPOLIA_RPC |
All testnet scripts | Infura (or other) RPC URL for Base Sepolia |
BASE_MAINNET_RPC |
All mainnet scripts | Infura (or other) RPC URL for Base mainnet |
SPONSOR_ADDRESS |
01_Deploy.s.sol |
Public address of the backend's paymaster sponsor key. This is the wallet whose private key the API uses (MOZAIK_PAYMASTER_SPONSOR_KEY) |
PAYMASTER_ADDRESS |
02_FundPaymaster, 03_TransferOwnership, VerifyDeploy |
Address of the deployed paymaster contract (output of step 1) |
DEPOSIT_AMOUNT_WEI |
02_FundPaymaster |
ETH to deposit into the EntryPoint on behalf of the paymaster, in wei |
NEW_OWNER_ADDRESS |
03_TransferOwnership |
Address to transfer paymaster ownership to (optional) |
FACTORY_ADDRESS |
VerifyDeploy |
Address of the deployed factory contract (output of step 1) |
PAYLINKS_ADDRESS |
VerifyDeploy |
Address of the deployed MozaikPaylinks contract (output of step 1) |
USDC_ADDRESS |
01_Deploy (optional) |
Overrides the paylinks USDC token. Defaults to the canonical USDC for the target chain (see chain reference table) |
ETHERSCAN_API_KEY |
Source verification (--verify, verify-account-*) |
Etherscan V2 API key; one key verifies contracts on both Base chains |
All commands below use Base Sepolia. For mainnet, replace sepolia with mainnet in make targets.
The paylinks escrow (MozaikPaylinks) is deployed in the same step, bound to the chain's
canonical USDC. Export USDC_ADDRESS first only if you need to override that token.
# Dry-run first (no --broadcast)
make deploy-sepolia
# If the dry-run looks good, broadcast the transaction
# Use --account <keystore_name> for a Foundry keystore, or set PRIVATE_KEY env var
make deploy-sepolia EXTRA="--broadcast --account deployer"The script logs the deployed addresses:
EntryPoint: 0x...
AccountImpl: 0x...
Factory: 0x...
Paymaster: 0x...
Sponsor: 0x...
Owner: 0x...
USDC: 0x...
Paylinks: 0x...
Save the Factory, Paymaster, and Paylinks addresses. You'll need them for the API config (incl.
MOZAIK_PAYLINKS_ADDR) and the next steps.
The paymaster needs an ETH deposit in the EntryPoint to pay for gas on behalf of users.
# Set the deployed paymaster address and deposit amount
export PAYMASTER_ADDRESS=0x<from_step_1>
export DEPOSIT_AMOUNT_WEI=100000000000000000 # 0.1 ETH for testnet
# Dry-run
make fund-paymaster-sepolia
# Broadcast
make fund-paymaster-sepolia EXTRA="--broadcast --account deployer"Recommended deposit amounts:
- Testnet: 0.1 ETH (
100000000000000000wei) -- enough for extensive testing - Mainnet: start with 0.01 ETH (
10000000000000000wei), monitor and top up as needed
export FACTORY_ADDRESS=0x<from_step_1>
export PAYMASTER_ADDRESS=0x<from_step_1>
export PAYLINKS_ADDRESS=0x<from_step_1>
make verify-sepoliaThis reads on-chain state and confirms:
- Factory and account implementation have code
- Paymaster has code
- Sponsor and owner are non-zero
- EntryPoint deposit exists
- MozaikPaylinks has code and its bound USDC has code
If you want a different address (e.g., a multisig) to own the paymaster:
export PAYMASTER_ADDRESS=0x<from_step_1>
export NEW_OWNER_ADDRESS=0x<multisig_or_new_owner>
make transfer-ownership-sepolia EXTRA="--broadcast --account deployer"Ownership uses OpenZeppelin's Ownable2Step -- the new owner must call acceptOwnership() to finalize.
This is separate from "Step 3: Verify Deployment" above, which only checks on-chain state. This section publishes the Solidity source to Basescan so anyone viewing a contract address sees verified code and can Read/Write it.
Why accounts only need to be verified once. Every user account is an
identical-bytecode ERC1967Proxy (only its constructor arguments differ, and
those are not part of runtime bytecode). Basescan's "Similar Match" verifies any
contract whose bytecode matches an already-verified one, so verifying a single
account makes every other account show source automatically. Basescan also
auto-detects the EIP-1967 implementation slot, so once the MozaikAccount
implementation is verified, every account exposes its ABI under "Read as Proxy".
Prerequisite: ETHERSCAN_API_KEY set in .env (see Environment
Variables). One Etherscan V2 key works for both Base chains.
All commands below use Base Sepolia (--chain 84532). For mainnet use
--chain 8453 and the -mainnet make target. Mainnet verification, like mainnet
deployment, should only be run with explicit approval.
Pass --verify when broadcasting the deploy. Foundry verifies the factory,
paymaster, paylinks, and (normally) the account implementation as it deploys them:
make deploy-sepolia EXTRA="--broadcast --verify --account deployer"For a stack deployed before verification was wired up, verify each core contract by address. Factory and implementation take no constructor arguments; paymaster and paylinks each take one address:
# Factory (no constructor args)
forge verify-contract <FACTORY> \
src/account/MozaikAccountFactory.sol:MozaikAccountFactory --chain 84532 --watch
# Account implementation (no constructor args)
forge verify-contract <IMPL> \
src/account/MozaikAccount.sol:MozaikAccount --chain 84532 --watch
# Paymaster (constructor: sponsor address)
forge verify-contract <PAYMASTER> \
src/paymaster/MozaikVerifyingPaymaster.sol:MozaikVerifyingPaymaster --chain 84532 \
--constructor-args $(cast abi-encode "constructor(address)" <SPONSOR>) --watch
# Paylinks (constructor: USDC address)
forge verify-contract <PAYLINKS> \
src/paylinks/MozaikPaylinks.sol:MozaikPaylinks --chain 84532 \
--constructor-args $(cast abi-encode "constructor(address)" <USDC>) --watch<IMPL> is the factory's ACCOUNT_IMPLEMENTATION(); <SPONSOR> and <USDC> are
the values logged by 01_Deploy (Step 1).
Pick any one deployed account and verify it. Basescan then shows every other account as a Similar Match automatically:
ACCOUNT=0x<account> IMPL=0x<impl> SPENDING=0x<spending> RECOVERY=0x<recovery> \
make verify-account-sepoliaSPENDING / RECOVERY are the account's signer pair (from its AccountCreated
event or the app's wallet record).
- Core contracts: once per deployed address, or automatically via
--verifyon future deploys. - Accounts: once per factory version. A new factory changes the proxy bytecode, so re-anchor one account from the new factory to refresh Similar Match.
After deployment, record these values for the API and mobile configuration:
| Value | Used By | Example |
|---|---|---|
| Factory address | API (MOZAIK_ACCOUNT_FACTORY) |
0xABC... |
| Paymaster address | API (MOZAIK_PAYMASTER_ADDRESS) |
0xDEF... |
| MozaikPaylinks address | API (MOZAIK_PAYLINKS_ADDR) |
0x123... |
| Sponsor private key | API (MOZAIK_PAYMASTER_SPONSOR_KEY) |
64-char hex, no 0x prefix |
| USDC address | API (MOZAIK_USDC_ADDRESS), Mobile (EXPO_PUBLIC_USDC_ADDRESS) |
See chain reference table above |
| Chain ID | API (MOZAIK_CHAIN_ID) |
84532 (Sepolia) or 8453 (mainnet) |
Every push to main and every pull request runs these required checks (one workflow each under
.github/workflows/):
| Check | Command | Gates |
|---|---|---|
| Test | make test + make test-fork |
Unit, fuzz, invariant, and Base Sepolia fork tests |
| Lint | forge lint src/ --deny notes |
Solidity lint on production code |
| Echidna | make echidna |
Property-based fuzzing (paymaster, account, paylinks) |
| E2E | make test-e2e |
Full lifecycle against a local anvil node |
| Slither | make slither |
Static analysis; fails on new High-severity findings |
| Coverage | make coverage-check |
src/ line coverage must stay at or above the floor (99%) |
Mutation testing (make mutate) runs on a weekly schedule and on demand only (mutation.yaml), never as
a PR gate. It reruns the suite once per generated mutant, so it is slow; surviving (uncaught) mutants are uploaded as an
artifact for review rather than blocking merges. Because slither-mutate rewrites src/ in place, make mutate runs the
campaign inside a throwaway git worktree at HEAD, so an interrupted run can never leave a mutant in the working tree.
make slither runs slither src/ --fail-high (lib/ is excluded via slither.config.json). The gate blocks only
High-severity findings; the full report is still printed to the CI log for reviewers. The current output is 12
results, all reviewed and accepted as benign:
| Detector | Severity | Count | Why it is safe |
|---|---|---|---|
unused-return |
Medium | 5 | ECDSA.tryRecover returns (address, RecoverError, bytes32); call sites bind the address and error and check the error, intentionally ignoring the third element (the error argument). |
timestamp |
Low | 3 | MozaikPaylinks compares block.timestamp for payment-link expiry over day/week windows where validator drift is irrelevant and there is no EVM alternative. |
assembly |
Info | 1 | MozaikAccount uses inline assembly only for its ERC-7201 namespaced storage slot (standard pattern). |
naming-convention |
Info | 3 | ACCOUNT_IMPLEMENTATION, SENDER_CREATOR, and USDC are immutable/constant, conventionally SCREAMING_SNAKE_CASE. |
Slither also prints IR-generation errors for OpenZeppelin's EIP712 constructor (_EIP712Name / _EIP712Version);
these are a limitation of Slither parsing OZ's ShortStrings assembly, affect only lib/ code, and do not change the
exit code. Because every finding tops out at Medium and is reviewed-benign, --fail-high keeps the gate low-noise while
still blocking the severity that maps to a must-fix issue. Re-run make slither locally after changing src/ and
review any new finding before merging.
make coverage-check regenerates lcov.info (forge coverage, excluding fork and e2e tests) and fails if src/ line
coverage drops below COVERAGE_MIN (99%, set in the Makefile). no_match_coverage in foundry.toml keeps the report
scoped to production code (tests, scripts, lib, e2e, and mocks are excluded).
Current coverage is 99.47% (189/190 lines). The single uncovered line is the MozaikVerifyingPaymaster constructor's
BasePaymaster(...) base-initializer line: the constructor provably runs (its body lines are covered), but
forge coverage does not attribute a hit to the base-initializer line. This is a coverage-instrumentation artifact, not
a missing test, so 100% is not reachable and the floor sits just below.