Onchain wallets for eSIM data plans. A phone gets one smart wallet controlled by a passkey, and every eSIM on that phone gets its own wallet that buys data bundles and keeps the record of what it bought.
The wallets are ERC-4337 accounts. Signatures are WebAuthn assertions over a P256 key held in the device's secure enclave, so no seed phrase appears anywhere in the flow.
Two proxy layers, and most of the risk sits in them. Four UUPS singletons hold protocol state and upgrade one at a time. Every wallet is a beacon proxy, so one beacon call moves every wallet of that kind at once. There is no per-wallet opt-out, which makes any beacon change a protocol-wide upgrade.
| Contract | What it does | Pattern |
|---|---|---|
Registry |
Central record of every device and eSIM wallet. Also holds the protocol admin, the vault, the pause switch and the data bundle price ceiling | UUPS proxy |
RegistryHelper |
The registry's storage and lazy deployment half | Inherited by Registry |
LazyWalletRegistry |
Holds data bundle history for users paying in fiat, keyed by device and eSIM identifier strings, until they ask for a wallet | UUPS proxy |
DeviceWalletFactory |
Deploys device wallets at deterministic CREATE2 addresses and owns their beacon | UUPS proxy |
DeviceWallet |
One per phone. Holds ETH and tokens, owns the eSIM wallets on that device, verifies the passkey | Beacon proxy |
ESIMWalletFactory |
Deploys eSIM wallets and owns their beacon | UUPS proxy |
ESIMWallet |
One per eSIM. Buys data bundles, keeps purchase history, pulls tokens from its device wallet | Beacon proxy |
PaymentAdapter |
The currencies the protocol accepts, the one conversion from USD cents into a token amount, and the payment references each spendable once. Moves tokens to the vault | UUPS proxy |
Account4337 |
The ERC-4337 IAccount and IERC1271 base that DeviceWallet builds on |
Inherited by DeviceWallet |
WebAuthn |
Verifies WebAuthn authentication assertions. Tries the RIP-7212 precompile first and falls back to FreshCryptoLib | Library |
P256Verifier |
One immutable address for accounts to verify through, wrapping the WebAuthn library | Plain contract |
ProtocolAdmin |
Timelock meant to own the five singletons. Adds a delay floor that updateDelay cannot go under, and a guardian role with exactly two powers |
Plain contract |
Errors |
Every custom error in the suite | Library |
CustomStructs |
Structs shared across contracts | Types |
interfaces/ |
IPausable, IOwnable2Step and IRegistryAdmin for the calls ProtocolAdmin makes back into the protocol; IPaymentRegistry for what PaymentAdapter reads from the registry |
Interfaces |
A backend server generates the device and eSIM identifiers and writes them into the wallets. That is what ties an onchain wallet to a provisioned eSIM.
Foundry 1.7.1, solc 0.8.36, evm_version = "osaka".
git clone --recurse-submodules https://github.com/Blockchain-Powered-eSIM/smart-contract-suite.git
cd smart-contract-suite
forge build --sizes
forge testThis repo does not compile without via-IR. via_ir = true is already in foundry.toml, so plain
forge commands pick it up. A cold build takes about 30 seconds.
Dependencies are git submodules under lib/.
Hardhat is wired up through @nomicfoundation/hardhat-foundry and compiles the same sources, so npx hardhat compile produces byte-identical bytecode.
That parity depends on bytecode_hash = "none" in foundry.toml and metadata.bytecodeHash: "none" in hardhat.config.js. Removing either breaks it.
819 tests across 73 suites, about eight minutes for a full run.
| Suite | Files | What it covers |
|---|---|---|
test/foundry/unit-testing/ |
44 | Per-contract behaviour, access control, revert paths, storage layout and initialiser locks |
test/foundry/fuzz-testing/ |
10 | Identifier and array shapes, price ceilings, token settlement, signature shapes, a P256 differential against the precompile |
test/foundry/invariant-testing/ |
9 | Registry consistency, wallet ownership, payment accounting, purchase history, timelock behaviour |
test/foundry/gas/ |
8 | Per-operation gas with every input pinned, offchain signatures included |
test/foundry/fork/ |
1 | A user operation through the deployed EntryPoint on both chains |
Branch coverage is 92.58%, or 237 of 256 arms, with WebAuthn at 80% the lowest and
Account4337, ProtocolAdmin, DeviceWallet and PaymentAdapter at 100%.
Measure it with scripts/checks/branch-coverage.py rather than reading forge's own percentage:
forge coverage --ir-minimum does not count require(cond, "string") as a branch, so those sites
report zero hits on both arms however often they run. The contracts use custom errors throughout, so nothing is currently excluded, but the raw number stops meaning anything the moment a require
string is added.
forge coverage --ir-minimum --report lcov --report-file lcov.info \
--no-match-path "test/foundry/{fork,invariant-testing}/*"
python3 scripts/checks/branch-coverage.py lcov.infoThe fork tests read ALCHEMY_OP_SEPOLIA_HTTPS and ALCHEMY_BASE_SEPOLIA_HTTPS. They skip rather
than fail when those are unset, so the suite runs without credentials.
The long invariant campaign is a separate profile, run before a release rather than on every change:
FOUNDRY_PROFILE=campaign forge test --match-path "test/foundry/invariant-testing/*"The deployment scripts have their own suite, kept out of the default run:
FOUNDRY_PROFILE=scripts forge test --threads 150 tests in test/scripts/, exercising Deploy.s.sol, Configure.s.sol and TransferOwnership.s.sol
against a scratch record. scripts/fork/rehearse.sh runs the same three against a Base Sepolia fork
and checks the happy path better than any test can, but it needs an RPC key and a live anvil, so CI
never runs it. These reach what it cannot: the resume branches and the mismatch guards, which only
fire on a deployment that has already gone wrong.
--threads 1 is not optional. The scripts read their parameters from the process environment and
their record from one file, and forge runs every setUp before any test and then runs the tests in
parallel. Without the flag, somewhere between 2 and 12 of the 50 fail on each run, and which ones
changes every time.
Two gas baselines, measuring different things. .gas-snapshot is the whole-test-body figure, useful as a regression tripwire and not as a protocol gas number, since a row can include whatever that test deployed.
snapshots/*.json is per operation, written by the tests under test/foundry/gas/ and rewritten by any ordinary forge test.
Audit. Reviewed by CD Security in March 2025. The report is in audits/2025-03-CDSecurity.pdf.
Formal verification. Eight Certora specs, 83 rules.
| Spec | Rules | Subject |
|---|---|---|
Registry.spec |
18 | Wallet registration and association records |
PaymentAdapter.spec |
15 | The currency table, the payment references, and what a settlement moves |
ProtocolAdmin.spec |
11 | The delay nothing gets around, and the two things a guardian can say |
ESIMWallet.spec |
10 | Ownership state machine, price ceiling, deploying factory |
ESIMWalletFactory.spec |
10 | Registry wiring, beacon control, the record of what it deployed |
DeviceWalletFactory.spec |
8 | Deterministic deployment and beacon control |
DeviceWallet.spec |
7 | Owner key, eSIM wallet set, funds access flags |
RegistryCrossContract.spec |
4 | Registry, DeviceWallet and ESIMWallet agreeing on who holds an eSIM wallet |
Three caveats attach to every proof. Loops unroll three times, so a result covers batches of at most three rather than all batches. Hashing of unbounded arguments is assumed within 224 bytes, raised to 1600 in ESIMWalletFactory.spec so its CREATE2 address prediction stays reachable. External calls are summarised one signature at a time.
LazyWalletRegistry has no spec, and the reason is every mapping in it is string-keyed, and Certora's storage analysis fails on any method taking a string.
Its properties are carried by Foundry invariants instead.
Static analysis. Slither and Aderyn run before anything substantial is committed:
slither . --filter-paths "test/,script/,lib/,node_modules/"
aderyn .Trust model, as it stands. On the v0.8 Base Sepolia deployment, ProtocolAdmin owns all five
UUPS proxies, PaymentAdapter included, and both factories that own the beacons. Every owner gated
call now waits out a two day delay, proposing is 2-of-3 or a cold key, and a 3-of-3 guardian
executes. The older deployments are not on that footing: one EOA still owns everything on the v0.7
Base Sepolia and OP Sepolia deployments, so a single key compromise reaches every wallet there in one
transaction. Admin transactions go into the public mempool on all three, with no private relay in
front of them.
The v0.7 Base Sepolia and OP Sepolia deployments predate PaymentAdapter, so neither carries it or
the fifth singleton it adds.
Testnet only. The v0.8 column is the current deployment, from commit 3397b6a,
the first to carry PaymentAdapter. The two older columns bind the v0.7 EntryPoint and were built
from an earlier commit. They were redeployed rather than upgraded, because the EntryPoint address is
immutable in the wallets.
| Contract | Base Sepolia (EP v0.8) | Base Sepolia (EP v0.7) | OP Sepolia |
|---|---|---|---|
RegistryProxy |
0x916b6b554119c789EF3026EDeB0E1Ba741b42A49 |
0xCa447f5C75C57f6C59027304A5Fb5A09F0E005c9 |
0x96dA9cE92D2C09f7b3ADE01260608e9079f16d12 |
LazyWalletRegistryProxy |
0x5bE46Cf216186Bc2E3C220729331D6bE7d186e84 |
0x8a1E53b903efcc6b252CE4bD3b255202318505Ef |
0x3F14D060074B174B0784056bDe5e0f8970D25ff1 |
DeviceWalletFactoryProxy |
0x0BB3BA8D9233514a4aA6D72c243a2473f9cFf0bb |
0xB4473979ff8cE4e09161B08f74EEb66BD7718076 |
0x243cCdE6a56b0Ba740E067f39896772748E20fFD |
ESIMWalletFactoryProxy |
0x57da54e07705de17c713ec311ac193e83470D5a5 |
0x63005d8214533fC7209678Aa39F7b9b0b51a7bcB |
0x8444bF9C39F01e4B092e42DC11695C61f8B93957 |
PaymentAdapterProxy |
0xBFaA666a8074924588E96507c307b680ecCeB2c1 |
not deployed | not deployed |
DeviceWalletImpl |
0x572BF04F9Ed9b0213C127EFC0215477fa3D5CffB |
0xde0dC03eF67317D4702e1d6Ef3f8cE246517e84e |
0x22FCFa80868dc9F423873F9332817eDAe4483974 |
ESIMWalletImpl |
0x06b8F9986fD15034364Fa8d98A9E959613400B07 |
0x59A78Cbb73e94a3fD6ada0136C89AE658BA16Dd9 |
0xf86FE9253b6ea9454abda657f47aE508B00c15C1 |
DeviceWalletBeacon |
0x81Ac8133Ab32151460898D402E2f0dd0c6FfA30f |
not recorded | not recorded |
ESIMWalletBeacon |
0xc2C09990cA54c3B0A88C9F51d540f9cf78F4D424 |
not recorded | not recorded |
P256Verifier |
0x6FA3E7E145476Dc4682734Fd845019A3872b4821 |
0xF04f3b3935aD461D17d4a8a78E7ea21d4a61AEb1 |
0x3c15a78046838481788613A9F111F972B562623C |
ProtocolAdmin |
0xdDeCC2C1345BC966337B5f4Fe57EC2D5bfad751A |
not deployed | not deployed |
EntryPoint |
0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 |
0x0000000071727De22E5E9d8BAf0edAc6f37da032 |
0x0000000071727De22E5E9d8BAf0edAc6f37da032 |
The full list, including the Ethereum Sepolia deployment, is in deployments/address.json. That file is addresses only. What the v0.8 deploy captured beyond them, build provenance, every transaction, gas, constructor arguments and verification status, is in deployments/base-sepolia-84532-entrypoint-v8.json.
The two chains are not symmetric in one way that file does not record. The owner EOA carries an EIP-7702 delegation on Base Sepolia and none on OP Sepolia, so the same address is a smart account on one chain and a plain EOA on the other.
- Registry
- Registry Helper
- Lazy Wallet Registry
- Protocol Admin
- Device Wallet Factory
- Device Wallet
- eSIM Wallet Factory
- eSIM Wallet
- Payment Adapter
- Account4337
- Upgradeable Beacon
- Custom Structs
- Errors
- Ownable Two-Step Interface
- Pausable Interface
- Registry Admin Interface
- Payment Registry Interface
- P256 Verifier
- WebAuthn
-
Install the app and register a passkey. The P256 key lives in the device's secure enclave and never leaves it.
-
Deploy the wallets. For a new device, the app asks the registry for a device wallet and one eSIM wallet, linked at deployment.
-
Pick and buy a data bundle. Paying in crypto deploys both wallets immediately, in an ERC-20 the payment adapter has been given a price for. Paying in fiat records the purchase in the lazy wallet registry, and the wallets are deployed later if the user asks for them.
Paying in ETH is not currently supported. The adapter works the amount out from the price, and nothing onchain prices ETH, so it comes back when the adapter can swap into the settlement token.
-
Provision the eSIM. The server generates the eSIM identifier, writes it into the eSIM wallet through the device wallet, and returns a QR code for activation.
-
Use the device wallet. It holds ETH and ERC-20 tokens and can be used as an ordinary wallet. Funds can be withdrawn at any time.
