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.
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
| Supported | Not supported |
|---|---|
| EOAs (MetaMask, Rabby, hardware wallets in EOA mode) | Smart-contract wallets (ERC-1271 signatures, Safe, etc.) |
| Standard 65-byte ECDSA signatures | Signature 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):
| Field | Value |
|---|---|
Domain name | Your app value (default ZkStables) — shown by the wallet as the requester; part of the seed |
Domain version | 1 |
Domain chainId | omitted — same derived account on every chain |
primaryType | Link |
disclaimer | Fixed warning text shown in the wallet — committed in the signature, so permanent |
wallet | The 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.
Link a wallet
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
- Register the EPK on-chain before receiving encrypted transfers (Account Creation — register EPK).
- Persist nothing if you only need session linking — re-sign the linking message on each visit (user approves in the wallet each time).
- 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
- Accounts & Runtimes reference → Wallet linking helpers — full signatures, the
accountfallback option, and constants. - Account Creation — mnemonic, seed, and external-signer accounts.