POC Validated Tezos X

Octez.connect Multi-chain — TZIP-10 extension proposal

A small, backward-compatible payload extension to TZIP-10 enables single-session multi-chain operations across Tezos L1 and the Tezos X Michelson interface. Transport-agnostic; existing wallets adopt it by handling two new optional fields.

April 2026 · Tezos X Adoption Team

Status
Draft
TZIP
tbd — likely a revision of TZIP-10
Type
Application
Created
2026-04
Discussions
tbd
Depends on
TZIP-10, CAIP-2

Motivation

Today, a user connecting to a Tezos X dApp has to disconnect and reconnect their wallet every time they switch between L1 and the Michelson interface. Each switch triggers a full permission handshake — new signature, new approval prompt, new session.

After this change, one connection covers both chains. The dApp tags each operation with its target chain; the wallet routes it. No reconnect, no re-approval.

Mechanically: today's TZIP-10 binds a session to a single network. This proposal lifts that constraint with a small, backward-compatible payload extension.

Proposal

Three small, backward-compatible additions to TZIP-10

  • permission_request gains an optional networks[] field — the list of chains the dApp wants access to.
  • permission_response gains an optional accounts map — public key per chain.
  • operation_request.network accepts a bare CAIP-2 string (tezos:<chain-id-b58>) so each operation is unambiguously routed.
Payload-only. Transport-agnostic. Backward-compatible — a wallet that ignores networks[] behaves exactly as before. Chain IDs follow CAIP-2, the same convention already used by WalletConnect v2.

Out of scope

Why it's cheap

This is a payload delta, not a new transport. The three existing TZIP-10 transports (extension PostMessage, Matrix P2P, WalletConnect v2) carry the new fields unchanged. For an extension wallet like Temple, the change is a small diff in the permission handler — no transport plumbing, no new APIs, no new permissions.

The dApp senses capability directly from the response shape — no SDK version check, no feature probe:

const result = await client.requestPermissions({ networks: [...] })

if (result.accounts) {
  // multi-chain wallet — result.accounts is the per-chain map
} else {
  // pre-upgrade wallet — standard TZIP-10 v2 behavior, single chain
}

Observations

Status: validated end-to-end on Matrix P2P and WalletConnect v2 on a dual-runtime previewnet. See Demo.

Where to go next

Protocol summary
  • permission_request + optional networks[]
  • permission_response + optional accounts map
  • operation_request.network accepts CAIP-2 string
Pick the audience that applies to your wallet or dApp.

dApp integration

dApp role. Declare which chains you need at connection time, detect the wallet's capability from the response shape, and tag each operation with its target chain ID. Fee estimation is the wallet's job — send operation details without fees.
SDK extension required. The Beacon SDK (@airgap/beacon-dapp / @tezos-x/octez.connect-dapp) needs to accept the new parameters natively. The diffs below show the proposed clean API. Until the SDK is updated, these parameters must be injected via an internal workaround — see the POC source (wc2/dapp/src/main.ts) for reference.

Permission flow

Before — single chain
// requestPermissions() with no args
// session is bound to the one network
// the client was initialised with
const result = await client
  .requestPermissions()

// Response shape:
// { publicKey, address, network, … }
const pk = result.publicKey
After — multi-chain (proposed SDK API)
// Pass the list of requested chains
const result = await client
  .requestPermissions({
    networks: [
      { chainId: 'tezos:NetXsqzbfFenSTS',
        rpcUrl: L1_RPC, name: 'L1' },
      { chainId: 'tezos:NetXH12Aer3be93',
        rpcUrl: L2_RPC,
        name: 'Michelson interface' },
    ],
  })

// Detect wallet version from response
if (result.accounts) {
  // v3 — wallet approved multi-chain
  v3Accounts = result.accounts
  // { 'tezos:NetX…': { publicKey } }
} else {
  // v2 — wallet is single-chain only
  singlePubKey = result.publicKey
}

Operation flow

Before — default network
// Routes to the network set at init
const result = await client
  .requestOperation({
    operationDetails: [
      { kind: 'transaction',
        amount: '1',
        destination: DEST },
    ],
  })
After — target chain explicit (proposed SDK API)
// Specify target chain alongside the op
const result = await client
  .requestOperation({
    network: 'tezos:NetXH12Aer3be93',  // CAIP-2
    operationDetails: [
      { kind: 'transaction',
        amount: '1',
        destination: DEST },
    ],
  })

// result.transactionHash — hash on
// the chain declared above
No fee work for the dApp. Operation details are sent without fees. The wallet estimates fees by querying the chain's RPC — the dApp developer has nothing to do.

Edge cases

  • Wallet only approves some chains (partial accounts map)
    The accounts map may not contain all requested chain IDs. Check before enabling each chain's operations:
    const canUseL2 = !!v3Accounts?.['tezos:NetXH12Aer3be93']
    btnL2.disabled = !canUseL2
  • Wallet doesn't support multi-chain (v2 response — no accounts)
    Fall back to single-chain mode. Disable operations that require a chain not covered by the legacy session:
    if (!result.accounts) {
      // v2: only L1 (client's init network) is available
      btnL2.disabled = true
    }
  • Operation targeted at a chain not in the session
    Guard before sending to avoid a confusing wallet error:
    if (v3Accounts && !v3Accounts[targetChainId]) {
      showError('Chain not in session — reconnect to add it')
      return
    }

Chrome extension wallet

Transport unchanged. The existing PostMessage ↔ chrome.runtime channel continues to work as-is. All changes are in message payload handling only — no new APIs, no new permissions.

Permission request handler

Before — single chain
case 'permission_request': {
  const ok = await
    showApproval(msg.appMetadata.name)
  if (!ok) {
    reject('ABORTED_ERROR')
    break
  }
  respond({
    type: 'permission_response',
    id: msg.id,
    publicKey: wallet.publicKey,
  })
  break
}
After — multi-chain
case 'permission_request': {
  const networks = msg.networks ?? []   // ← new
  const ok = await
    showApproval(msg.appMetadata.name, networks)
  if (!ok) {
    reject('ABORTED_ERROR')
    break
  }
  // Build accounts map and store registry
  const accounts = {}, registry = {}
  for (const net of networks) {
    const pk = keyForChain(net.chainId)
    accounts[net.chainId] = { publicKey: pk }
    if (net.rpcUrl) registry[net.chainId] = net.rpcUrl
  }
  if (networks.length)
    await chrome.storage.session.set({ registry })

  respond({
    type: 'permission_response',
    id: msg.id,
    publicKey: wallet.publicKey,
    ...(networks.length && { accounts }),  // ← new
  })
  break
}

Operation request handler

Before — network always an object
case 'operation_request': {
  const rpcUrl =
    msg.network?.rpcUrl ?? DEFAULT_RPC

  const hash = await
    inject(rpcUrl, msg.operationDetails)

  respond({
    type: 'operation_response',
    id: msg.id,
    transactionHash: hash,
  })
  break
}
After — CAIP-2 string or legacy object
case 'operation_request': {
  const net = msg.network
  let rpcUrl
  if (typeof net === 'string') {        // ← new
    const { registry } = await
      chrome.storage.session.get('registry')
    rpcUrl = registry?.[net] ?? fallback(net)
  } else {                               // legacy
    rpcUrl = net?.rpcUrl ?? DEFAULT_RPC
  }

  const hash = await
    inject(rpcUrl, msg.operationDetails)

  respond({
    type: 'operation_response',
    id: msg.id,
    transactionHash: hash,
  })
  break
}
Fee estimation is automatic. Call tezos.contract.batch(ops).send() — Taquito fetches the chain's mempool parameters via getMempoolFilter and computes the correct fee floor on its own, for both L1 and the Michelson interface. Use Taquito ≥ 24.3.0-rc.3 which correctly handles the Michelson interface's higher fee params.

Edge cases

  • Wallet doesn't have a key for one of the requested chains
    Return a partial accounts map with only the chains your wallet supports. The dApp detects which chains were approved by inspecting the map keys.
    for (const net of networks) {
      if (isChainSupported(net.chainId))
        accounts[net.chainId] = { publicKey: keyFor(net.chainId) }
      // silently skip unsupported chains
    }
  • Operation request for a chain not in the stored registry
    Use a hardcoded fallback for known chains, or return an operation_error for truly unknown ones. Don't crash silently.
    function fallback(chainId) {
      if (chainId === 'tezos:NetXH12Aer3be93') return MICHELSON_RPC
      if (chainId === 'tezos:NetXsqzbfFenSTS')  return L1_RPC
      throw new Error(`Unknown chain: ${chainId}`)
    }
  • Different key pairs per chain
    For Tezos-family chains (L1 + Michelson interface), the same Ed25519 key pair is valid on all chains — same derivation, same address format tz1…. If your wallet uses separate keys per chain (e.g. different HD paths), put the correct publicKey in each accounts entry. The dApp uses these keys independently per chain.

Standalone app wallet

Same payload change as the Chrome extension. A standalone web or mobile wallet that already speaks TZIP-10 over Matrix P2P or WalletConnect v2 applies the same payload-only diff — handle networks[], return an accounts map, read CAIP-2 network on operation_request. The transport (QR pairing → Matrix room, or WC2 session) is unchanged. See the Chrome extension tab for the full handler diffs — they apply verbatim.

WalletConnect v2 — one extra consideration

In Matrix and PostMessage transports, a v2 session exists before the multi-chain handshake. In WC2, the session proposal is the first message — namespaces must be declared atomically. Put the primary chain in requiredNamespaces and any additional chains in optionalNamespaces; the wallet approves per chain. The payload-level networks[] / accounts contract is the same — only the SDK plumbing differs.

Popup UX — exploratory sketch (not part of the TZIP proposal)
Not standardized. The POC includes a popup-based UX pattern for a standalone web wallet opened via window.open(?popup=1), using a minimal invented message envelope internally named tzip10-popup. This is a UX exploration — it derisks the pattern but is not part of the proposed TZIP-10 extension. Standardizing it would be separate work.

Message sequence

Wallet
dApp
wallet-ready
Wallet loaded, has window.opener
Requests access to both chains
permission-request
permission-response
Approved; accounts map included
— session active; popup stays open across operations —
For each op: specifies chainId + operations[]
operation-request
operation-response
Returns transactionHash

Wallet skeleton

Before — wallet with no popup mode
// main() initialises the wallet UI
async function main() {
  const signer = await
    InMemorySigner.fromSecretKey(KEY)

  // set up Matrix / WC2 transport
  await initBeaconClient(signer)
}
After — popup mode detected at startup
async function main() {
  const signer = await
    InMemorySigner.fromSecretKey(KEY)

  // Detect popup mode before anything else
  const isPopup =
    new URLSearchParams(location.search)
      .has('popup')
  if (isPopup && window.opener) {
    await runPopupMode(signer)
    return              // skip Matrix/WC2
  }

  // set up Matrix / WC2 transport
  await initBeaconClient(signer)
}

async function runPopupMode(signer) {
  const send = msg =>
    window.opener.postMessage(msg, '*')
  let registry = {}

  // 1. Announce ready
  send({ type: 'tzip10-popup',
         action: 'wallet-ready',
         address: await signer.publicKeyHash() })

  // 2. Handle incoming messages
  window.addEventListener('message', async e => {
    const m = e.data
    if (m?.type !== 'tzip10-popup') return

    if (m.action === 'permission-request') {
      const ok = await
        showApproval(m.appName, m.networks)
      if (!ok) {
        send({ type: 'tzip10-popup',
               action: 'permission-error',
               id: m.id, errorType: 'ABORTED_ERROR' })
        return
      }
      const pk  = await signer.publicKey()
      const accounts = {}
      for (const net of (m.networks ?? [])) {
        accounts[net.chainId] = { publicKey: pk }
        if (net.rpcUrl) registry[net.chainId] = net.rpcUrl
      }
      send({ type: 'tzip10-popup',
             action: 'permission-response',
             id: m.id, publicKey: pk, accounts })
    }

    if (m.action === 'operation-request') {
      const rpc = registry[m.chainId] ??
                  fallback(m.chainId)
      try {
        const hash = await
          inject(signer, rpc, m.operations)
        send({ type: 'tzip10-popup',
               action: 'operation-response',
               id: m.id, transactionHash: hash })
      } catch (err) {
        send({ type: 'tzip10-popup',
               action: 'operation-error',
               id: m.id, error: err.message })
      }
    }
  })
}

Edge cases — popup transport

  • Popup blocked by the browser
    window.open() must be triggered from a user gesture (button click). If the popup is blocked, the return value is null — detect it and offer Matrix pairing as fallback:
    const popup = window.open(walletUrl, 'wallet', 'width=480,height=700')
    if (!popup) startMatrixPairing()  // fallback
  • User closes the popup mid-session
    The dApp should check popupWindow.closed before each operation. If closed, prompt the user to reconnect rather than hanging on a promise that will never resolve:
    if (!popupWindow || popupWindow.closed) {
      showError('Wallet popup was closed — reconnect to continue')
      return
    }
  • window.opener is null in the wallet
    Can happen if the parent page has a strict Cross-Origin-Opener-Policy header (same-origin). The wallet must guard before entering popup mode and fall through to normal UI rather than crashing:
    if (isPopup && !window.opener) {
      // Can't reach the opener — show an error in the popup UI
      showError('Cannot communicate with the opener page.')
      return
    }
  • wallet-ready and the dApp's listener race
    The dApp must register its message listener before clicking the button that opens the popup — the popup can load fast from cache. In practice: register window.addEventListener('message', onReady) synchronously inside the click handler, right after window.open() returns and before the popup's JS has had time to run.

Message format — full reference

DirectionactionKey fields
wallet → dApp wallet-ready address (tz1…)
dApp → wallet permission-request id, appName, networks[] — same format as TZIP-10 §1.1
wallet → dApp permission-response id, publicKey, accounts map
wallet → dApp permission-error id, errorType (e.g. "ABORTED_ERROR")
dApp → wallet operation-request id, appName, chainId (CAIP-2), operations[]
wallet → dApp operation-response id, transactionHash
wallet → dApp operation-error id, error (message string)
Security. In the wallet's message listener, validate event.origin against a list of trusted dApp origins before processing any message.

Walkthrough

End-to-end multi-chain session: permission request over WalletConnect v2, tez transfer on Tezos L1, contract call on the Michelson interface — all in one session without reconnecting.

What was validated

Transport / scenarioStatusEvidence
Matrix P2P — payload delta end-to-end ✓ validated Phases 1–4, HTTP-driven Node runner
WalletConnect v2 — CAIP multi-chain session ✓ validated test/phase5.ts via Reown relay
Cross-domain deployment (wallet + dApp on separate origins) ✓ validated Phase 4 browser test
Dual-runtime previewnet (L1 + Michelson interface) ✓ validated Operations confirmed on both chains
Popup UX sketch (exploratory) ✓ demoed test/phase6.ts via Playwright

Reproduce locally

Requires a funded wallet on both Tezos L1 (ghostnet) and the Tezos X Michelson interface previewnet. Set WALLET_SK or edit wc2/wallet/src/main.ts.

Phase 5 — WalletConnect

# Terminal 1
cd wc2/wallet && npx vite --port 5174

# Terminal 2
cd wc2/dapp && npx vite --port 5173

# Terminal 3
npm run test:phase5

Phase 6 — Popup transport

# Terminal 1
cd wc2/wallet && npx vite --port 5174

# Terminal 2
cd wc2/dapp && npx vite --port 5173

# Terminal 3
npm run test:phase6
Implementation notes (headless control APIs)

Each Vite app exposes a headless control API (HEADLESS=1) that the Node test runner drives over HTTP. See wc2/dapp/src/main.ts and wc2/wallet/src/main.ts.

PoC planning and execution

The full phase-by-phase plan used to derisk and validate this proposal — six phases from network prerequisites through cross-domain deployment, each with open questions, outcomes, and root causes documented — is archived at the POC development plan. It also contains the test infrastructure reference (headless control APIs, test runner structure, environment setup) that drove the validation.