POC In progress Tezos X

Octez.connect Multi-chain — Development Plan

Proof of concept for extending Octez.connect (TZIP-10) to support single-session multi-chain operations across Tezos L1 and the Michelson interface of Tezos X. Covers three transports: PostMessage, Matrix P2P, and WalletConnect v2.

April 2026 · Tezos X Adoption Team

Context

Octez.connect (TZIP-10) today supports a single network per session. Tezos X introduces a second runtime — the Michelson interface — that shares the same account model as Tezos L1 but lives on a separate chain. A dApp targeting Tezos X needs to operate on both chains within a single wallet session.

This POC validates the required protocol changes to Octez.connect (TZIP-10) and the wallet-side implementation across all three Octez.connect transports, on a real dual-runtime previewnet.

The SDK fork (@airgap/beacon-sdk) used throughout the POC serves as a reference implementation for the TZIP proposal. At POC completion, changes are distilled into a TZIP document and a PR against the upstream SDK. The fork is then archived.

Objectives
Objective 1

UX demonstration

Show what single-session multi-chain UX looks like for a user: one pairing, one connection approval screen (covering both chains at once), then operations on both L1 and the Michelson interface without re-connecting.

Objective 2

Protocol change demonstration

Show the concrete delta to the Octez.connect (TZIP-10) protocol — optional networks[] on permission_request (response-shape detection, no new message type), explicit network routing on operation_request — and prove backward compatibility.

Objective 3

Transport coverage

Validate multi-chain sessions across all three Octez.connect transports (Matrix P2P, WalletConnect v2, PostMessage popup) and in a real cross-domain deployment — confirming the protocol extension is transport-agnostic.

Test network

Private previewnet (txpark)

InterfaceChain IDRPC
Tezos L1 (shadownet) tezos:NetXsqzbfFenSTS https://rpc.shadownet.teztnets.com
Michelson interface (L2) tezos:NetXH12Aer3be93 https://demo.txpark.nomadic-labs.com/rpc/tezlink
EVM interface eip155:127124 https://demo.txpark.nomadic-labs.com/rpc
Portal: https://demo.txpark.nomadic-labs.com/  ·  Blockscout: https://demo-blockscout.txpark.nomadic-labs.com  ·  Faucet: https://demo-faucet.txpark.nomadic-labs.com
Transports covered
Matrix P2P — QR pairing via Matrix homeserver, same transport from Phase 1 to Phase 4
WalletConnect v2 — relay-based, CAIP multi-chain sessions (Phase 5)
Popup (PostMessage via window.open) — derisking for browser-extension-like UX (Phase 6)
Phases
0

Network prerequisites

L1 (shadownet) + Michelson L2 (tezlink) — both chain IDs confirmed
infra
Done
Outcome: Both chain IDs confirmed — L1 tezos:NetXsqzbfFenSTS (shadownet), Michelson L2 tezos:NetXH12Aer3be93 (tezlink). No blocker for Phases 1–3.
1

Minimal wallet — Matrix P2P, single network

Web wallet (Vite + @airgap/beacon-wallet + Taquito), standard TZIP-10 over Matrix
Matrix P2P TZIP-10
Done
Outcome: dApp pairs with wallet over Matrix, sends a tez transfer on L1, wallet signs and injects, operation confirmed on txpark. Headless REST API in place for all subsequent test phases.
2

TZIP-10 protocol extension

Optional networks[] on permission_request, response-shape version detection, routing on operation_request
Matrix P2P TZIP-10 delta
Done
Outcome: dApp sends two operations in one session — one to L1 (shadownet), one to Michelson L2 (tezlink) — wallet routes each to the correct RPC. Backward compatibility with unpatched wallet proven via response-shape detection (no timeout, no new message type).
3

Real chain execution — two distinct networks

Confirm two operations on-chain on two distinct chains in the same session
Matrix P2P real chains
Done
Outcome: Two operations in one session land on two distinct chains, confirmed independently on each RPC. Blocked on Michelson infrastructure fix (Phase 0).
3b

UX demo — concrete use-case

Polished UI, screen recording — shareable demo for wallet teams
Matrix P2P UX demo
Done
Outcome: Screen recording (real chain IDs) shareable with wallet teams. One pairing, one wallet connection approval (not one signature — the user approves access to both chains at session start), then L1 transfer + Michelson contract call each signed individually.
4

Cross-domain deployment

Wallet on a cloud domain, dApp on another — no transport change, pure deployment
Matrix P2P deployment
Done
Outcome: Full multi-chain session works between two separate HTTPS domains. CORS issues with Matrix homeserver surfaced and resolved.
5

WalletConnect v2 transport

Multi-chain WC2 session with two tezos: chains in a single session proposal
WalletConnect v2 CAIP-25
Done
Outcome: WC2 session spanning both tezos: chains established via Reown relay. tezos_send dispatched to each chain via chainId routing. Changes localized to WalletConnectCommunicationClient in Octez.connect.
6

Popup model — PostMessage via window.open

dApp opens wallet as a popup — derisking the browser-extension-like UX pattern
Popup UX
Done
Outcome: Multi-chain session (L1 + Michelson interface) established via tzip10-popup PostMessage protocol. window.open(?popup=1) with ?headless=1 for auto-approve. Playwright test passes end-to-end: permission handshake, L1 transfer included, L2 contract call confirmed. Popup stays alive across both ops.
Tech stack
Vite (build tool)
@airgap/beacon-wallet
@airgap/beacon-dapp
@taquito/taquito
@taquito/signer (InMemorySigner)
Matrix homeserver (papers.tech or octez.io)
Reown relay — Project ID: fb4d4407a8fe167d79bd14b5afcc7230
Playwright (Phase 4 CORS detection, Phase 6 popup tests)
Previewnet RPC (txpark)
Test infrastructure

Each phase is validated by an automated agent via HTTP, not a human. Both wallet and dApp expose a headless control API (HEADLESS=1). A Node.js test runner drives the full flow without UI interaction.

dApp API

EndpointBehaviour
POST /request-permissions No body → v2 flow (Phase 1). {networks:[…]} → single-step multi-chain request; mode detected from response shape. Blocks until Matrix URI is ready.
GET /pairing-uri URI generated by the last /request-permissions call.
POST /request-operation Body: {network?, operationDetails}. Returns {transactionHash}.
GET /last-permission v3: {version:"3", accounts:{chainId:{publicKey}}}
v2: {version:"2", publicKey}
GET /last-handshake {mode}"v3" or "v2". Verifies the code path taken, not just the outcome.
GET /last-op {transactionHash} from last operation.
POST /reset Clears all state.

Wallet API

EndpointBehaviour
POST /connect Accepts pairing URI, calls WalletClient.addPeer(). Auto-approves all requests when HEADLESS=1.
GET /last-rpc-call {chainId, rpcUrl} for the last operation_request routed — validates actual HTTP dispatch.
POST /wc2-ready (Phase 5) Returns 200 once SignClient is initialized and listening for proposals.
POST /reset Clears all state.

Test runner

  • Phases 1–4: HTTP-based. Phase 4 adds one Playwright step for CORS detection (browser-side only).
  • Phase 6: Playwright full-browser — wallet runs as popup, no HTTP server.
  • Env: DAPP_URL (default localhost:5173), WALLET_URL (default localhost:5174).
  • One file per phase: test/phase1.tstest/phase6.ts. Exit 0 = pass.

Prerequisite spike — gate before Phase 1 (≤ 2h)

  • Confirm pairing flow direction in SDK source: assumption is dApp generates URI, wallet calls addPeer().
  • Identify TypeScript changes needed on approvePermissionRequest and PermissionResponse to support per-chain accounts.
  • Confirmed via SDK source inspection: unknown fields on permission_request pass through silently (plain cast, no schema validation). Unknown message types call assertNever() → unhandled promise rejection, no SDK event, listener survives. Response-shape detection is the correct detection strategy.
// test/phase1.ts — HTTP-based, no SDK access
await fetch(`${DAPP_URL}/request-permissions`, { method: 'POST' });
const uri = await fetch(`${DAPP_URL}/pairing-uri`).then(r => r.text());
await fetch(`${WALLET_URL}/connect`, { method: 'POST', body: uri });
const { transactionHash: hash } = await fetch(`${DAPP_URL}/request-operation`, {
  method: 'POST', body: JSON.stringify({ operationDetails: [{ kind: 'transaction', amount: '1', destination: DEST }] })
}).then(r => r.json());
await waitForConfirmation(hash, L1_RPC); // polls /operations/${i} passes 0–3, 60s timeout
const op = (await Promise.all([0,1,2,3].map(i =>
  fetch(`${L1_RPC}/chains/main/blocks/head/operations/${i}`).then(r => r.json())
))).flat().find(o => o.hash === hash);
assert(op !== undefined, 'operation not found on chain');
Phases
0

Network prerequisites

L1 confirmed — Michelson chain ID known but pending distinct value
infra
Done

Confirmed chain IDs

  • L1 (shadownet): tezos:NetXsqzbfFenSTShttps://rpc.shadownet.teztnets.com
  • Michelson interface (L2): tezos:NetXH12Aer3be93https://demo.txpark.nomadic-labs.com/rpc/tezlink
  • EVM interface: eip155:127124https://demo.txpark.nomadic-labs.com/rpc

Endpoints ready

  • Two distinct chain IDs on two distinct RPC endpoints — routing can be validated at HTTP dispatch level from Phase 2
  • Shadownet faucet: https://faucet.shadownet.teztnets.com
  • txpark faucet: https://demo-faucet.txpark.nomadic-labs.com
Done: L1 curl https://rpc.shadownet.teztnets.com/chains/main/chain_id"NetXsqzbfFenSTS" ✓  |  Michelson L2 curl https://demo.txpark.nomadic-labs.com/rpc/tezlink/chains/main/chain_id"NetXH12Aer3be93" ✓  |  Both chain IDs distinct — no blocker for any phase.
1

Minimal wallet — Matrix P2P, single network

Web wallet (Vite + @airgap/beacon-wallet + Taquito), standard TZIP-10 over Matrix
Matrix P2P TZIP-10
Done

Build the wallet and dApp foundation before introducing any protocol changes. Matrix P2P is used from the start — the same transport will carry through Phases 2–4, so there is no transport switch later. The dApp generates a pairing URI; the wallet reads it and connects via a Matrix homeserver.

Deliverables

  • wallet/ — Vite app, WalletClient, hard-coded Ed25519 key via InMemorySigner
  • dapp/ — Vite app, DAppClient, pairing URI + QR code display
  • Handles permission_request + operation_request (standard TZIP-10)
  • Wallet forges, signs, injects via Taquito on L1 RPC
  • Headless REST API on both wallet and dApp (HEADLESS=1) — implemented in Phase 1, used in all subsequent test phases

Derisking questions

  • Which Matrix homeserver to use for the POC? (papers.tech, octez.io)
  • Does WalletClient pairing via Matrix URI work without extension context?
  • SDK fork decided: Phase 2 requires changes to @airgap/beacon-sdk — POC works from a fork from day one
// wallet — core pattern
const signer = await InMemorySigner.fromSecretKey('edsk...');
const Tezos = new TezosToolkit('https://rpc.shadownet.teztnets.com'); // L1 shadownet — not tezlink (L2)
Tezos.setSignerProvider(signer);

const client = new WalletClient({ name: "Tezos X Wallet POC" });
await client.init();

client.onPermissionRequest(async (request) => {
  await client.approvePermissionRequest(request, { publicKey: await signer.publicKey() });
});

client.onOperationRequest(async (request) => {
  // Forge + sign + inject via Taquito, return hash
  const op = await Tezos.contract.batch(request.operationDetails).send();
  await client.approveOperationRequest(request, { transactionHash: op.hash });
});
Phase done when: dApp pairs with wallet via headless API, sends a tez transfer on L1, wallet signs and injects, operation confirmed on the txpark previewnet. Note: on-chain confirmation depends on txpark uptime — transport validation (steps 1-4) is independent and can be verified even if the RPC is unreachable.

E2E validation — test/phase1.ts

  1. POST /request-permissions (no body) → GET /pairing-uri returns non-empty URI.
  2. POST /connect on wallet with URI → wallet connects over Matrix, auto-approves.
  3. GET /last-permission returns {version:"2", publicKey}.
  4. POST /request-operation {operationDetails:[{kind:"transaction", amount:"1", destination: DEST}]}waitForConfirmation(hash, L1_RPC) resolves within 60s.
  5. Operation found in all 4 passes: [0,1,2,3].map(…).flat().find(o => o.hash === hash) is defined.
2

TZIP-10 protocol extension

Optional networks[] on permission_request, response-shape version detection, routing on operation_request
Matrix P2P TZIP-10 delta
Done

Core protocol change. Fork @airgap/beacon-sdk from day one — both dApp and wallet sides need changes, monkey-patching is too fragile. Both chain IDs and RPC endpoints are known from Phase 0, so Phase 2 uses real networks directly. Also delivered: a second unpatched wallet build at WALLET_UNPATCHED_URL for backward compat, and a HEADLESS_APPROVE_NETWORKS flag for subset-approval tests.

Version negotiation — single-step (response-shape detection)
  • 1
    dApp sends a standard permission_request with optional networks[] field. No new message type. The envelope is identical to TZIP-10 v2 — only one new field is added.
  • 2
    Wallet responds. v3-capable: response has accounts: {"tezos:…": {publicKey}} (one entry per approved chain) → v3 mode. Legacy v2: response has publicKey: "edpk…" → v2 mode. No timeout, no race condition.
  • 3
    dApp reads response shape to set mode. In v3 mode, subsequent operation_request messages carry network: CAIP-2. In v2 legacy mode, omit network.

Why not a 3-step handshake with version_upgrade_request?

  • SDK inspection confirmed: unknown fields on permission_request pass through silently (no Zod/AJV, plain cast). Unknown message types hit assertNever() → unhandled promise rejection (no SDK event). A new message type would rely on a 5-second timeout to detect v2 wallets — fragile and slow.
  • Response-shape detection is instantaneous, deterministic, and adds no round trips. This is the standard protocol extension pattern (Postel's law).
  • The 3-step design is documented here as context; it was the initial approach before SDK behavior was confirmed.

New / changed fields

  • networks: NetworkInfo[] on permission_request v3 — each entry: { chainId, rpcUrl }. Wallet builds its RPC registry from this.
  • network (singular) kept in v3 for old-SDK parse compat only. A v3 wallet ignores it when networks[] is present. Documented in TZIP.
  • network: string on operation_request — bare CAIP-2 chain ID (e.g. "tezos:NetXsqzbfFenSTS"). Required in v3, absent in v2. Note: different shape from the object on permission_request — document in TZIP. Edge cases: v2 wallet receiving operation_request with a network field must ignore it (field is unknown in TZIP-10 v2 — unknown fields are silently dropped). v3 wallet receiving operation_request without network must return an error ("network field required in v3 mode").

SDK changes

  • PermissionResponse gains accounts: Record<chainId, {publicKey}>
  • approvePermissionRequest() extended to accept per-chain accounts map
  • Trust model caveat (out of scope): wallet trusts dApp-supplied RPC URLs. Flag for TZIP: wallets should prefer their own configured node over rpcUrl.

Derisking

  • Backward compat: unpatched wallet ignores networks[] on permission_request (confirmed via SDK source — unknown fields pass through silently) → responds with old {publicKey} shape → dApp detects v2 mode. Validated with WALLET_UNPATCHED_URL headless build. Production wallets (Kukai, Temple) out of scope for POC.
  • Account model: L1 (shadownet) and Michelson interface (tezlink) share the same Ed25519 key pair — same publicKey appears for both chains in the v3 response. No per-chain key management needed in this POC.
  • What does the response look like when the wallet approves only a subset of requested networks?
  • Does the dApp need a separate per-network Taquito client, or is the network field on operation_request enough?
  • Session lifecycle: Octez.connect disconnect tears down the entire session — partial chain revoke is undefined in TZIP-10. Flag in TZIP proposal.
// permission_request — single step. networks[] is a new optional field; v2 wallets silently ignore it.
{
  type: "permission_request", version: "2",
  network: { type: "custom", chainId: "tezos:NetXsqzbfFenSTS" }, // kept for v2 parse compat
  networks: [
    { type: "custom", name: "Tezos L1", chainId: "tezos:NetXsqzbfFenSTS",
      rpcUrl: "https://rpc.shadownet.teztnets.com" },
    { type: "custom", name: "Michelson interface", chainId: "tezos:NetXH12Aer3be93",
      rpcUrl: "https://demo.txpark.nomadic-labs.com/rpc/tezlink" }
  ],
  scopes: ["operation_request"]
}

// Response — v3 wallet (networks[] understood):
// { version: "2", accounts: { "tezos:NetXsqzbfFenSTS": { publicKey: "edpk..." },
//                             "tezos:NetXH12Aer3be93": { publicKey: "edpk..." } } }
// Note: same publicKey for both chains — L1 and Michelson interface share the same Ed25519 key pair.

// Response — legacy v2 wallet (networks[] silently ignored):
// { version: "2", publicKey: "edpk..." }   ← flat shape, no accounts map → dApp detects v2 mode

// dApp reads response shape to determine mode:
const mode = 'accounts' in response ? 'v3' : 'v2';

// operation_request — network field required in v3 mode, omitted in v2 legacy
await dAppClient.requestOperation({
  network: "tezos:NetXH12Aer3be93",  // Michelson L2 — required in v3; v2 wallets must ignore unknown fields
  operationDetails: [...]
});
Phase done when: dApp sends two operations in the same session — one to L1 (shadownet), one to Michelson L2 (tezlink) — wallet routes each to the correct RPC, and an unpatched wallet still works with the old single-network flow.

E2E validation — test/phase2.ts ✅ passing

  1. v3 session: POST /request-permissions {networks:[L1, Michelson]} → patched wallet responds with accounts map → GET /last-permission returns {version:"2", accounts:{"tezos:NetXsqzbfFenSTS":{publicKey}, "tezos:NetXH12Aer3be93":{publicKey}}}GET /last-handshake returns {mode:"v3"}.
  2. L1 routing: POST /request-operation {network:"tezos:NetXsqzbfFenSTS", …}GET /last-rpc-call returns {rpcUrl:"https://rpc.shadownet.teztnets.com"}waitForConfirmation(hash, L1_RPC) resolves (head polling fallback — monitor/heads not supported on shadownet).
  3. Michelson routing: POST /request-operation {network:"tezos:NetXH12Aer3be93", …}GET /last-rpc-call returns {rpcUrl:"https://demo.txpark.nomadic-labs.com/rpc/tezlink"}. Wallet confirms via account counter (tezlink protocol never exposes ops in blocks/{id}/operations/{pass}) before responding.
  4. Subset approval: not tested in this phase — HEADLESS_APPROVE_NETWORKS flag not implemented. Deferred to a later phase or TZIP documentation.
  5. Backward compat: POST /set-mode {mode:"v2"} switches the wallet to legacy behavior → POST /request-permissions {networks:[L1, Michelson]} → wallet ignores networks[], responds with flat {publicKey} → dApp detects v2 mode → GET /last-handshake returns {mode:"v2"}POST /request-operation without network routes to L1. Note: implemented via runtime mode switch rather than a separate unpatched build.
3

Real chain execution — two distinct networks

Confirm two operations on-chain on two distinct chains in the same session
Matrix P2P real chains
Done

Phase 2 validated the protocol and routing logic. Phase 3 confirms that operations actually land on-chain on two distinct networks — shadownet (L1) and tezlink (Michelson L2). Both endpoints are available from Phase 0.

E2E validation — covered by test/phase2.ts

  1. test/phase2.ts passes against shadownet (L1) and tezlink (Michelson L2) — no separate test/phase3.ts needed.
  2. requestOperation({ network: L1_CHAIN_ID, ... })waitForConfirmation(hashL1, L1_RPC) resolves via head polling (shadownet, monitor/heads not supported).
  3. requestOperation({ network: "tezos:NetXH12Aer3be93", ... }) → confirmed on tezlink via account counter in the wallet before responding. The tezlink protocol does not expose operations in blocks/{id}/operations/{pass} — counter advance is the only inclusion signal.
  4. GET /last-rpc-call returns correct { chainId, rpcUrl } for each operation — distinct RPC endpoints confirmed.
Phase done when: Two operations in one session land on two distinct chains, confirmed independently on each RPC.
3b

UX demo — browser wallet + dApp (Vite)

Real browser apps — dApp in one tab, wallet in another — shareable demo for wallet teams
Matrix P2P UX demo
Done

Replace the headless Express servers with real Vite browser apps. The wallet runs in one browser tab, the dApp in another (or a popup). The Beacon SDK runs client-side (same as production wallets like Kukai or Temple), so the demo is architecturally honest. First milestone is internal validation; screen recording polish comes later.

Architecture

  • wc2/wallet/ — Vite app: WalletClient in the browser, InMemorySigner, reacts to Beacon messages in real time
  • wc2/dapp/ — Vite app: DAppClient in the browser, QR code or deep-link pairing, operation buttons
  • PostMessage transport for same-browser testing; Matrix P2P for cross-tab / cross-machine
  • Hard-coded key (same edsk… as phases 1–2) — no key management UI needed for the POC

Use-case flow

  • User opens dApp, clicks "Connect" → pairing QR shown
  • Wallet tab scans / reads URI, shows: "Approve access to L1 and Michelson interface?"
  • User approves once — both chains granted
  • dApp button "Transfer on L1" → wallet tab shows op details + network label → user signs
  • dApp button "Call contract on Michelson interface" → wallet signs second op on L2
  • Both hashes confirmed in the same session

Deliverables

  • wc2/dapp/ — Vite dApp with connect + two operation buttons
  • wc2/wallet/ — Vite wallet with permission approval UI and per-operation signing screen
  • Both apps runnable locally with npm run dev

Derisking — resolved

  • WalletClient works in Vite with vite-plugin-node-polyfills (Buffer global needed for generateGUID)
  • ✓ L2 contract: KT1PWPM4rXF8QhouXmF8EugxFvYcdfiz6L3z — minimal parameter string; storage string originated on txpark tezlink
  • ✓ Matrix P2P used across two tabs; pairing URI pasted manually in wallet input
Phase done when: A human can open two browser tabs, pair wallet with dApp, sign one L1 transfer and one Michelson L2 contract call, and see both confirmed — without touching the terminal.

Validation — completed

  1. ✓ dApp shows pairing URI; wallet has paste input → pair
  2. ✓ Wallet approval screen lists L1 (Shadownet) + Michelson interface with colored dots
  3. ✓ Per-operation signing screen shows network label, destination, entrypoint
  4. ✓ L1 transfer: hash confirmed via TzKT stream (stream-driven, shows block number)
  5. ✓ L2 contract call: "included" shown immediately (counter-based confirmation in wallet); TzKT link provided (indexer lagging ~6300 blocks behind RPC)
  6. ✓ Full flow without terminal interaction

Known issues / notes

  • ✓ Taquito fee estimation fixed in 24.3.0-rc.3 (getMempoolFilter fetches real tezlink fee params; ×3 workaround removed, TZX-128 closed)
  • TzKT L2 indexer significantly lagging behind RPC — block number enrichment may be delayed minutes
  • Reconnect after disconnect works; removeAllPeers() must be called to clear Matrix session before re-pairing
4

Cross-domain deployment

Wallet on a cloud domain, dApp on another — no transport change, pure deployment
Matrix P2P deployment
Done

Matrix P2P is already the transport from Phase 1. This phase is purely a deployment change: wallet and dApp move from localhost to separate public HTTPS domains (like Umami web vs. a dApp). The wallet is deployed as a dev build with headless mode enabled — making the flow testable by other people without a manual QR scan. No protocol or transport change — the goal is to confirm the setup works in a real-world network context and surface any CORS or DoS-protection issues with the Matrix homeserver.

Tasks

  • Deploy wallet to cloud (e.g. wallet.<domain>)
  • Deploy dApp to a separate domain
  • Validate headless pairing end-to-end over public internet (DAPP_URL + WALLET_URL env vars pointing to deployed domains)
  • Re-run Phase 2 multi-chain session from separate domains

Derisking questions

  • Any CORS issues from the new wallet domain to the Matrix homeserver?
  • Does the Matrix homeserver apply rate limiting or DoS protection that affects the POC?
  • Message latency acceptable over public internet?
Phase done when: Full multi-chain session works between two separate HTTPS domains. Same use-case as Phase 3, from a real deployment.

Validation — completed (manual browser test, 2026-04-17)

  1. ✓ Wallet deployed at trilitech.github.io/tezos-x-octez-connect/wallet/, dApp at …/dapp/ — both served from trilitech.github.io (same origin, isolated by LocalStorage prefix)
  2. ✓ Pairing via URI paste — wallet approval screen shows L1 + Michelson interface
  3. ✓ L1 transfer confirmed on-chain
  4. ✓ L2 contract call confirmed — fees correct with Taquito 24.3.0-rc.3 (no insufficient_fees)
  5. ✓ No CORS errors from Matrix homeserver

Issues resolved

  • M_FORBIDDEN: already in the room — fixed by isolating wallet/dApp localStorage with LocalStorage('tezx-wallet') / LocalStorage('tezx-dapp') prefixes
  • ✓ Fee underestimation on tezlink — fixed by upgrading to Taquito 24.3.0-rc.3 (getMempoolFilter fetches real chain fee params)
5

WalletConnect v2 transport

Multi-chain WC2 session with two tezos: chains in a single session proposal
WalletConnect v2 CAIP-25
Done

WalletConnect v2 targets wallets like Kukai and Bento that support WC2. Changes are localized to WalletConnectCommunicationClient in Octez.connect (separate repo, external dependency) — this work happens in a branch of Octez.connect in parallel; the tezos-x-beacon repo documents the protocol design and provides the E2E test suite. The TZIP-10 protocol changes from Phase 2 carry over unchanged; only the transport layer differs. Architecture test Phase 5 : le serveur HTTP headless de contrôle (dApp + wallet) tourne inchangé en Phase 5 — WC2 remplace Matrix comme transport de communication Octez.connect, mais les endpoints headless (POST /request-permissions, POST /request-operation, GET /last-handshake, etc.) restent disponibles pour le test runner HTTP, exactement comme en Phases 1–4. This phase validates that the WC2 relay handles a session with two tezos: chains and that multi-chain routing translates correctly to CAIP-27 chainId routing.

Tasks

  • Patch WalletConnectCommunicationClient to include both chains in session proposal
  • Extend wallet to handle WC2 session with two tezos: namespaces
  • Map operation_request.networkwc_sessionRequest.chainId
  • Extend headless mode to auto-approve WC2 session proposals (in addition to Octez.connect messages) — the wallet must call signClient.approve() automatically when HEADLESS=1. State-machine coordination required: WC2 proposals arrive asynchronously via signClient.on('session_proposal', ...); the wallet must initialize the WC2 SignClient, connect the relay WebSocket, and register the event listener before the test sends signClient.connect(). Solution: wallet exposes POST /wc2-ready that returns 200 only once the SignClient is initialized and listening — the test polls this endpoint every 500ms with a 10s timeout before proceeding.
  • Validate with Reown relay — Project ID: fb4d4407a8fe167d79bd14b5afcc7230

Design decision — gate before Phase 5

In Matrix/PostMessage, a v2 session exists before the upgrade handshake. In WC2, the session proposal is the first message — namespaces are negotiated atomically. Two options:

Option A — WC2 native (viable)

L1 in requiredNamespaces, Michelson in optionalNamespaces. WC2 session = step 1. Steps 2–3 (version_upgrade + permission v3) run in the established WC2 channel. /last-handshake returns {step1:"wc2-session", …}.

Option B — opaque (non-viable)

L1-only session proposal, Octez.connect v3 messages as opaque blobs. Breaks WC2 routing: signClient.request({chainId: MICHELSON}) fails if Michelson is not in session.namespaces.

Spike before Phase 5

  • Confirm WC2 merges required + optional under the same tezos key in session.namespaces — the chains.includes(MICHELSON) assertion depends on this.
  • Confirm version_upgrade_request can be sent as a plain WC2 message after session establishment and received by the wallet handler.

Open questions

  • Does the Reown relay accept two chains in the same tezos namespace?
  • Rate limits or relay policy issues with a non-mainnet tezos: chain?
  • CAIP-25 profile update needed for the Tezos namespace?
// WC2 session proposal
{
  requiredNamespaces: {
    tezos: {
      chains: ["tezos:NetXsqzbfFenSTS"],   // L1
      methods: ["tezos_getAccounts", "tezos_send", "tezos_sign"],
      events: []
    }
  },
  optionalNamespaces: {
    tezos: {
      chains: ["tezos:NetXH12Aer3be93"],  // Michelson interface
      methods: ["tezos_getAccounts", "tezos_send", "tezos_sign"],
      events: []
    }
  }
}
Phase done when: A WC2 session spanning both tezos: chains is established via Reown relay, and tezos_send is correctly dispatched to each chain via chainId routing.

Validation — completed (test/phase5.ts, 2026-04-17)

  1. ✓ Reown relay reachable (relay.walletconnect.org/health → 200)
  2. signClient.connect({requiredNamespaces: {tezos: [L1]}, optionalNamespaces: {tezos: [L2]}}) produces pairing URI; wallet auto-approves via POST /wc2-pair
  3. session.namespaces.tezos.chains contains both tezos:NetXsqzbfFenSTS (L1) and tezos:NetXH12Aer3be93 (Michelson interface)
  4. tezos_send on L1 → GET /last-rpc-call returns L1 RPC → hash confirmed on-chain
  5. tezos_send on Michelson → GET /last-rpc-call returns tezlink RPC → hash confirmed via counter advance

Implementation notes

  • WC2 implemented directly in the headless wallet (wallet/src/index.ts) via dynamic import('@walletconnect/sign-client') — no Octez.connect SDK changes needed for the POC
  • Test runner acts as the dApp (initialises its own SignClient); no headless dApp server needed for Phase 5
  • Taquito stays at 24.2.0 in the headless wallet (Octez.connect SDK has @stablelib/* peer deps that rc.3 no longer provides); ×2 fee multiplier kept for tezlink
6

Popup model — PostMessage via window.open

dApp opens wallet as a popup — derisking the browser-extension-like UX pattern
Popup UX
Done

Octez.connect has a PostMessage transport for browser extensions. This phase adapts or extends it to support a popup model: the dApp opens the wallet via window.open(); because the opener holds a reference to the popup's window object, PostMessage works between the two pages. If the Octez.connect SDK already supports popup initialization, this is a small extension; if not, a new transport variant is implemented from scratch in the SDK fork.

Test architecture change: the wallet runs as a popup inside a browser — no standalone HTTP server, so the headless HTTP API (Phases 1–5) doesn't apply. Instead, the wallet auto-approves via ?headless=1 URL param, and Playwright controls both the dApp page and the popup window directly. No POST /connect needed — the dApp opens the wallet itself via window.open().

Tasks

  • dApp opens wallet URL via window.open()
  • Implement PostMessage handshake between opener and popup
  • Route Octez.connect messages over the popup channel
  • Re-run Phase 2 multi-chain session via popup transport

Derisking questions

  • Do popup blockers interfere with window.open() triggered on user action?
  • Is the Octez.connect SDK popup transport already implemented or does it need to be added?
  • UX comparison: popup vs. QR code — which is more natural for a Tezos web wallet?
Phase done when: Same multi-chain use-case works with a popup wallet. UX comparison between popup and Matrix QR pairing is documented.

E2E validation — test/phase6.ts (Playwright) ✓ passed 2026-04-17

npm run test:phase6 — 1 passed (2m20s)

  1. Popup opened at localhost:5174/?popup=1&headless=1 — wallet detected ?popup=1 && window.opener and entered PostMessage mode.
  2. wallet-readypermission-requestpermission-response handshake via cross-origin PostMessage. dApp shows "Connected · popup · tezos:NetXsqzbfFenSTS, tezos:NetXH12Aer3be93".
  3. L1 transfer submitted and included (shadownet). Hash: op64Cdeh…
  4. L2 Michelson contract call submitted and included via counter-poll. Hash: opFgG1Xu…
  5. Popup remains open across both operations (popupPage.isClosed() === false).

Root cause found during validation: port 5174 was occupied by the Phase 5 headless Node.js server instead of the wc2/wallet Vite dev server — popup was getting "Cannot GET /". Also: popup handler was passing raw Beacon ops to Taquito without the destination→to / amount→number mapping, fixed in runPopupMode.

Wallet integration guide — Tezos X multi-chain sessions

Three small, backward-compatible changes to the TZIP-10 Beacon protocol unlock sessions that span multiple Tezos-family chains simultaneously. This page shows the impact from three perspectives — the dApp, a Chrome extension wallet, and a standalone app wallet — with before/after diffs and edge cases.

Protocol summary:  permission_request gains an optional networks[] field.  permission_response gains an optional accounts map.  operation_request's network field now accepts a bare CAIP-2 string.

dApp role. The dApp declares which chains it needs at connection time, detects the wallet's capability from the response, and tags each operation with its target chain ID. Fee estimation is the wallet's responsibility — the dApp sends operation details without fees and the wallet handles the rest.
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
    }
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.

Matrix P2P transport

Same payload changes as the Chrome extension tab — handle networks[], return accounts, read CAIP-2 network. Transport (QR pairing → Matrix room) is unchanged.

Popup transport (new)

The dApp opens the wallet via window.open(?popup=1). Communication is via cross-origin postMessage. Useful when the wallet is a web app on its own domain.

Popup transport — tzip10-popup protocol

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.