Pipeline that fetches Taiwan MOICA Certificate Revocation Lists (CRLs), builds a Sparse Merkle Tree (SMT) from revoked serial numbers, serves ZK-friendly membership/non-membership proofs via REST and gRPC, and posts roots on-chain.
MOICA CRL (DER)
β
CRL Fetcher/Parser (internal/crl)
β
βΌ
TreeManager (internal/manager) ββ per-issuer SMTs (g2, g3)
β β
βΌ βΌ
REST API (chi) + gRPC API Chain Relayer
GET /proof/{issuerId}/{sn} posts root on-chain
GET /status via SMTRootStorage.sol
cd server
make build # β bin/smtserver
make run # starts REST + gRPC servers
# or run tests
make test
# integration tests (API E2E ~10s + live CRL fetch ~30min)
make test-integrationPre-built SMT snapshots are published as GitHub Release assets (snapshot-latest tag), updated twice daily.
When the server starts, it automatically:
- Loads local snapshots from
$DATA_DIR/{issuerID}/tree-snapshot.json.gz - Falls back to downloading from the GitHub release
- Falls back to rebuilding from live CRL data (slow, ~30 min)
To manually download snapshots:
# Download latest snapshots
cd server
mkdir -p data/g2 data/g3
curl -L -o data/g2/tree-snapshot.json.gz \
https://github.com/moven0831/moica-revocation-smt/releases/download/snapshot-latest/g2-tree-snapshot.json.gz
curl -L -o data/g3/tree-snapshot.json.gz \
https://github.com/moven0831/moica-revocation-smt/releases/download/snapshot-latest/g3-tree-snapshot.json.gzSnapshots are available in two formats:
- JSON (
.json.gz) β compatible with@zk-kit/smtv1.0.2, used by the server - Binary (
.bin.gz/.bin) β compact format for client-side WASM loading (see Client-Side WASM)
The latest Merkle roots for each issuer are displayed in the snapshot-latest release notes. Since the SMT is deterministic, anyone can independently verify a root by rebuilding from the same CRL data.
# Check server status
curl localhost:3000/status
# Query a revoked certificate (membership proof)
curl localhost:3000/proof/g2/100048210dd2df2e128096a9282b5ec5
# Query a non-revoked certificate (non-membership proof)
curl localhost:3000/proof/g2/00000000000000000000000000000001Returns a membership or non-membership proof. Serial number accepts hex with or without 0x prefix (max 32 hex chars).
{
"issuerId": "g2",
"serialNumber": "0x100048210dd2df2e128096a9282b5ec5",
"entry": ["0x...", "0x...", "0x1"],
"matchingEntry": ["0x...", "0x...", "0x1"],
"siblings": ["0x...", "0x...", "..."],
"root": "0x3c2151...",
"membership": false,
"depth": 128
}entryβ[key, value, 1]for the queried serialmatchingEntryβ present only for non-membership proofssiblingsβ up to 128 sibling hashes (varies by proof)- All BigInt values are 0x-prefixed hex strings
{
"generations": {
"g2": {
"loaded": true,
"count": 412404,
"root": "0x3c2151...",
"crlNumber": 2026031610,
"loadedAt": "2026-03-16T08:00:00Z"
}
},
"uptimeSeconds": 3600.5
}Service RevocationProofService on port 50051 with GetProof and GetStatus RPCs. See server/pkg/proto/revocation/revocation.proto.
SMTRootStorage.sol β on-chain registry for SMT roots.
Deployed contract:
| Network | Address |
|---|---|
| Ethereum Mainnet (production) | 0xf3aAAe2D017dcC9cA901aDC9Da419f1C70362ab1 |
| Arbitrum Sepolia (legacy testnet) | 0xc461326eb6e46F10A276B0F14BFFf8b256A43FFA |
The contract stores SMT Merkle roots on Ethereum Mainnet so anyone can verify certificate revocation status against a trusted root. A CI relayer updates roots after rebuilding from MOICA CRL data (cron runs twice daily; a transaction is sent only when the CRL actually changes). Each root is tied to a monotonically increasing CRL number to prevent stale updates. Anyone can call getRoot(issuerId) to read the latest root and verify proofs off-chain.
| Function | Description |
|---|---|
setRoot(bytes32 issuerId, uint256 newRoot, uint256 crlNumber) |
Update root (relayer only, monotonic CRL number) |
getRoot(bytes32 issuerId) β uint256 |
Read current root |
Issuer IDs: keccak256("MOICA-G2"), keccak256("MOICA-G3")
Deploy to Ethereum Mainnet (rehearse on the sepolia L1 testnet first):
- Generate a fresh, dedicated relayer keypair:
cast wallet new - Fund the relayer address with mainnet ETH (covers gas for ~2
setRoottx per CRL change; keep a buffer for gas spikes) - Deploy with the optimizer-enabled
productionbuild profile:
cd onchain-contract
nvm use 22
pnpm install
npx hardhat ignition deploy ignition/modules/SMTRootStorage.ts \
--network mainnet --build-profile production \
--parameters '{"SMTRootStorageModule": {"relayer": "0x<RELAYER_ADDRESS>"}}'- Verify the source on Etherscan for transparency
- Set GitHub Actions secrets (
RPC_URLβ mainnet RPC,RELAYER_PRIVATE_KEYhex no0x,CONTRACT_ADDRESSβ deployed address) and optionally the variablesRELAYER_MAX_FEE_GWEI/RELAYER_TX_TIMEOUT_SECto tune the gas ceiling and confirmation timeout
Post roots on-chain manually (reads root.json files, skips gracefully if env vars are unset):
cd server
./bin/smtbuild --post-rootSee onchain-contract/README.md for detailed setup instructions.
| Variable | Default | Description |
|---|---|---|
PORT |
3000 | REST API port |
GRPC_PORT |
50051 | gRPC port |
DATA_DIR |
./data |
Storage for CRL data and snapshots |
CRL_G2_URL |
MOICA G2 endpoint | CRL download URL for G2 issuer |
CRL_G3_URL |
MOICA G3 endpoint | CRL download URL for G3 issuer |
CRL_POLL_INTERVAL |
21600 (6h) | CRL polling interval in seconds |
RPC_URL |
β | Ethereum JSON-RPC URL |
RELAYER_PRIVATE_KEY |
β | Hex private key for chain relayer |
CONTRACT_ADDRESS |
β | SMTRootStorage contract address |
RELAYER_MAX_FEE_GWEI |
100 | Max gas fee per gas the relayer will pay; aborts posting above this ceiling |
RELAYER_TX_TIMEOUT_SEC |
180 | Per-tx confirmation timeout before a same-nonce fee-bumped resend |
GITHUB_REPO |
moven0831/moica-revocation-smt |
GitHub repo for snapshot releases |
A Go WASM module (smt.wasm, ~3.3MB) enables loading the full SMT and generating proofs entirely in the browser β no server round-trip needed.
Build: cd server && make build-wasm (requires Go 1.24+)
The WASM module exposes these functions on globalThis:
| Function | Signature | Description |
|---|---|---|
smtInitTree |
(nodeCount, depth) |
Pre-allocate tree structure |
smtAddNodeChunk |
(Uint8Array) β number |
Stream binary nodes in chunks, returns count parsed |
smtFinalize |
(rootHex, leafCount) |
Set root and finalize tree for proof generation |
smtCreateProof |
(keyHex) β string |
Generate proof, returns JSON with root, entry, matchingEntry, siblings |
smtVerifyProof |
(proofJSON) β boolean |
Verify a proof |
smtGetMemStats |
() β string |
Go runtime memory stats as JSON |
Proof output format: all values are bare hex (no 0x prefix), matching the server's REST API structure.
The binary format is a compact representation of the SMT node tree, designed for efficient chunked streaming to WASM.
Header (52 bytes, big-endian):
[0:2] magic uint16 0x534D ("SM")
[2:4] version uint16 1
[4:8] nodeCount uint32
[8:40] rootHash [32]byte
[40:44] depth uint32
[44:52] crlNumber uint64
Per node (variable size):
[0:1] type uint8 (0=branch, 1=leaf)
[1:33] hash [32]byte
Branch: [33:65] left [32]byte, [65:97] right [32]byte (97 bytes total)
Leaf: [33:65] key [32]byte, [65:97] value [32]byte, [97:129] entryMark [32]byte (129 bytes total)
Build binary snapshots: cd server && make build-binary or ./bin/smtbuild --binary
Convert existing JSON snapshot: ./bin/smtbuild --convert-binary data/g2/tree-snapshot.json.gz
Web or mobile clients can use the published release artifacts to load an SMT and generate proofs locally:
- Load runtime β include
wasm_exec.js(Go's JS glue), instantiatesmt.wasmviaWebAssembly.instantiateStreaming - Fetch snapshot β download
g2-tree-snapshot.bin.gzand decompress (browser:DecompressionStream, native:gziplibrary) - Build tree β parse 52-byte binary header β
smtInitTree(nodeCount, depth)β stream nodes viasmtAddNodeChunk(chunk)in batches of ~10,000 βsmtFinalize(rootHex, leafCount) - Generate proof β
smtCreateProof(serialNumberHex)β parse JSON β convert hex to decimal strings β pad siblings to depth 128 - Feed to circuit β proof fields (
smtRoot,smtSiblings[128],smtOldKey,smtOldValue,smtIsOld0) become inputs forSMTNonMembershipVerifier(128)in the zkID circom circuit
The snapshot-latest release (updated twice daily) includes:
| Asset | Size | Description |
|---|---|---|
smt.wasm |
~3.3MB | Go WASM module |
wasm_exec.js |
~17KB | Go WASM runtime support |
g2-tree-snapshot.bin.gz |
~71MB | Binary snapshot (gzip-compressed) |
g2-tree-snapshot.json.gz |
~76MB | JSON snapshot (server) |
| Metric | Desktop (M3, Chrome) | iPhone (Safari) | Android (Chrome) |
|---|---|---|---|
| Download | 6ms (localhost) | 103ms | 157ms |
| Decompress (.bin.gz) | 296ms | 14.4s | 14.9s |
| Tree load | 2.85s | 1.74s | 5.86s |
| Proof generation | <1ms | 1ms | 3ms |
| WASM heap | 442MB | 424MB | 424MB |
| Total | 3.2s | 16.3s | 21.1s |
Run the benchmark locally: cd server && make benchmark β opens http://localhost:8080/benchmark.html
The same binary snapshot and proof format work across platforms:
- Web β Go WASM (
smt.wasm) loaded viawasm_exec.js+WebAssembly.instantiateStreaming - Mobile (iOS/Android) β Go mobile bindings via
gomobile bind(.xcframeworkfor Swift,.aarfor Kotlin) using the same binary format - Proof conversion β hex-to-decimal + sibling padding is ~50 lines of platform-agnostic logic, easily ported to any language
- Circuit β same
SMTNonMembershipVerifier(128)circom circuit, same witness format; mopro handles mobile proving
ci.yml β runs on push/PR to main:
- Go server:
go test ./...+ build binary - WASM: verify
smt.wasmcompiles (GOOS=js GOARCH=wasm) - E2E integration: downloads real G2 snapshot (~412k entries), verifies proofs via REST + gRPC
- Contracts:
npx hardhat test(Node 22)
update-smt.yml β runs twice daily at 12:00/00:00 UTC+8 (04:00/16:00 UTC):
- Build server binary + WASM module
- Fetch CRL, build SMT, export JSON + binary snapshots (skipped if merkle root is unchanged)
- Upload snapshots, WASM module, and
wasm_exec.jsto GitHub Release (snapshot-latest) - Post root on-chain via
smtbuild --post-root(Ethereum Mainnet), bounded by theRELAYER_MAX_FEE_GWEIgas ceiling
Required secrets: RPC_URL, RELAYER_PRIVATE_KEY, CONTRACT_ADDRESS (on-chain posting skips gracefully if unset)
Wire-compatible with @zk-kit/smt v1.0.2 (bigNumbers mode):
- Hash: Poseidon over P-256 base field via
go-poseidon-p256 - Tree depth: 128 (sufficient for MOICA 64β128 bit serial numbers; halves proof size vs 256)
- Path encoding: LSB-first (
big.Int.Bit(i)for i in 0..127) - Leaf node:
Hash3(key, value, 1)β the1is the entry mark - Branch node:
Hash2(left, right) - Proof: entry
[key, value, 1]+ up to 128 siblings; non-membership includes optional matching entry
| CRL | Revoked Certs | File Size |
|---|---|---|
| G2 | ~412,000 | ~20MB DER |
| G3 | ~103,000 | ~5MB DER |
- MOICA β Taiwan citizen digital certificate
- Poseidon Hash β ZK-friendly hash function
- Hadeshash spec β Round number parameters
- zkID β ZK identity verification project