Public-facing record of how CardZero's on-chain components are deployed and operated on Base mainnet. Written for peer protocol teams, auditors, and integrators evaluating the operational threat model.
Last updated: 2026-05-13
Principles
- Deterministic addresses. Every wallet address is predictable from
(owner, agentSalt)before any chain interaction. Clients can be told "your wallet is0x…" and pre-fund it before the contract is deployed. - Idempotent deployment. Re-calling
createWalletwith the same inputs is a no-op, not a revert. A bundler retry or paymaster replay cannot create a divergent state. - Role isolation. Privileged operations are split across four EOAs that never share a private key with any other role.
- Frozen commitments. Reputation scoring rules are committed on-chain
as a
keccak256hash of a public document. Any post-hoc change is detectable by recomputing. - Sepolia full-lifecycle gate. No mainnet deployment lands without the same end-to-end flow having run green on Sepolia.
- Separated post-deploy operations. Role grants, registry wiring, and admin handoffs each happen as their own transaction, so each is independently inspectable on Basescan.
Deterministic CREATE2 salt
The wallet factory uses OpenZeppelin's Create2.deploy over an
ERC1967Proxy constructor. The user-facing salt is composed:
// CardZeroFactory.createWallet (and CardZeroFactoryV3.createWallet)
bytes32 finalSalt = keccak256(abi.encodePacked(owner, agentSalt));
bytes memory initData = abi.encodeCall(
CardZeroWallet.initialize,
(owner, feeRate, feeRecipient)
);
bytes memory proxyBytecode = abi.encodePacked(
type(ERC1967Proxy).creationCode,
abi.encode(address(walletImplementation), initData)
);
address predicted = Create2.computeAddress(finalSalt, keccak256(proxyBytecode));
// Idempotent — re-call returns the same address without redeploying
if (predicted.code.length > 0) return predicted;
wallet = Create2.deploy(0, finalSalt, proxyBytecode);
Salt composition rationale
owneris part of the salt. Two different owners passing the sameagentSaltget two different addresses. Salt collisions across owners are impossible.agentSaltis a per-wallet client-side identifier. CardZero's API generates this as a random 32-byte value at wallet-creation request time and stores it in the off-chain DB.abi.encodePacked(notabi.encode). 32-byteagentSalt+ 20-byteownerpacks to exactly 52 bytes with no padding ambiguity.- Fee params are NOT in the salt but ARE in
initData, which IS hashed into the CREATE2 calculation viakeccak256(proxyBytecode). So changingfeeRateorfeeRecipientchanges the predicted address — the same(owner, agentSalt)pair maps to different addresses on different fee configurations. By design: it makes fee rates a frozen-at-deploy commitment per wallet.
Pre-deployment prediction
API clients call
Factory.getWalletAddress(owner, agentSalt, feeRate, feeRecipient) returns (address)
as a view function. No chain state is mutated. Owner can pre-fund the
predicted address with USDC; ERC-4337 sponsors the eventual deployment via
the bundler on first userOp.
Idempotency guarantee
if (predicted.code.length > 0) return predicted;
Re-calling createWallet for an already-deployed wallet returns the
existing address without consuming the CREATE2 opcode. This protects
against bundler retries during paymaster failures, concurrent claim
attempts from the same client, and cross-region API instances both
attempting deployment.
A divergent state is impossible because there's only one valid bytecode
hash for any (owner, agentSalt, feeRate, feeRecipient) tuple.
Why not direct CREATE with nonce?
CREATE addresses depend on the deployer's nonce, which is a global
counter on the factory account. That makes pre-deployment prediction
fragile (any other deploy increments the nonce and invalidates the
prediction). CREATE2 keeps the predicted address stable regardless of
unrelated factory activity.
EOA role isolation (4-key model)
Four mainnet EOAs, each with a single role, generated independently and never sharing a key:
| Role | Address | Holds | Notes |
| --- | --- | --- | --- |
| DEPLOYER | 0x79985809…018edce | ADMIN_ROLE on all UUPS contracts | Currently single-key. Multisig + Timelock migration is the next pre-audit step. |
| REGISTRAR | 0xfd865c3C…EED51 | REGISTRAR_ROLE on IdentityRegistry | Calls register(). Cannot transfer USDC. |
| ATTESTOR | 0xf76a7a56…a1D4 | ATTESTOR_ROLE on ReputationRegistry | Writes reputation events. Cannot transfer USDC. |
| EVALUATOR | 0x8157Cb8e…0E59 | EVALUATOR_ROLE on Jobs | Calls evaluatorComplete() / evaluatorReject(). Cannot withdraw escrow. |
Properties verified at deploy time
The deploy script's self-check enforces:
DEPLOYERdoes not holdREGISTRAR_ROLE,ATTESTOR_ROLE, orEVALUATOR_ROLEon any contract.REGISTRARholds onlyREGISTRAR_ROLE.ATTESTORholds onlyATTESTOR_ROLE.EVALUATORholds onlyEVALUATOR_ROLE.- No EOA holds USDC at deploy completion.
- Each role's
getRoleMemberCountis exactly 1.
The script aborts if any check fails.
Blast-radius bounds
- REGISTRAR compromise: can register arbitrary agent identities. Cannot affect existing reputation scores or move funds.
- ATTESTOR compromise: can write reputation events. Cannot register agents or move funds.
- EVALUATOR compromise: can complete or reject jobs in
submittedstatus. Cannot complete jobs that aren't submitted, cannot drain treasury. Bounded bydailyCompleteLimit(5000 USDC). - DEPLOYER compromise (today): worst case — can upgrade contracts arbitrarily. Multisig + Timelock migration removes this SPoF.
SCORING_RULES_HASH commitment
The reputation system's scoring methodology is committed at registry deploy:
bytes32 public immutable scoringRulesHash;
string public scoringRulesURI;
Current values:
scoringRulesHash:0xe23c8005889bb20bf2214f125f498ada9b7e81776af010c2fb21c5387a4f06c3scoringRulesURI:https://cardzero.ai/SCORING-RULES.md
Any third party can verify:
curl -s https://cardzero.ai/SCORING-RULES.md > /tmp/scoring.md
node -e "
const { keccak256, toBytes } = require('viem');
const fs = require('fs');
console.log(keccak256(toBytes(fs.readFileSync('/tmp/scoring.md', 'utf8'))));
"
# expected: 0xe23c8005889bb20bf2214f125f498ada9b7e81776af010c2fb21c5387a4f06c3
If on-chain and computed hashes diverge, either the document was modified
post-commitment (would require an admin updateScoringRules tx to
re-commit) or the deployment is on a different chain.
Sepolia full-lifecycle gate
Every mainnet deploy is preceded by a Sepolia deploy of the same artifact, executing the full lifecycle that mainnet will exercise:
For wallets: predict → deploy via first userOp → grantSessionKey → transfer USDC → verify on-chain state.
For ERC-8004: register one agent via REGISTRAR → write one reputation event
via ATTESTOR → verify getReputation() and scoringRulesHash.
For ERC-8183 / CardZeroJobs: createJob → clientApprove → fund → submit →
evaluatorComplete → verify provider/evaluator/treasury splits match
platformFeeBps / evaluatorFeeBps → verify reputation auto-attestation.
Mainnet deploy is gated on Sepolia smoke test ≥ 1 day old, self-check 6/6 green, and founder review.
This gate caught a uint64 vs uint256 expiry ABI bug during Sprint 9 —
a function selector mismatch that would have caused silent reverts on
mainnet. Fixed before mainnet deploy.
Multisig + Timelock migration (planned, pre-audit)
Current state: ADMIN_ROLE on all four UUPS contracts (CardZeroJobs,
CardZeroIdentityRegistry, CardZeroReputationRegistry,
CardZeroWalletV3 implementation upgrades) is held by DEPLOYER EOA.
Target state:
DEPLOYER EOA → Safe (2-of-3, Base) → TimelockController (48h delay) → ADMIN_ROLE
Why both, not just one:
- Safe alone, no timelock: removes single-key SPoF but allows surprise upgrades. Community has no review window.
- Timelock alone, no Safe: gives 48h review window but proposer is still a single key. If compromised, attacker queues a malicious upgrade and waits.
- Safe + Timelock: removes SPoF AND gives community review. Attacker needs to compromise 2 of 3 signers AND wait 48h, during which the queued upgrade is visible on-chain and revocable.
Sequence (per contract):
- Deploy TimelockController on Base mainnet (proposer = Safe, executor = Safe, delay = 172800s)
- From DEPLOYER:
contract.grantRole(ADMIN_ROLE, timelock) - Verify on Basescan:
contract.hasRole(ADMIN_ROLE, timelock) == true - Test: queue a no-op admin call via Safe → wait 48h → execute → verify
- From DEPLOYER:
contract.renounceRole(ADMIN_ROLE, deployer) - Verify on Basescan:
contract.hasRole(ADMIN_ROLE, deployer) == falseANDcontract.getRoleMemberCount(ADMIN_ROLE) == 1
Each step is a separate tx, individually visible on Basescan, in this
order. Step 5 is the point of no return — after renounceRole, only
the Safe (through the timelock) can administer the contract.
Rollback options:
- Before Step 5: revert by
grantRoleback to deployer +revokeRole(timelock). - After Step 5: rollback requires the Safe to queue a
grantRole(ADMIN_ROLE, newKey)tx through the timelock — same 48h window.
Staged rollout — transparent disclosure
CardZero's migration to Safe + Timelock happens in two stages. We're stating both stages publicly so peer protocol teams and auditors can evaluate the actual threat model, not the marketing version.
Stage 1 (launch): Single-principal 2-of-3 Safe with hard isolation.
- All three Safe signer keys held by Nicholas
- Three independent hardware wallets (mixed manufacturers: Ledger + Trezor, no shared firmware surface)
- Three independent physical storage locations
- Two independent workstation environments — different machines, isolated browser profiles, no shared wallet extension state
- Three seed phrases generated at separate times, written to paper, stored in different geographic locations
What this defeats:
- Remote single-machine compromise (each machine only has access to one key)
- Single physical-location compromise (other two keys are elsewhere)
- Loss of one hardware wallet (other two can administer + queue a rotation)
What this does NOT defeat (honest acknowledgement):
- Coercion of Nicholas (single principal)
- Social engineering of Nicholas (single decision-maker)
- Loss of all three storage locations simultaneously
- Legal compulsion (single legal entity holds all keys)
Auditors should classify this as "single-principal 2-of-3 multisig with hard physical and machine isolation" — strictly stronger than a single EOA, materially weaker than independent-principal multisig.
Stage 2 (T+90 days from launch): Independent-principal 2-of-3 Safe.
One of the three Safe signer keys is rotated to an external passive signer — an independent natural person who:
- Physically holds one hardware wallet (set up by Nicholas, transferred for ongoing custody)
- Is not required to understand cryptography or operate independently
- Executes signatures only in response to live communication from Nicholas
- Holds an anti-coercion safe word that, if spoken by Nicholas, signals "do not sign"
Rotation is executed via the Safe's swapOwner(prevOwner, oldOwner, newOwner)
function. Old key is destroyed.
Public commitment: Stage 2 timeline is documented in
docs/MULTISIG-TIMELOCK-MIGRATION.md. We will post a thread-update to the
magicians forum confirming Stage 2 execution with the Basescan tx links. If
Stage 2 slips beyond 120 days from launch we will publicly explain why.
Pauser role separation
Each upgradeable contract supports pause() as a monotonic-toward-safety
operation (freeze only, no fund movement). The pauser role is held by a
separate hardware wallet distinct from the three Safe signers — a fourth
independent device. Rationale:
- Pause is the appropriate response to a discovered active exploit
- Running pause through the 48h timelock would be too slow (funds drained before the queued tx executes)
- Pause cannot be used adversarially to extract funds — worst case is contract operations are halted, which is exactly the safety property we want
- Compromise of the pauser key alone causes no fund loss; recovery is via Safe granting pause to a new key
The pauser key shares the machine-isolation discipline of the Safe signers (separate workstation, separate browser profile, separate physical location).
Post-deploy ops as separated transactions
Operations that could be bundled but are intentionally separated:
| Operation | Why separate |
| --- | --- |
| Jobs.setReputationAttestor(reputationRegistry) | Wires Jobs → Reputation. Done after Reputation has granted Jobs the ATTESTOR_ROLE. Independently reviewable. |
| Reputation.grantRole(ATTESTOR_ROLE, jobs) | Allows Jobs to attest reputation on finalizeJob. Independently reviewable. |
| Per-EOA role grants (grantRole(REGISTRAR_ROLE, registrarEOA), etc.) | Each grant reviewed independently. |
| renounceRole(ADMIN_ROLE, deployer) (after multisig migration) | Point of no return; deserves its own audit-trail entry. |
Bundling into a multicall would shave gas but lose the per-step audit trail. We pay the gas premium.
Open invitations
We welcome:
- Auditor scoping reviews against this document
(
[email protected]) - Peer-protocol comparisons: send your equivalent and we'll publish a side-by-side
- Issues filed against any of the linked commitments at github.com/mrocker/CardZero
- Patches to the salt computation, role isolation, or migration plan that strengthen the model without breaking address determinism
See also: Contract addresses, Changelog.