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
- 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"}.
- 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).
- 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.
- Subset approval: not tested in this phase —
HEADLESS_APPROVE_NETWORKS flag not implemented. Deferred to a later phase or TZIP documentation.
- 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.