Documentation
Metatarz Wallet
A Canton-native, EVM-compatible browser wallet.
#Introduction
CIP-0056, CIP-0103 and Ethereum EIPs: EIP-6963, EIP-1193.#Canton support
#Install
Install for Google Chrome
Alternatively, a direct download link is also provided.
Chrome / Edge / Brave
1. Download the extension for your browser2. Extract the ZIP file to a folder3. Open chrome://extensions/ (or edge://extensions/)4. Enable "Developer mode"5. Click "Load unpacked" and select the extracted folder6. You can pin the extension to your toolbar for easy access#Getting started
#Whitelist your address
#Add an account
Add account. Provide a name for the account and your EVM private key. Metatarz stores the key locally and never transmits it. Upon creation, the wallet derives your Canton user party and provisions the on-chain resources needed to receive assets.#User party ID
user_<first 8 hex chars of your EVM address>::namespaceFor example, an address
0xA1B2C3D4E5F6... yields a party ID starting with user_a1b2c3d4::.... This party ID is what other Canton participants use to send you assets.#Transfer preapproval
#Send CC tokens
1. To send CC, click Send tokens in the wallet and fill in:- Amount — how many CC to send.
- Sent to address — a partyID or an EVM address if the user also uses Metatarz Wallet
- Memo — optional, but required when sending to most centralized exchanges to route the deposit to your account.
2. Click Prompt transaction. A review window opens with the full transaction details.3. Confirm by clicking Send. On success, the wallet shows a confirmation toast.#History & Assets
History tab shows the entry with a tx link to CCVIEW so you can view the update on-chain.The
Assets tab refreshes automatically with your new balance.#Connect to dApps
On the dApp, click
Connect wallet and pick Metatarz Wallet. If the dApp only lists MetaMask, pick MetaMask — Metatarz will respond as the injected provider.#Wallet API
/register/prepare-topologyStep 1 of external registration
/registerStep 2 of external registration
/api/v2/transfer/prepareBuild transfer / preapproval
/api/v2/transfer/executeSubmit signed transaction
#Authentication
| Header | Value |
|---|---|
X-Signature | EIP-191 signature over X-Message |
X-Message | Timed message, e.g. transfer:1710000000 |
Content-Type | application/json |
Messages must contain a Unix timestamp (seconds) after a colon and be within 5 minutes of server time, otherwise the request is rejected with
401 Unauthorized. Prefixes vary per endpoint:registration:<ts>— registration endpointstransfer:<ts>— transfer endpoints (also used for preapproval)
#POST /register/prepare-topology
Step 1 of external (non-custodial) registration. The server generates Canton topology transactions and returns a multi-hash that the client must sign. The result is held in a short-lived cache keyed by a registration_token.
/register/prepare-topologyRequest body
{
"signature": "0x…", // EIP-191 sig over "registration:<ts>"
"message": "registration:<ts>",
"key_mode": "external",
"canton_public_key": "0x02…" // 33-byte compressed secp256k1 pubkey
}Response 200
{
"topology_hash": "0x9f2c…", // sha256 over topology txs (sign this)
"public_key_fingerprint": "1220ab…",
"registration_token": "3f1c9d2e-…" // pass to /register within minutes
}user_exists: true with the existing party and skips topology generation — the client can jump straight to success.Errors
| Status | Description |
|---|---|
| 400 | invalid signature / invalid canton_public_key |
| 403 | address not whitelisted for registration |
#POST /register
Step 2 of external registration. The server verifies your DER signature over the topology hash, allocates the external party, creates the fingerprint mapping, and persists the user.
/registerRequest body (external)
{
"signature": "0x…", // EIP-191 sig over "registration:<ts>"
"message": "registration:1710000000",
"key_mode": "external",
"canton_public_key": "0x02…",
"topology_signature": "0x30…", // DER sig over sha256(topology_hash)
"transaction_hash": "0x9f2c…", // topology_hash from prepare-topology
"public_key_fingerprint": "1220ab…",
"registration_token": "3f1c9d2e-…"
}Response 200
{
"user_exists": false,
"party": "user_a1b2c3d4::1220ab…",
"fingerprint": "1220ab…",
"mapping_cid": "00abc…",
"evm_address": "0xa1b2c3d4e5f6…",
"key_mode": "external"
}user_exists: true with the existing partyErrors
| Status | Description |
|---|---|
| 400 | invalid signature / invalid topology_signature / key mismatch |
| 401 | signature and message required |
| 403 | address not whitelisted for registration |
| 404 | registration token not found or already used |
| 409 | user already registered / party already allocated |
| 410 | registration token expired |
#POST /api/v2/transfer/prepare
Builds a Canton transaction and returns the hash to sign. Supports threetype values: transfer, preapproval (for CC), and preapproval2 / preapproval3 (for CBTC / cETH).
/api/v2/transfer/prepareRequest body — transfer
{
"type": "transfer",
"to": "user_a1b2c3d4::1220ab…", // party_id OR 0x… EVM address
"amount": "12.5",
"token": "CC", // must be in the allowed set
"memo": "optional memo" // required for most CEX deposits
}Request body — preapproval
{
"type": "preapproval" // or "preapproval2" (CBTC) / "preapproval3" (cETH)
}Response 200
{
"transfer_id": "f3a1…",
"transaction_hash": "0x7d4e9a…", // hex-encoded Canton tx hash
"party_id": "user_a1b2c3d4::1220ab…",
"expires_at": "2026-01-15T12:34:56Z" // RFC3339
}type is echoed back only in the body you send, the server identifies the kind via the request. If a preapproval already exists for the party, you get 409 Conflict.Errors
| Status | Description |
|---|---|
| 400 | unsupported token / invalid recipient / invalid amount |
| 401 | user not found / message expired |
| 403 | address not whitelisted |
| 409 | preapproval already exists |
#POST /api/v2/transfer/execute
Submits the DER signature over the prepared transaction hash. On success the Canton update is applied and the resulting update_id is returned.
/api/v2/transfer/executeRequest body
{
"transfer_id": "f3a1…",
"signature": "0x30…", // DER sig
"signed_by": "1220ab…", // Canton multihash fingerprint
"transaction_hash": "0x7d4e9a…",
"public_key": "0x02…", // compressed secp256k1 pubkey
"type": "transfer" // or "preapproval"
}Response 200
{
"status": "completed",
"update_id": "1220c4…" // Canton update reference. View it in a Canton explorer such as ccview.io/update/<update_id>
}Errors
| Status | Description |
|---|---|
| 400 | invalid DER signature / VerifyDER self-check failed |
| 401 | user not found |
| 403 | address not whitelisted |
| 404 | transfer not found |
| 410 | transfer expired |
#JavaScript examples
End-to-end examples using ethers v6. Both flows assume a whitelisted EVM address and a private key available locally — the key never leaves the device.
Setup
import { ethers } from "ethers";
import { secp256k1 } from "@noble/curves/secp256k1";
import { sha256 } from "@noble/hashes/sha256";
const RPC_URL = "https://canton.rpc.wallet.metatarz.xyz";
const PRIVATE_KEY = "<PK>"
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const EVM_ADDRESS = wallet.address;
const COMPRESSED_PUBKEY = wallet.signingKey.compressedPublicKey;
// Derive a DER-encoded secp256k1 signature over sha256(hashHex)
export async function signHashDER(
hashHex: string,
privateKeyHex: string,
raw = false,
): Promise<string> {
const hash = ethers.getBytes(hashHex);
const privateKey = ethers.getBytes(privateKeyHex);
const signature = secp256k1.sign(
raw ? hash : sha256(hash),
privateKey,
{ prehash: false, lowS: true, format: "der" },
);
return signature.toHex();
}
const authHeaders = (message: string, signature: string) => ({
"Content-Type": "application/json",
"X-Message": message,
"X-Signature": signature,
});Register — step 1: prepare topology
Sign a timed registration:<ts> message, send it with the compressed pubkey, and receive a topology hash to sign in step 2.
async function prepareTopology() {
const message = "registration:" + Date.now();
const signature = await wallet.signMessage(message);
const res = await fetch(RPC_URL + "/register/prepare-topology", {
method: "POST",
headers: authHeaders(message, signature),
body: JSON.stringify({
signature,
message,
key_mode: "external",
canton_public_key: COMPRESSED_PUBKEY,
}),
});
const data = await res.json();
// Already registered on-chain — nothing to sign
if (data.user_exists) {
return { alreadyRegistered: true, party: data.party, fingerprint: data.fingerprint };
}
return {
alreadyRegistered: false,
message,
signature,
topologyHash: data.topology_hash, // sha256 over topology txs
publicKeyFingerprint: data.public_key_fingerprint,
registrationToken: data.registration_token, // pass to step 2
};
}// first-time registration
{
"topology_hash": "0x9f2c…",
"public_key_fingerprint": "1220ab…",
"registration_token": "3f1c9d2e-…"
}
// already registered
{
"user_exists": true,
"party": "user_a1b2c3d4::1220ab…",
"fingerprint": "1220ab…"
}Register — step 2: submit DER signature
Sign the sha256 of the topology hash with your secp256k1 key using DER encoding (the wallet's signHashDER helper), then POST the signature along with the token from step 1.
import { sha256 } from "ethers";
import { signingKeyToDER } from "./canton-signing"; // see note below
async function registerWallet() {
const prepared = await prepareTopology();
if (prepared.alreadyRegistered) {
return { party: prepared.party, fingerprint: prepared.fingerprint };
}
// DER-sign with the user's secp256k1 key
const topologySignature = await signHashDER(
prepared.topologyHash,
PRIVATE_KEY,
);
const res = await fetch(RPC_URL + "/register", {
method: "POST",
headers: authHeaders(message, signature),
body: JSON.stringify({
signature: prepared.signature, // EIP-191 over registration:<ts>
message: prepared.message,
key_mode: "external",
canton_public_key: COMPRESSED_PUBKEY,
topology_signature: topologySignature, // DER over sha256(topology_hash)
transaction_hash: prepared.topologyHash,
public_key_fingerprint: prepared.publicKeyFingerprint,
registration_token: prepared.registrationToken,
}),
});
if (!res.ok) {
// 403 = not whitelisted · 409 = already registered · 410 = token expired
throw new Error("registration failed: " + res.status);
}
return await res.json();
}{
"party": "user_a1b2c3d4::1220ab…",
"fingerprint": "1220ab…",
"mapping_cid": "00abc…",
"evm_address": "0xa1b2c3d4e5f6…",
"key_mode": "external"
}Send a transfer — prepare
Build a Canton transaction by POSTing the intent. The response contains the hash you sign next.
async function prepareTransfer({ to, amount, token, memo }) {
const message = "transfer:" + Date.now();
const signature = await wallet.signMessage(message);
const res = await fetch(RPC_URL + "/api/v2/transfer/prepare", {
method: "POST",
headers: authHeaders(message, signature),
body: JSON.stringify({
type: "transfer",
to, // party_id OR 0x… EVM address
amount, // decimal string, e.g. "12.5"
token, // e.g. "CC", "USDCx", "CBTC"
...(memo ? { memo } : {}), // required for most CEX deposits
}),
});
const data = await res.json();
if (data.code === 409) {
// e.g. preapproval already exists — safe to surface to the user
throw new Error("preapproval already exists");
}
if (!data.transaction_hash) {
throw new Error("prepare failed");
}
return { message, signature, ...data };
}{
"transfer_id": "f3a1…",
"transaction_hash": "0x7d4e9a…",
"party_id": "user_a1b2c3d4::1220ab…",
"expires_at": "2026-01-15T12:34:56Z"
}Send a transfer — execute
DER-sign then submit the signature along with the compressed pubkey and the fingerprint suffix of your party ID.
async function executeTransfer(prepared) {
const txHashSig = await signHashDER(
prepared.transaction_hash,
PRIVATE_KEY,
);
// party_id is "user_a1b2c3d4::1220ab…" — send the fingerprint half
const signedBy = prepared.party_id.split("::")[1];
const res = await fetch(RPC_URL + "/api/v2/transfer/execute", {
method: "POST",
headers: authHeaders(prepared.message, prepared.signature),
body: JSON.stringify({
transfer_id: prepared.transfer_id,
signature: txHashSig, // DER over sha256(transaction_hash)
signed_by: signedBy, // Canton multihash fingerprint
transaction_hash: prepared.transaction_hash,
public_key: COMPRESSED_PUBKEY,
type: "transfer",
}),
});
if (!res.ok) {
throw new Error("execute failed: " + res.status);
}
return await res.json();
}{
"status": "completed",
"update_id": "1220c4…" // Canton update reference. View it in a Canton explorer such as ccview.io/update/<update_id>
}Full round-trip
async function sendCC({ to, amount, memo }) {
const prepared = await prepareTransfer({
to,
amount,
token: "CC",
memo,
});
const result = await executeTransfer(prepared);
return result.update_id;
}
// usage
const updateId = await sendCC({
to: "user_deadbeef::1220…",
amount: "1.5",
memo: "invoice-42",
});
console.log("Canton update:", updateId);#EVM JSON-RPC API
RPC endpoint
https://canton.rpc.wallet.metatarz.xyz (mainnet)https://canton-testnet.rpc.wallet.metatarz.xyz (testnet)Chain ID 31337 (mainnet) · 30337 (testnet)
prepare → sign → execute flow instead of being broadcast as raw Ethereum transactions.#Supported methods
The endpoint supports the read and account methods used by common EVM tooling (ethers, viem, web3.js). The table below lists what the Metatarz wallet itself relies on, plus notes about Canton behavior.
| Method | Purpose |
|---|---|
eth_chainId | Return the active chain ID |
eth_getBalance | Native CC balance for an address |
eth_call | CIP-56 balance for an address |
eth_estimateGas | Gas estimation |
eth_gasPrice | Current gas price |
eth_blockNumber | Latest open round |
eth_sendRawTransaction | Send native transfer |
Anything not listed here may be unsupported or return empty results.
#Canton-specific behavior
These are the places where Canton semantics leak through the EVM compatibility layer. If you are porting an existing dApp, this is where you should pay attention.
Native balance is CC, not wei
eth_getBalance returns the party's native Canton Coin (CC) balance. The value is a hex-encoded integer with the same 18-decimal convention used by ethers, so formatEther works out of the box. The wallet uses this path to render the "Current Balance" field in the Send screen.
eth_call for CIP-56 tokens
CIP-56 tokens (USDCx, HANDL, CBTC, cETH) are read through the same eth_call interface as ERC20, using the standard 0x70a08231 balanceOf(address) selector. Two differences from vanilla ERC20:
- The wallet does not call
decimals()first — CIP-56 balances are always formatted with 18 decimals. - Known Canton token addresses are hard-coded in the wallet, e.g.
0xDE4000…01for USDCx,0xDE6000…01for CBTC.
// eth_call — balanceOf for a CIP-56 token
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_call",
"params": [
{
"to": "0xDE40000000000000000000000000000000000001",
"data": "0x70a08231" + "000000000000000000000000" + "<address without 0x>"
},
"latest"
]
}eth_sendRawTransaction is intercepted
When your dApp (or the wallet UI) submits a transaction, the request never reaches a validator as a signed Ethereum envelope. Metatarz catches it and runs the two-phase flow described in the Wallet API section:
- The wallet calls
/api/v2/transfer/preparewith the intent (recipient, amount, token, memo). - The Canton transaction hash is returned and signed locally with the user's secp256k1 key.
- The signature is submitted to
/api/v2/transfer/execute, which broadcasts on Canton. - The resulting
update_idis surfaced back to the dApp as the "tx hash".
eth_sendRawTransaction (and later eth_getTransactionReceipt) is a Canton update_id, not an Ethereum-style 32-byte transaction hash. Look it up on CCVIEW rather than an EVM explorer.Gas is free
Transactions carry gasLimit: "0x0" and gasPrice: "0x0".eth_estimateGas and eth_gasPrice exist for compatibility but return minimal stub values. Do not rely on them for fee calculation — there is no fee market on Canton in this flow.
#Examples
Native CC balance
// request
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getBalance",
"params": ["0xa1b2c3d4e5f6…", "latest"]
}{
"jsonrpc": "2.0",
"id": 1,
"result": "0x1bc16d674ec80000" // 2.0 CC in wei-equivalent
}Convert with formatEther to get 2.0.
CIP-56 balanceOf
Same request shape for every CIP-56 token — only the to address changes.
// USDCx — 0x70a08231 = balanceOf(address)
{
"jsonrpc": "2.0",
"id": 2,
"method": "eth_call",
"params": [
{
"to": "0xDE40000000000000000000000000000000000001",
"data": "0x70a08231000000000000000000000000a1b2c3d4e5f6…"
},
"latest"
]
}{
"jsonrpc": "2.0",
"id": 2,
"result": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000"
}0xde0b6b3a7640000 = 1e18, i.e. 1.0 USDCx at 18 decimals.
| Token | Contract address |
|---|---|
| USDCx | 0xDE40000000000000000000000000000000000001 |
| HANDL | 0xDE50000000000000000000000000000000000001 |
| CBTC | 0xDE60000000000000000000000000000000000001 |
| cETH | 0xDE70000000000000000000000000000000000001 |
Send a CC transaction (ethers) - Javascript example
import { BrowserProvider, parseEther } from "ethers";
const provider = new BrowserProvider(window.ethereum); // Metatarz injects here
const signer = await provider.getSigner();
// Metatarz intercepts this call and runs prepare → sign → execute
const tx = await signer.sendTransaction({
to: "user_a1b2c3d4::1220ab…",
value: parseEther("1.0"),
// wallet also reads: memo, token — pass via your dApp's own UX
});
// tx.hash is a Canton update_id
console.log("Canton update:", tx.hash);memo and token field that are not part of the standard EIP-1193 transaction object. The Metatarz UI collects these from the user directly — dApps that need a memo must collect it themselves and pass it through the wallet's own API rather than the generic provider.#Use Metatarz via MetaMask
#Add Canton network
1.In MetaMask, open Settings → Networks → Add a custom network and enter:- Network name:
Canton - Default RPC URL:
https://canton.rpc.wallet.metatarz.xyz - Chain ID:
31337 - Currency symbol:
CC - Block explorer URL:
https://cantonscan.com
2.Click Save. Your native CC balance will show up directly in MetaMask.