Battle-tested patterns for Tezos dApp wallet integration using the octez.connect DAppClient. Covers Temple Wallet quirks, network config, fork conflicts, beacon state reset, and a minimal React pattern.
skill_type: patterns
domain: Tezos blockchain / dApp wallet integration
version: 1.0
derived_from: Real-world debugging of hack.tez + BCD (Better Call Dev) source analysis
Hard-won patterns for using @tezos-x/octez.connect-sdk's DAppClient in production dApps. These patterns address actual failure modes encountered when connecting to Temple Wallet (and other Tezos wallet extensions) via the beacon protocol.
Temple Wallet's beacon handler maps network.type strings to internal chain IDs. If the wallet version doesn't recognize a network name (e.g. "ghostnet"), it throws PARAMETERS_INVALID_ERROR — a completely opaque error with no description.
Temple's internal NetworkType definition may NOT include all networks that @tezos-x/octez.connect-types defines. The SDK has NetworkType.GHOSTNET, NetworkType.WEEKLYNET, etc., but Temple may only know mainnet | custom | rionet | seoulnet.
import { DAppClient, NetworkType, type Network } from "@tezos-x/octez.connect-sdk";
function buildNetwork(name: string, rpcUrl: string): Network {
if (name === "mainnet") {
return { type: NetworkType.MAINNET };
}
// For ANY non-mainnet network, use CUSTOM with explicit RPC URL.
// This bypasses the wallet's internal network lookup entirely.
return {
type: NetworkType.CUSTOM,
name: name.charAt(0).toUpperCase() + name.slice(1), // "Ghostnet"
rpcUrl: rpcUrl, // e.g. "https://rpc.ghostnet.teztnets.com"
};
}
const client = new DAppClient({
name: "My dApp",
network: buildNetwork("ghostnet", "https://rpc.ghostnet.teztnets.com"),
});
Temple's beacon message handler (getTempleReq in actions.ts) has this exact code path:
const network = req.network.type === 'custom'
? { name: req.network.name!, rpc: req.network.rpcUrl! } // ← uses YOUR RPC directly
: req.network.type; // ← string goes through internal lookup (CAN FAIL)
BCD also uses this pattern — see CORRECT_NETWORK_TYPES in baking-bad/bcd/src/utils/wallet.js:
const CORRECT_NETWORK_TYPES = {
"sandboxnet": NetworkType.CUSTOM,
"rollupnet": NetworkType.CUSTOM,
}
network NOT preferredNetworkDAppClient accepts both config.network (a Network object) and config.preferredNetwork (a NetworkType enum). The constructor resolves them like:
this.network = config.network ?? { type: config.preferredNetwork ?? NetworkType.MAINNET };
If you use preferredNetwork, you can only pass an enum value — no RPC URL, no name. This means you can't use CUSTOM network type, which is the safe path.
Always use network (the object form), never preferredNetwork:
// ❌ BAD — can't provide rpcUrl
new DAppClient({ name: "app", preferredNetwork: NetworkType.GHOSTNET });
// ✅ GOOD — full control
new DAppClient({ name: "app", network: { type: NetworkType.CUSTOM, name: "Ghostnet", rpcUrl: "https://..." } });
const connect = async () => {
// Check if there's already a connected account from a previous session
const existing = await client.getActiveAccount();
if (existing) {
// Already connected — just hydrate your UI state
updateUI(existing.address);
return;
}
// No active account — request fresh permissions
await client.requestPermissions({
scopes: [PermissionScope.OPERATION_REQUEST, PermissionScope.SIGN],
});
const account = await client.getActiveAccount();
if (account) updateUI(account.address);
};
Calling requestPermissions() when an active account already exists causes the SDK to either:
makeRequestBC (broadcast channel) instead of makeRequest, which behaves differentlyBCD's getNewPermissions() always checks getActiveAccount() first and only calls requestPermissions() if there's no active account.
requestOperation with Raw MichelsonFor calling smart contract entrypoints, use client.requestOperation() with raw Michelson value objects:
import { TezosOperationType } from "@tezos-x/octez.connect-sdk";
const result = await client.requestOperation({
operationDetails: [{
kind: TezosOperationType.TRANSACTION,
destination: "KT1...",
amount: "0",
parameters: {
entrypoint: "set_child_record",
value: {
prim: "Pair",
args: [
{ bytes: "..." },
{
prim: "Pair",
args: [
{ prim: "Some", args: [{ string: "tz1..." }] },
// ... rest of Michelson
]
}
]
}
}
}]
});
amount must be a string (mutez as string, e.g. "0")parameters.value is a raw Michelson JSON object, NOT a Taquito ContractMethodformatOpParams() which remaps destination → to and converts amount to numberfee, gas_limit, storage_limit unless you know what you're doingThe beacon SDK stores state in localStorage under keys prefixed with beacon:. This includes:
beacon:accounts — all known accountsbeacon:active-account — current active account identifierbeacon:postmessage-peers-dapp — paired wallet extensionsbeacon:sdk-secret-seed — cryptographic seed for encrypted channelIf this state gets corrupted (e.g., from switching between beacon SDK forks, dual-client bugs, or interrupted pairing), you get mysterious errors that persist across page reloads.
function clearBeaconState() {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith("beacon:")) keysToRemove.push(key);
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
}
async function resetConnection(client: DAppClient) {
try { await client.destroy(); } catch { /* may already be destroyed */ }
clearBeaconState();
// Recreate the client from scratch
return new DAppClient({ name: "app", network: buildNetwork(...) });
}
Always give users a "Reset Connection" button. Wallet pairing is fragile and users WILL end up in broken states. A visible escape hatch saves support tickets.
| Package prefix | Maintainer | Used by |
|---|---|---|
@airgap/beacon-* | AirGap | Temple Wallet extension (internal) |
@ecadlabs/beacon-* | ECAD Labs | @taquito/beacon-wallet |
@tezos-x/octez.connect-* | Trilitech | BCD, standalone dApps |
Each fork creates its own DAppClient singleton, registers its own postMessage listener, and generates its own cryptographic keypair. If two forks coexist in the same page:
window.addEventListener('message', ...) handlersIf you use @tezos-x/octez.connect-sdk, do NOT also import @taquito/beacon-wallet in browser code. The @taquito/beacon-wallet package bundles @ecadlabs/beacon-dapp which creates a competing client.
You CAN use @taquito/taquito, @taquito/signer, @taquito/utils in server-side code (Netlify functions, Node.js) without conflict — the collision only happens in the browser.
@temple-wallet/dapp) — simpler postMessage API@tezos-x/octez.connect-sdk pathWhen a beacon message arrives, Temple's beacon.ts decrypts it, then actions.ts maps it to Temple's internal format via getTempleReq(). Errors at ANY point in this chain get mapped to generic PARAMETERS_INVALID_ERROR.
appMetadata.name Must Be a StringTemple's requestPermission() validates:
if (typeof req?.appMeta?.name !== 'string') throw new Error(TempleDAppErrorType.InvalidParams);
If DAppClient.name is undefined/null, the getOwnAppMetadata() returns { name: undefined, ... } and Temple rejects it.
dAppNetworksChainIds MapTemple internally maps network names to chain IDs. As of recent versions:
const dAppNetworksChainIds = {
mainnet: TEZOS_MAINNET_CHAIN_ID,
ghostnet: TempleTezosChainId.Ghostnet,
shadownet: TempleTezosChainId.Shadownet,
tezlink: TempleTezosChainId.Tezlink,
rionet: TempleTezosChainId.Rio,
seoulnet: TempleTezosChainId.Seoul,
tallinnnet: TempleTezosChainId.Tallinn,
};
Newer Temple versions DO include ghostnet. Older versions may not. Using CUSTOM network type is the safe path that works across ALL Temple versions.
Note: tezlink in this map is the legacy name for what is now the Michelson interface of Tezos X. The Tezlink-era chain ID predates the previewnet and changes on network resets, so for Tezos X previewnet always use CUSTOM with rpcUrl https://michelson.previewnet.tezosx.nomadic-labs.com rather than the named entry. See the tezos-x skill.
When requestPermissions() fails:
beacon:* keys. Stale state? Nuke them.PARAMETERS_INVALID_ERROR almost always means the wallet rejected the message format, not your code logic.console.log before requestPermissions() to see the exact scopes/network being sent.grep -r "postmessage-pairing" dist/CUSTOM.ACTIVE_ACCOUNT_SET warning — "An active account has been received, but no active subscription was found" means you're creating the client before subscribing to events. Subscribe in useEffect, not after user interaction.import { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
import { DAppClient, NetworkType, PermissionScope, BeaconEvent } from "@tezos-x/octez.connect-sdk";
function createClient() {
return new DAppClient({
name: "my-dapp",
network: {
type: NetworkType.CUSTOM,
name: "Ghostnet",
rpcUrl: "https://rpc.ghostnet.teztnets.com",
},
});
}
let client = createClient();
export function WalletProvider({ children }) {
const [address, setAddress] = useState(null);
const clientRef = useRef(client);
useEffect(() => {
clientRef.current.getActiveAccount().then((acct) => {
if (acct) setAddress(acct.address);
});
clientRef.current.subscribeToEvent(BeaconEvent.ACTIVE_ACCOUNT_SET, (acct) => {
setAddress(acct?.address ?? null);
});
}, []);
const connect = useCallback(async () => {
const existing = await clientRef.current.getActiveAccount();
if (existing) { setAddress(existing.address); return; }
await clientRef.current.requestPermissions({
scopes: [PermissionScope.OPERATION_REQUEST, PermissionScope.SIGN],
});
const acct = await clientRef.current.getActiveAccount();
if (acct) setAddress(acct.address);
}, []);
const disconnect = useCallback(async () => {
await clientRef.current.clearActiveAccount();
setAddress(null);
}, []);
return <Ctx.Provider value={{ client: clientRef.current, address, connect, disconnect }}>{children}</Ctx.Provider>;
}