Skip to main content
Version: 0.5.0

Wallet Linking (EOA → ZKP account)

Many apps already have a MetaMask (or other EOA) session. Wallet linking derives a ZKP account deterministically from a one-time EIP-712 signature on that EOA — no mnemonic to store, and the same wallet always yields the same ZK address (EPK) — the zk1… address.

The signature seeds only the ESK (the read/decrypt key), from which the ZK address (EPK) is derived. The connected wallet is the account's controller: its own EOA key takes the place of a derived controller key (CSK) and signs every spend live. No CSK is derived from the signature, so a leaked linking signature exposes only the read-only ESK, never spending rights.

Permanent choice

Your app value is the EIP-712 domain name, so the wallet shows it as the app requesting the signature. It is also part of the key-derivation seed: the same wallet with a different app derives a separate account that can't see the other's funds. Choose one value per application and never change it — switching it strands whatever is held under the old value (recoverable only by linking again with that same app). Pass ZKSTABLES_DEFAULT_APP for the default.

Scope

SupportedNot supported
EOAs (MetaMask, Rabby, hardware wallets in EOA mode)Smart-contract wallets (ERC-1271 signatures, Safe, etc.)
Standard 65-byte ECDSA signaturesSignature formats that are not 65 bytes

Smart wallets may return a contract-specific payload; the SDK rejects non-65-byte signatures with a clear error.

Canonical EIP-712 message

The SDK pins domain and message shape in @cardinal-cryptography/core (re-exported from @cardinal-cryptography/sdk):

FieldValue
Domain nameYour app value (default ZkStables) — shown by the wallet as the requester; part of the seed
Domain version1
Domain chainIdomitted — same derived account on every chain
primaryTypeLink
disclaimerFixed warning text shown in the wallet — committed in the signature, so permanent
walletThe linking EOA address

Constants: ZKSTABLES_DEFAULT_APP, ZKSTABLES_LINKING_DISCLAIMER, ZKSTABLES_LINKING_VERSION, ZKSTABLES_LINKING_TYPES. Use buildZkStablesLinkingSignRequest(wallet, app) for the full signTypedData payload.

import {
createRuntime,
createFullClient,
createAccountFromLinkedWallet,
ZKSTABLES_DEFAULT_APP,
} from '@cardinal-cryptography/sdk'
import { createWalletClient, custom, http } from 'viem'
import { baseSepolia } from 'viem/chains'

// 1. Connect the user's EOA (browser example)
const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' })
const walletClient = createWalletClient({
account: address,
chain: baseSepolia,
transport: custom(window.ethereum),
})

// 2. Sign linking message + derive account.
// `app` determines the keys — pass ZKSTABLES_DEFAULT_APP or your own app id.
const runtime = await createRuntime()
const account = await createAccountFromLinkedWallet(runtime, walletClient, {
app: ZKSTABLES_DEFAULT_APP,
})

console.log(account.zkpAddress) // ZK address (EPK), zk1… — deterministic for this EOA + app
console.log(account.controllerAddress) // === the linking EOA — your controller (no CSK derived)

// 3. Use with a full client (bundler path typical for dApps)
const client = await createFullClient({
chain: baseSepolia,
transport: http(),
zkpAccount: account,
})

Equivalent manual steps:

import {
signZkStablesLinkingMessage,
signerFromWalletClient,
eskFromSeed,
ZKSTABLES_DEFAULT_APP,
} from '@cardinal-cryptography/sdk'

const signature = await signZkStablesLinkingMessage(walletClient, { app: ZKSTABLES_DEFAULT_APP })
const esk = eskFromSeed(signature) // ESK (read key) → ZK address (EPK)
const signer = signerFromWalletClient(walletClient, address) // controller = the wallet (no CSK)
const account = runtime.createAccount(esk, signer)

signZkStablesLinkingMessage validates the signature length after signing.

Multiple accounts from one wallet

A linked wallet maps to one canonical account per app. Deriving several accounts from a single linked wallet is discouraged: in linked mode every account shares the same controller (the connected EOA), so their on-chain EPK registrations and spend authorizations all resolve to one address — publicly proving the accounts share a single owner. That is a stronger leak than the encrypted-balances transaction graph: the graph only reveals which accounts transact with one another, whereas a shared controller collapses the accounts into one identity outright.

A different app value derives a separate account but does not change the controller, so the accounts stay provably co-owned by that one EOA. If you need accounts that aren't tied to a common owner on-chain, link a different wallet (or use mnemonic-derived accounts, which carry their own controllers).

After linking

  1. Register the EPK on-chain before receiving encrypted transfers (Account Creation — register EPK).
  2. Persist nothing if you only need session linking — re-sign the linking message on each visit (user approves in the wallet each time).
  3. Do not persist the signature as a long-term secret — it seeds the ESK, so it grants read/decrypt access (not spending). Prefer re-linking or use a mnemonic for recovery.

See also