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.
Three small, backward-compatible additions to TZIP-10
permission_requestgains an optionalnetworks[]field — the list of chains the dApp wants access to.permission_responsegains an optionalaccountsmap — public key per chain.operation_request.networkaccepts a bare CAIP-2 string (tezos:<chain-id-b58>) so each operation is unambiguously routed.
networks[] behaves exactly as before. Chain IDs follow CAIP-2, the same convention already used by WalletConnect v2.Out of scope
- Not a new transport. Rides on the three existing TZIP-10 transports (extension PostMessage, Matrix P2P, WalletConnect v2) unchanged.
- Does not standardize the popup UX explored in the POC — see Integration → Standalone app. That's a separate, exploratory sketch.
- Not a replacement for TZIP-10. Short revision or companion TZIP — not a new fully-fledged protocol.
- Not a general multi-chain framework. Scope is Tezos-family chains under the CAIP-2
tezos:namespace.
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
- Backward-compat is automatic. Unknown fields on
permission_requestpass through silently in the current SDK (plain cast, no schema validation). Older wallets see a v2 request; newer wallets see both fields. - Extension wallets are trivial to upgrade. The existing
chrome.runtimechannel is untouched; the handler adds amsg.networks ?? []branch and returns anaccountsmap. - WalletConnect v2 needs a small patch. In WC2 the session proposal is the first message, so multi-chain has to be declared up front in
requiredNamespaces/optionalNamespaces. One change toWalletConnectCommunicationClient. - Same key across Tezos-family chains. Ed25519 keys are valid on both L1 and the Michelson interface, so
accountsentries are often identical. The structure supports distinct keys per chain if the wallet uses different HD paths.
Where to go next
- Implementing this in a wallet or dApp? See Integration for before/after diffs and edge cases per audience.
- Want to see it running? See Demo — short video plus the validation table and reproduce-locally instructions.
- Curious about the full spec? The current working draft lives at
docs/wallet-multichain-integration.mdin the repository. - How was this derisked and validated? See the PoC project planning and execution — six phases, technical details, and test infrastructure.
permission_request+ optionalnetworks[]permission_response+ optionalaccountsmapoperation_request.networkaccepts CAIP-2 string
dApp integration
@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
// 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
// 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
// Routes to the network set at init
const result = await client
.requestOperation({
operationDetails: [
{ kind: 'transaction',
amount: '1',
destination: DEST },
],
})
// 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
Edge cases
-
Wallet only approves some chains (partial
accountsmap)Theaccountsmap 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 sessionGuard 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
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
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
}
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
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
}
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
}
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 chainsReturn a partial
accountsmap 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 registryUse a hardcoded fallback for known chains, or return an
operation_errorfor 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 chainFor 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 correctpublicKeyin eachaccountsentry. The dApp uses these keys independently per chain.
Standalone app wallet
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)
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
window.openeraccounts map includedchainId + operations[]transactionHashWallet skeleton
// main() initialises the wallet UI
async function main() {
const signer = await
InMemorySigner.fromSecretKey(KEY)
// set up Matrix / WC2 transport
await initBeaconClient(signer)
}
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 isnull— 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-sessionThe dApp should check
popupWindow.closedbefore 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.openeris null in the walletCan happen if the parent page has a strictCross-Origin-Opener-Policyheader (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-readyand the dApp's listener raceThe dApp must register itsmessagelistener before clicking the button that opens the popup — the popup can load fast from cache. In practice: registerwindow.addEventListener('message', onReady)synchronously inside the click handler, right afterwindow.open()returns and before the popup's JS has had time to run.
Message format — full reference
| Direction | action | Key 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) |
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 / scenario | Status | Evidence |
|---|---|---|
| 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.