Multichain addresses
The goal is one account address per user across every chain, while still using a different first signer on each chain (first-signer reuse across chains would weaken the few-time guarantee) and a single chain-independent backup key.
What the address commits to
The factory deploys accounts as CREATE2 minimal proxies, so the address is fixed before deployment and must be computed from inputs that are identical on every chain:
account address = f( factory address,
account implementation address,
initialSignerRoot, // Merkle root of per-chain first signers
backupSignerLeaf, // commitment to the SPHINCS backup key
user salt )The CREATE2 salt is domain-separated through InitialSignerCommitment:
backupSignerLeaf(pkSeed, pkRoot) = keccak256(abi.encode(
keccak256("NiceTryBackupSignerLeaf:v1(bytes32 pkSeed,bytes32 pkRoot)"),
pkSeed, pkRoot
));
accountSalt(root, backupLeaf, salt) = keccak256(abi.encode(
keccak256("NiceTryAccountSalt:v2(bytes32 initialSignerRoot,bytes32 backupSignerLeaf,uint256 salt)"),
root, backupLeaf, salt
));Two consequences:
- The first signer is not in the salt. It differs per chain, so it cannot be. Instead the salt commits to a Merkle root over all first signers, and each chain proves its own leaf at activation.
- The backup key is in the salt. A deploy-race front-runner who supplies a different SPHINCS key at
createAccountderives a different address, so nobody can install their own recovery key at the user's address.
The initial-signer tree
Each supported chain contributes one leaf:
initialSignerLeaf(chainId, signer) = keccak256(abi.encode(
keccak256("NiceTryInitialSignerLeaf:v1(uint256 chainId,address signer)"),
chainId, signer
));Base Sepolia (84532) -> signer A ┐
Arbitrum Sepolia (421614) -> signer B ├─ initialSignerRoot (same on every chain)
Ethereum Sepolia (11155111) -> signer C │
OP Sepolia (11155420) -> signer D ┘The root is identical everywhere because it is built from the same full list. At activation, the account rebuilds the leaf with block.chainid and verifies a Merkle proof, so on each chain only that chain's committed signer can activate, and a proof for one chain cannot be replayed on another.
Building the initial-signer tree
The canonical procedure is in the repo's docs/merkle-tree-generation-instructions.md. The non-negotiable rules:
-
Fixed 256-leaf tree (depth 8). Every proof has exactly 8 siblings, so
proofLen = 8and the activation header is0x010008. -
Sort real chains by numeric
chainIdascending, never by name, string, or input order. Reject duplicates, the zero address, and more than 256 chains. -
Real leaves first, then deterministic padding for every remaining slot up to index 255:
paddingLeaf(index) = keccak256(abi.encode( keccak256("NiceTryInitialSignerPaddingLeaf:v1(uint256 index)"), index ))Never use zero leaves, never duplicate the last real leaf, never pad only to the next power of two.
-
Sorted-pair hashing for every parent:
keccak256(min(a,b) ‖ max(a,b)), comparing as 32-byte big-endian values. This matches SoladyMerkleProofLib.verify, and it means proofs carry no left/right direction bits. -
Proofs are ordered leaf to root. Verify every proof locally before trusting the root.
Uncommitted chains
The SPHINCS backup key is chain-independent and already bound into the address, so it can activate the account on any chain, including ones absent from the tree. Committed chains use the cheap FORS+Merkle path; uncommitted chains use a SPHINCS signature with no Merkle proof. To onboard a new chain:
- Deploy the verifiers and the factory there deterministically (
script/Deploy.s.solordeploy-4337/). createAccount(root, backupPkSeed, backupPkRoot, salt)to get the counterfactual account (or let the first UserOp'sinitCodedo it).- Send a SPHINCS bootstrap UserOp. See Recovery & bootstrap.
Per-chain replay is prevented because userOpHash includes the chain id: one SPHINCS signature per chain.
Deployment uniformity
Because the factory and implementation addresses are part of the calculation, they must also match across chains. The deploy script deploys both verifiers and the factory through the standard CREATE2 deployer so the same bytecode, salts, and constructor args yield the same addresses everywhere. The invariant a wallet must verify before trusting a predicted address:
same ForsVerifier address
same SphincsVerifier address
same factory address
same account implementation address
same EntryPoint
same initialSignerRoot
same backup key (pkSeed, pkRoot)
same user saltIf any of these drift, the account address drifts. Compiler settings are part of "same bytecode": foundry.toml pins solc 0.8.30, optimizer 200 runs and via-ir.
Deployment race
The first UserOp may carry deployment initCode (standard ERC-4337). If someone else deploys the account first, that is harmless: the account is still inactive, still holds the expected root, and, because the backup key is in the salt, still holds the expected backup key. The original UserOp may fail (EntryPoint rejects initCode for an existing account); the wallet recovers by resubmitting activation with empty initCode after checking:
account code exists
initialSignerRoot() == expected root
backupPkSeed() / backupPkRoot() == expected backup key
activated() == false
ENTRY_POINT(), VERIFIER(), SPHINCS_VERIFIER() are the expected contractsBecause the resubmitted UserOp has a different hash, it needs a fresh activation signature. This is accepted under the FORS+C bounded-reuse policy for this specific race.
Full rationale: docs/multichain-consistent-addresses.md.