z2-zetrix-mcp
v0.1.5
Published
MCP server for the Z2 rollup: deposit/withdraw across L1 (Zetrix) and L2 (Besu) for native ZETRIX, ZTP-20, and ZTP-721, plus L2 info.
Maintainers
Readme
z2-zetrix-mcp
An MCP server for the Z2 optimistic rollup. It exposes the L1 (Zetrix) and L2 (Besu EVM) sides and the bridge between them as typed tools, so an agent (Claude Desktop/Code, Cursor, …) can deposit, withdraw, and read L2 state for native ZETRIX, ZTP-20, and ZTP-721 — without hand-crafting node calls, leaf hashes, or confirmation polling.
It's intent-driven: a user says "withdraw 5 ZETRIX to my account ZTX3…" and the server resolves the asset, child token, burn, confirmation wait, and claim.
Multi-withdrawal claims are supported. When several withdrawals share one assertion window, its send root is a Merkle tree over all of them (not
root == leaf). The server enumerates the window, rebuilds that tree, and derives the exit's proof automatically. It only needs the original ZTX3 token of each other non-native withdrawal in the assertion — pass them aswindowTokenson the claim (native siblings need none). A sibling whose token isn't supplied fails closed rather than producing a wrong proof. See §5 and docs/03-bridge-flows.md §6.
- Read-only by default. Write (signing) tools appear only when
Z2_ALLOW_WRITES=true. - Fail-closed claims. A withdrawal is claimed only after its leaf is proven to match the on-chain send root.
- No custody. Keys are passed per call (or via env), never stored, never logged.
Full design docs are in docs/ (00 overview → 08 deployment).
1. Install
Requires Node.js ≥ 20. Published to npm as z2-zetrix-mcp — no clone or build:
npx z2-zetrix-mcp # run on demand (what MCP clients use)
npm install -g z2-zetrix-mcp # or install the `z2-zetrix-mcp` binary globallyYou normally don't run it by hand — the MCP client launches it for you (see §2).
git clone https://github.com/Zetrix-Chain/z2-zetrix-mcp.git
cd z2-zetrix-mcp && npm install && npm run build # dist/index.js is the entry
npm test # unit tests (offline)
npm run dev # run from source (tsx, no build)Zero-config on testnet
You don't need to configure anything to use testnet. The package ships a bundled
profile with the current testnet addresses and endpoints, so npx z2-zetrix-mcp runs
read-only out of the box — no profile file, no endpoint URLs, no contract addresses to
look up. You only add config to enable writes (a signing key, §2) or to target a
different deployment (e.g. mainnet).
Deployment profile (only for non-default deployments)
A profile JSON holds a deployment's contract addresses (L1 Bridge/Outbox/Rollup, L2
L2Bridge/WithdrawalManager/StateReceiver/wZETRIX) + endpoints + chainId. Z2_PROFILE
defaults to the bundled profiles/testnet.example.json; point it at your own file for
another deployment:
# save a profile JSON anywhere and set Z2_PROFILE to its absolute path
# (start from the bundled profiles/testnet.example.json as a template)At startup the server validates the profile against the live chain (Bridge wiring +
L2Bridge getters + chainId) and refuses to start on a mismatch — so it can never act
against a stale/wrong deployment. Regenerate it from the contract repo's
deployed-addresses-l1-<date>.json after any fresh redeploy.
2. MCP server settings
The server speaks MCP over stdio. Register it in your MCP client. On testnet you need no env at all — it defaults to the bundled testnet profile + endpoints, read-only.
Claude Code
# testnet, read-only — zero config
claude mcp add z2-zetrix-mcp -- npx -y z2-zetrix-mcp
# to enable deposits/withdrawals, add a signing key:
claude mcp add z2-zetrix-mcp -e Z2_ALLOW_WRITES=true -e Z2_L1_SIGNER_KEY=priv… -- npx -y z2-zetrix-mcpClaude Desktop / Cursor (JSON config)
{
"mcpServers": {
"z2-zetrix-mcp": {
"command": "npx",
"args": ["-y", "z2-zetrix-mcp"]
// testnet read-only needs no env. Add an "env" block (below) to enable writes
// or to target another deployment.
}
}
}Installed globally (
npm install -g z2-zetrix-mcp)? Use"command": "z2-zetrix-mcp", "args": []— or keepnpx, which also finds the global bin. From source, use"command": "node", "args": ["/abs/path/dist/index.js"].
To enable writes (deposit/withdraw/claim), set Z2_ALLOW_WRITES=true and provide a
signing key — per tool call (preferred) or as an env fallback. You only need the
key(s) for what you do: L1 key signs deposits/claims; L2 key signs the
withdrawal burn (see §4/§5).
"env": {
"Z2_ALLOW_WRITES": "true",
"Z2_L1_SIGNER_KEY": "priv…", // Zetrix (L1) key — deposits, mapToken, claim
"Z2_L2_SIGNER_KEY": "0x…" // EVM (L2) key — the withdrawal burn (omit if deposit-only)
}To target another deployment (e.g. mainnet), also set
Z2_PROFILEto your profile path (and optionallyZ2_L1_NODE_URL/Z2_L2_RPC_URLto override the endpoints).
Security: prefer passing
signerin the call over env keys (an env key is inherited by child processes / readable via/proc/<pid>/environ). Note that a key placed in a client config file (the JSON above / Claude Desktop / Cursor) is stored at rest on disk in that file — treat it like any other stored secret, or prefer per-call signers. For a shared HTTP-hosted instance, keep it read-only unless a scoped signer is deliberately provisioned behind TLS + auth. See docs/07-security.md.
Environment variables
| Var | Required | Default | Meaning |
|-----|----------|---------|---------|
| Z2_L1_NODE_URL | | test-node.zetrix.com | Zetrix L1 node host (no scheme). Override for another deployment. |
| Z2_L2_RPC_URL | | https://z2-test-node.zetrix.com | Besu L2 JSON-RPC URL. Override for another deployment. |
| Z2_PROFILE | | bundled testnet.example.json | Path to the deployment profile JSON. Override for mainnet/custom. |
| Z2_L2_CHAIN_ID | | 1337 | L2 chainId |
| Z2_L1_SECURE | | true | HTTPS to the L1 node |
| Z2_ALLOW_WRITES | | false | Register the signing tools |
| Z2_L1_SIGNER_KEY / Z2_L2_SIGNER_KEY | | – | Optional signer fallbacks |
| Z2_CONFIRM_TIMEOUT_MS | | 900000 | Max wait for assertion confirmation (withdraw) |
| Z2_POLL_INTERVAL_MS | | 45000 | Confirmation/relay poll cadence |
| Z2_LOG_LEVEL | | info | Log level (stderr; keys always redacted) |
3. Tools
Read tools need no key. Write tools require a signer and appear only when
Z2_ALLOW_WRITES=true. Every write tool supports dryRun: true — build + fee-estimate
(and, for a claim, run the full leaf verification) and return what would be submitted,
without sending.
Utility (pure/local)
| Tool | What it does |
|------|--------------|
| create_l2_account | Generate a new L2 (EVM) account — address + private key + mnemonic. Use the address as a deposit l2Recipient; keep the key to withdraw later. Nothing sent on-chain; key not stored (save it!). |
| derive_l2_address | L1 ZTX3 token → its L2 EVM address (first 20 bytes of sha256(utf8(ztx3))) + 32-byte slot |
| compute_leaf_hash | Compute an exit-leaf hash (pre-flight check for a claim) |
Read / query
| Tool | What it does |
|------|--------------|
| bridge_state | L1 Bridge wiring (owner/outbox/rollup/l2Counterpart) + native escrow + the L2 wZETRIX address + delayed-message count. Good liveness probe. |
| l2_balance | An account's L2 balance. asset: native resolves wZETRIX automatically (no address needed); ztp20/ztp721 take the L1 ZTX3 token and resolve the L2 child. Use it to confirm a deposit minted. |
| token_info | For a mapped L1 token: metadata + derived L2 root-token + deployed L2 child + bridge escrow |
| withdrawal_status | Where an L2 withdrawal is + whether it's claimable: decodes the event, finds the covering assertion, checks confirmation, verifies the leaf vs getSendRoot. |
| l1_query | Call any L1 contract query (escape hatch) |
| l2_call | Call any L2 EVM view (escape hatch) |
| get_l1_transaction | L1 tx outcome (CONFIRMED_OK/CONFIRMED_FAILED, ledgerSeq) |
| get_l2_receipt | L2 receipt (status, block, logs) |
Write / workflow (need Z2_ALLOW_WRITES=true)
| Tool | What it does |
|------|--------------|
| deposit | Deposit native / ztp20 / ztp721 into L2. Auto-approves the Bridge for tokens (and refuses to deposit if the approve isn't confirmed). The relayer mints on L2 after L1 finality. |
| withdraw | One-call L2→L1: burn on L2 → wait for the assertion to confirm → claim on L1. Returns both tx hashes, or a resumable handle if confirmation outlasts the timeout. |
| start_withdrawal | Just the L2 burn (returns the decoded Withdrawal); use with withdrawal_status + claim_withdrawal for manual control |
| claim_withdrawal | Claim a confirmed withdrawal on L1 (fail-closed). Rebuilds the Merkle proof automatically; for a multi-withdrawal assertion pass windowTokens (the other exits' ZTX3 tokens). |
| map_token | Owner-only: register an L1 ZTP-20/721 so it can be bridged |
| l1_invoke / l2_send | Signed contract-call escape hatches |
Full schemas + examples: docs/02-tools-reference.md.
4. How to use — DEPOSIT (L1 → L2)
No L2 address yet? Say "create an L2 account" →
create_l2_accountreturns a fresh0x…address + private key. Deposit to that address (you only need the address to receive — the private key stays with you and is only needed later to withdraw). L1 and L2 are separate keypairs: your L1ZTX3…account signs the deposit; the L20x…account just receives.
What the user prompts (natural language) → what the agent calls:
| You say | The agent calls |
|---------|-----------------|
| "Deposit 1 ZETRIX to my L2 address 0x6459…" | deposit { asset: "native", amount: "1000000", l2Recipient: "0x6459…" } |
| "Deposit 100000 DUSD (token ZTX3Tcct…) to 0x6459…" | deposit { asset: "ztp20", token: "ZTX3Tcct…", amount: "100000", l2Recipient: "0x6459…" } |
| "Bridge my NFT #3 of collection ZTX3b3Wh… to 0x6459…" | deposit { asset: "ztp721", token: "ZTX3b3Wh…", tokenId: "3", l2Recipient: "0x6459…" } |
What the user needs to provide in the prompt:
- the asset — "ZETRIX"/"native", or the token (its L1
ZTX3…address or symbol) for ZTP-20/721; - the amount (native/ZTP-20, in base units — 1 ZETRIX = 1,000,000 ZETA) or tokenId (NFT);
- the L2 recipient — the
0x…address to receive the minted asset; - an L1 signing key — passed as
signerin the call or configured asZ2_L1_SIGNER_KEY.
The tool submits the L1 deposit (auto-approving the Bridge for tokens) and returns the L1 tx hash. The relayer mints on L2 automatically after ~3 L1 blocks.
On L2 the asset arrives as a token contract, not the chain's gas coin: native ZETRIX
mints as the wZETRIX ERC-20, ZTP-20 as an ERC-20 child, ZTP-721 as an ERC-721 child.
Confirm the mint with l2_balance { holder, asset } (native needs no address — it
resolves wZETRIX for you):
l2_balance { holder: "0x…", asset: "native" } → your wZETRIX balance
l2_balance { holder: "0x…", asset: "ztp20", token: "ZTX3…" } → your child-token balancePreview a deposit first with dryRun: true.
5. How to use — WITHDRAW (L2 → L1)
Withdrawal is inherently multi-step (burn on L2 → wait for the optimistic-rollup
confirmation window → claim on L1). The withdraw tool does all of it in one call.
What the user prompts → what the agent calls:
| You say | The agent calls |
|---------|-----------------|
| "Withdraw 0.5 ZETRIX back to my L1 account ZTX3WJqe…" | withdraw { asset: "native", amount: "500000", l1Recipient: "ZTX3WJqe…" } |
| "Withdraw 50000 DUSD (ZTX3Tcct…) to ZTX3WJqe…" | withdraw { asset: "ztp20", token: "ZTX3Tcct…", amount: "50000", l1Recipient: "ZTX3WJqe…" } |
| "Withdraw my bridged NFT #3 (ZTX3b3Wh…) to ZTX3WJqe…" | withdraw { asset: "ztp721", token: "ZTX3b3Wh…", tokenId: "3", l1Recipient: "ZTX3WJqe…" } |
What the user needs to provide in the prompt:
- the asset — "ZETRIX"/"native", or the token's L1
ZTX3…address for ZTP-20/721 (the server resolves the L2 child to burn, and needs the ZTX3 to build the claim); - the amount (native/ZTP-20) or tokenId (NFT);
- the L1 recipient — the
ZTX3…address to receive the released asset; - an L2 signing key (
l2Signer, owns the L2 asset — signs the burn) and an L1 signing key (l1Signer, pays the claim). Both can come from env (Z2_L2_SIGNER_KEY/Z2_L1_SIGNER_KEY).
The tool burns on L2, polls until the covering assertion is CONFIRMED (up to
Z2_CONFIRM_TIMEOUT_MS, ~minutes), verifies the leaf against the on-chain send root,
then claims — returning { l2WithdrawTx, l1ClaimTx, releasedTo, … }. If confirmation
takes longer than the timeout, it returns a resumable handle; finish later with:
claim_withdrawal { l2WithdrawTx: "0x…", rootToken: "ZTX3Tcct…" } // rootToken omitted for nativePrefer step-by-step control? Use start_withdrawal → poll withdrawal_status until
claimable: true → claim_withdrawal. Preview any step with dryRun: true (a claim
dry-run still runs the full CONFIRMED + leaf-match verification).
Multi-withdrawal assertions (windowTokens)
Several withdrawals can be batched into one assertion — then its send root is a Merkle
tree over all of them, and each claim needs its proof within that tree. The server
builds this for you: it enumerates every withdrawal in the assertion window and rebuilds
the tree. To hash the sibling leaves it needs their original ZTX3 tokens, which aren't
recoverable from the L2 event — so supply the non-native ones via windowTokens:
claim_withdrawal {
l2WithdrawTx: "0x…", // the exit you're claiming
rootToken: "ZTX3Tcct…", // this exit's token (omit for native)
windowTokens: ["ZTX3Tcct…", "ZTX3b3Wh…"] // ZTX3 of the OTHER exits in the same assertion
}Single-withdrawal assertions need no windowTokens (the proof is empty, root == leaf).
If a sibling's token is missing the claim fails closed — it never guesses. The proof is
always re-verified against the on-chain getSendRoot before anything is submitted.
6. Safety model
- Read-only by default — write tools aren't registered unless
Z2_ALLOW_WRITES=true. - Fail-closed claims —
claim/withdrawsubmit only if the assertion is CONFIRMED and the computed Merkle root equalsgetSendRoot; otherwise they raise (AssertionNotConfirmed/LeafMismatch/RootTokenUnresolved) and submit nothing. dryRunon every write tool for preview-before-sign.- Deposit approve-gate — a token deposit is not attempted if the auto-approve isn't confirmed.
UNKNOWN≠ success — an L1 write whose outcome isn't observed returnsstatus: "UNKNOWN"(never a false success); poll the hash.- Keys — never persisted or logged; any signer that appears in an error is redacted.
- Profile validation — startup aborts if the profile doesn't match live on-chain wiring.
See docs/07-security.md.
7. Testing
npm test # unit tests (offline; golden vectors + mocked adapters)
# live integration against a real deployment (gated; needs a profile pointing at it)
Z2_INTEGRATION=1 \
Z2_L1_NODE_URL=test-node.zetrix.com \
Z2_L2_RPC_URL=https://z2-test-node.zetrix.com \
Z2_PROFILE=./profiles/testnet.json \
npm run test:integrationThe unit suite includes golden vectors seeded from proven testnet round-trips:
ZTP-20 & ZTP-721 leaf hashes verified against the real on-chain send roots, and the
real 3-leaf tree (native + ZTP-20 + ZTP-721 sharing send root e4a32ea5…) that
locks the multi-leaf proof builder byte-for-byte against the reexecutor. The gated
integration suite boots the real context (validating the profile against the live
chain) and verifies withdrawal_status against those proven exits.
8. Architecture (one line)
stdio server → tools (zod-validated) → services (bridge/rollup/outbox/withdrawal/
token/mapping/proof) → adapters (zetrix-sdk-nodejs for L1, ethers for L2) → crypto (pure,
byte-parity with the Outbox contract + reexecutor Merkle tree). See docs/01-architecture.md.
