Documentation

Metatarz Wallet

A Canton-native, EVM-compatible browser wallet.

#Introduction

Metatarz is a fully open-source EVM browser wallet based on Ethers, Ionic, Manifest V3, and Vue. If a website supports Metamask it also does support Metatarz wallet out of the box. Websites will detect it as MetaMask — select MetaMask when interacting with dApps. It its compatible with Canton CIPs: CIP-0056, CIP-0103 and Ethereum EIPs: EIP-6963, EIP-1193.

#Canton support

All parties on Metatarz are non-custodial external parties. Your private keys never leaves your device. Currently, it supports Canton preapprovals and Canton Native transfers and soon will also support sending non-CC assets such as USDCx.

#Install

The extension is available to install via Chrome Web Store. Pin it to your toolbar, and import or create an account. Canton networks are available immediately from the network picker.

Install for Google Chrome



Alternatively, a direct download link is also provided.

Chrome / Edge / Brave

1. Download the extension for your browser

2. Extract the ZIP file to a folder

3. Open chrome://extensions/ (or edge://extensions/)

4. Enable "Developer mode"

5. Click "Load unpacked" and select the extracted folder

6. You can pin the extension to your toolbar for easy access

#Getting started

Metatarz is fully EVM compatible. Before you can create accounts, your EVM address needs to be whitelisted. Once whitelisted, you can import your existing EVM key and start receiving Canton assets in a single flow.

#Whitelist your address

Request access by submitting your EVM address through the Install form. Approved addresses unlock account creation inside the wallet. Whitelisting is per-address, so you can whitelist multiple addresses if you use several environments.

#Add an account

Open the wallet and click 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

Your Canton party ID follows the convention:

user_<first 8 hex chars of your EVM address>::namespace

For 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

On account creation, Metatarz automatically deploys a transfer preapproval contract for your party. This is what enables 1-way transfers: senders can push assets to you without requiring you to accept each incoming transfer offer. You are always in control of your outgoing transfers.

#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

After a successful transaction, the 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

Because Metatarz implements the MetaMask provider API, any EVM dApp — Uniswap, Aave, 1inch, block explorers with wallet login, etc. — can connect to it directly.

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

The Wallet API lets you build non-custodial Canton flows on top of Metatarz. All operations follow a two-step prepare → execute pattern: the server builds the Canton transaction and returns a hash, your client signs it locally, and the server submits it. Your private keys never leave the device.
POST/register/prepare-topology

Step 1 of external registration

POST/register

Step 2 of external registration

POST/api/v2/transfer/prepare

Build transfer / preapproval

POST/api/v2/transfer/execute

Submit signed transaction

#Authentication

Every request to the Wallet API is authenticated with an EIP-191 signature sent in two headers:

HeaderValue
X-SignatureEIP-191 signature over X-Message
X-MessageTimed message, e.g. transfer:1710000000
Content-Typeapplication/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 endpoints
  • transfer:<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.

POST/register/prepare-topology

Request 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

200 OK
{
  "topology_hash": "0x9f2c…",       // sha256 over topology txs (sign this)
  "public_key_fingerprint": "1220ab…",
  "registration_token": "3f1c9d2e-…" // pass to /register within minutes
}
If the fingerprint is already mapped on-chain, the endpoint returnsuser_exists: true with the existing party and skips topology generation — the client can jump straight to success.

Errors

StatusDescription
400invalid signature / invalid canton_public_key
403address 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.

POST/register

Request 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

200 OK
{
  "user_exists": false,
  "party": "user_a1b2c3d4::1220ab…",
  "fingerprint": "1220ab…",
  "mapping_cid": "00abc…",
  "evm_address": "0xa1b2c3d4e5f6…",
  "key_mode": "external"
}
If the fingerprint is already mapped on-chain, the endpoint returnsuser_exists: true with the existing party

Errors

StatusDescription
400invalid signature / invalid topology_signature / key mismatch
401signature and message required
403address not whitelisted for registration
404registration token not found or already used
409user already registered / party already allocated
410registration 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).

POST/api/v2/transfer/prepare

Request 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

200 OK
{
  "transfer_id": "f3a1…",
  "transaction_hash": "0x7d4e9a…",  // hex-encoded Canton tx hash
  "party_id": "user_a1b2c3d4::1220ab…",
  "expires_at": "2026-01-15T12:34:56Z" // RFC3339
}
Preapproval responses return the same shape — 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

StatusDescription
400unsupported token / invalid recipient / invalid amount
401user not found / message expired
403address not whitelisted
409preapproval 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.

POST/api/v2/transfer/execute

Request 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

200 OK
{
  "status": "completed",
  "update_id": "1220c4…"           // Canton update reference. View it in a Canton explorer such as ccview.io/update/<update_id>
}

Errors

StatusDescription
400invalid DER signature / VerifyDER self-check failed
401user not found
403address not whitelisted
404transfer not found
410transfer 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
  };
}
200 OK
// 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();
}
200 OK
{
  "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 };
}
200 OK
{
  "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();
}
200 OK
{
  "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

Metatarz exposes a JSON-RPC endpoint that is compatible with any standard EVM client library. Point your provider at the endpoint below and use the same methods you would use on Ethereum — the wallet handles the Canton translation layer transparently.

RPC endpoint

https://canton.rpc.wallet.metatarz.xyz (mainnet)https://canton-testnet.rpc.wallet.metatarz.xyz (testnet)

Chain ID 31337 (mainnet) · 30337 (testnet)

Canton is not an EVM chain. The JSON-RPC surface is a compatibility shim: reads are translated to Canton ledger queries, and writes are routed through the wallet's 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.

MethodPurpose
eth_chainIdReturn the active chain ID
eth_getBalanceNative CC balance for an address
eth_callCIP-56 balance for an address
eth_estimateGasGas estimation
eth_gasPriceCurrent gas price
eth_blockNumberLatest open round
eth_sendRawTransactionSend 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…01 for USDCx, 0xDE6000…01 for 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:

  1. The wallet calls /api/v2/transfer/prepare with the intent (recipient, amount, token, memo).
  2. The Canton transaction hash is returned and signed locally with the user's secp256k1 key.
  3. The signature is submitted to /api/v2/transfer/execute, which broadcasts on Canton.
  4. The resulting update_id is surfaced back to the dApp as the "tx hash".
Because of this, the hash you get back from 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"]
}
200 OK
{
  "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"
  ]
}
200 OK
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000"
}

0xde0b6b3a7640000 = 1e18, i.e. 1.0 USDCx at 18 decimals.

TokenContract address
USDCx0xDE40000000000000000000000000000000000001
HANDL0xDE50000000000000000000000000000000000001
CBTC0xDE60000000000000000000000000000000000001
cETH0xDE70000000000000000000000000000000000001

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);
Native transfers carry a 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

You can also view your Canton native balance from inside MetaMask by adding Canton as a custom network.

#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.

#Future: MetaMask snap

Sending Canton assets from within MetaMask is not yet supported — MetaMask cannot produce Canton-compatible signatures out of the box. A future MetaMask snap will bridge that gap, enabling Canton-native signing directly from MetaMask while keeping Metatarz as the reference wallet.