Embedded Wallet Solution

Key Ceremony

Run the distributed key generation ceremony, place shares across the cohort, pick a curve and recovery clause, and persist the artefacts your application needs after create() returns.

Key Ceremony

The key ceremony is the one-time event that brings a user's wallet into existence. It performs distributed key generation (DKG) across the configured cohort, places exactly one share on each participant, optionally seals a recovery bundle, and returns a LoadedKeyShare handle bound to the resulting state.

Two properties make this ceremony different from any single-key wallet creation:

  • No participant ever sees the full private key. Each party generates randomness, contributes to the protocol, and ends up holding a partial share. The full key is never reconstructed at any point — not during ceremony, not during signing.
  • Cohort topology is fixed at create time. Threshold, agent set, and curve are committed when you call create. To rotate share material in place use loaded.reshare({ passphrase }); cohort-shape changes (replace a party, change the threshold) are not currently supported.

This page walks through the ceremony from EmbeddedAgent construction to the artefacts your backend persists.

→ For the canonical reference on every option, see @zafeguard/mpc-sdk → Embedded Agent → Quickstart and Topology.


Configure the cohort

The EmbeddedAgent constructor takes a JsEmbeddedAgentConfig:

import { EmbeddedAgent, Curve, BridgeKind } from '@zafeguard/mpc-sdk';

const signer = new EmbeddedAgent({
  agents: [
    { baseUrl: 'https://signing-1.example.com', apiKey: process.env.ZG_AGENT_1! },
    { baseUrl: 'https://signing-2.example.com', apiKey: process.env.ZG_AGENT_2! },
  ],
  threshold: 2,
  curve: Curve.Secp256k1,
  // Optional — opt-in auto-refill for the presignature pool.
  presignaturePool: { autoRefillWhen: 5, autoRefillTo: 50 },
});
FieldEffect
agent[]The cloud cohort. Device sits at party 0; each entry becomes party 1, party 2, … in array order. Cohort size is 1 + agent.length. At least one agent is required.
thresholdSigning quorum (t of n). Range 2..=(1 + agent.length). Default warm-cold shape is threshold: 2 ("device + any one agent"); higher thresholds tighten security at the cost of needing more agents online per signature.
curveThe elliptic curve. Defaults to Curve.Secp256k1. A signer instance is single-curve; supporting both ECDSA chains and Solana means two signer instances.
storageStorage backend for the device share. Defaults to StorageKind.Auto (Keychain on iOS, Keystore on Android, encrypted file on Node, IndexedDB+CryptoKey on web).
bridgeTransport. Defaults to BridgeKind.Https.
presignaturePoolOpt-in auto-refill policy. { autoRefillWhen, autoRefillTo } — both required together. After a sign() drops the pool to autoRefillWhen or below, the SDK kicks a background mint up to autoRefillTo. Omit to manage the pool manually.

Choosing the cohort size and threshold

Three patterns cover most production deployments:

  • (1 device + 1 agent, threshold 2) — the simplest 2-of-2 wallet. Both parties must sign. Use for starter tiers where simplicity matters more than fault tolerance. This shape supports the sealed recovery bundle (see Recovery).
  • (1 device + 2 agents, threshold 2) — device + any one agent. Survives one agent going offline. Default for consumer apps. Recovery clause must be Noop — the additional agents themselves ARE the recovery surface.
  • (1 device + 3 agents, threshold 3) — device + any two agents. Survives one agent plus regional infrastructure issues. Default for premium tiers. Recovery clause must be Noop.

→ See Embedded Agent → Topology for the formal trade-off.

Mixing built-in and self-hosted agents

A common production pattern uses two cloud agents on Zafeguard's built-in infrastructure plus one agent inside your own VPC:

const signer = new EmbeddedAgent({
  agents: [
    { baseUrl: 'https://built-in-1.zafeguard.com',  apiKey: process.env.ZG_BUILTIN_1! },
    { baseUrl: 'https://built-in-2.zafeguard.com',  apiKey: process.env.ZG_BUILTIN_2! },
    { baseUrl: 'https://mpc.your-internal.example', apiKey: process.env.ZG_SELF_HOSTED! },
  ],
  threshold: 3,
  curve: Curve.Secp256k1,
});

An attacker who compromises Zafeguard's built-in agents still cannot sign without also compromising your infrastructure — an independence boundary you can defend separately.


Run the ceremony

signer.create drives the DKG protocol with the configured cohort and returns a LoadedKeyShare:

import { RecoveryKind } from '@zafeguard/mpc-sdk';

const loaded = await signer.create({
  signerId: `user-${userId}`,
  recovery: { kind: RecoveryKind.Password, password: userPassphrase },
  passphrase: exportPassphrase,    // seals loaded.exportedBlob — persist directly
  // Optional — forwarded to the agent's pre_dkg webhook for your audit pipeline.
  policyContextJson: JSON.stringify({
    productSurface: 'mobile-app-v2',
    onboardingFlow: '2026-spring',
  }),
});

Three things happen inside this call, in order:

  1. DKG protocol execution. The SDK opens an authenticated channel to each cohort agent. The parties run the multi-round DKG protocol, exchanging commitments and zero-knowledge proofs. At the end, each party holds exactly one share; the device holds its own share.
  2. Share persistence on the device. The device share is sealed into the configured storage backend (Keychain on iOS, Keystore on Android, IndexedDB+CryptoKey on web, encrypted file on Node).
  3. Recovery seal (if applicable). For the 2-of-3 single-agent shape with a non-Noop recovery clause, the SDK produces a sealed recoveryBundle — opaque bytes you persist out-of-band to use with signer.load(...) on a fresh device.

signerId is required and must be non-empty; an undefined or empty value rejects with InvalidSignerId before any network side effect runs.

The returned LoadedKeyShare exposes:

loaded.signerId;          // string — your application-scoped ID
loaded.publicKey;         // Buffer — compressed public key, used to derive addresses
loaded.curve;             // Curve — Curve.Secp256k1 or Curve.Ed25519
loaded.threshold;         // number
loaded.participants;      // number — cohort size
loaded.recoveryBundle;    // Buffer | null — sealed bundle (single-agent + non-Noop only)

There is no auto-derived multi-chain address surface on the handle. Addresses are computed from loaded.publicKey using the standard derivation paths for each chain — either in your application code or through the workflow components COMPUTE_PUBLIC_KEY and COMPUTE_EVM_ADDRESS (the Smart Account Flow shows the workflow-side derivation pattern).


Picking a recovery clause

The recovery clause passed to create controls whether a sealed recovery bundle is produced and what credential opens it. Real options are discriminated by RecoveryKind:

import { RecoveryKind } from '@zafeguard/mpc-sdk';

// No sealed bundle. Losing the export() blob ends the wallet.
recovery: { kind: RecoveryKind.Noop }

// Passphrase-sealed bundle. User memorises or writes down the passphrase.
recovery: { kind: RecoveryKind.Password, password: 'twelve word seed phrase' }

// Shamir-split bundle. Distribute across trusted contacts (any K of N restore).
recovery: {
  kind: RecoveryKind.Social,
  socialThreshold: 3,
  socialRecipientsHex: [contact1X25519Hex, contact2X25519Hex, contact3X25519Hex, contact4X25519Hex],
}

// RSA-OAEP sealed to a custodian's public key.
recovery: { kind: RecoveryKind.Custodial, custodialPublicKeyPem: hsmPublicKeyPem }

The sealed-bundle variants only apply to the 2-of-3 single-agent shape; multi-agent cohorts (cohort size 3+) require Noop because the additional agents themselves are the recovery surface.

→ See Embedded Agent → Recovery for the full design rationale.


Persisting ceremony output

After create returns, your application needs to persist both the device-share blob and (if produced) the recovery bundle.

// The export blob — encrypted device share + transport keys + cached cohort
// metadata, sealed inside create() under the create-time `passphrase`.
// scrypt(passphrase) → AES-256-GCM(salt || nonce || ciphertext || tag).
// Already ready on `loaded.exportedBlob`; no second `loaded.export(...)` call
// needed for the routine path.
await secureStorage.put(`signer:${userId}`, loaded.exportedBlob!);

// The recovery bundle (when present) — parallel sealed copy of the device share,
// gated by the recovery clause credential. Persist OUT OF BAND from the export blob.
if (loaded.recoveryBundle) {
  await cloudBackup.put(`recovery:${userId}`, loaded.recoveryBundle);
}

// Public material your backend can persist freely — nothing private here.
await db.users.update(userId, {
  signerId: loaded.signerId,
  publicKeyHex: loaded.publicKey.toString('hex'),
  curve: loaded.curve,
  threshold: loaded.threshold,
  participants: loaded.participants,
});

The export blob is the canonical state for "rehydrate on the same device or on a fresh device the user still controls." The recovery bundle is the canonical state for "rehydrate on a fresh device where the user lost the export blob, using the recovery credential." Persist them with independent durability and access patterns — losing both ends the wallet.

The export blob also carries the presignature pool snapshot, so a device that pre-minted entries can resume signing against them after restart.


Adding a second curve for Solana support

A single EmbeddedAgent instance is single-curve. To support both EVM/Bitcoin and Solana, construct two signer instances and run two ceremonies under one signerId namespace at the application layer:

const ecdsaSigner = new EmbeddedAgent({ agent, threshold: 2, curve: Curve.Secp256k1 });
const ecdsaLoaded = await ecdsaSigner.create({
  signerId: `user-${userId}-secp256k1`,
  recovery: { kind: RecoveryKind.Password, password },
  passphrase: exportPassphrase,
});

const ed25519Signer = new EmbeddedAgent({ agent, threshold: 2, curve: Curve.Ed25519 });
const ed25519Loaded = await ed25519Signer.create({
  signerId: `user-${userId}-ed25519`,
  recovery: { kind: RecoveryKind.Password, password },
  passphrase: exportPassphrase,
});

The two signers are independent — different shares, different cohort sessions, different recovery state. Your application code joins them under one logical wallet for the user. The agent connection list can be identical; the DKG runs are separate.


What the user sees during the ceremony

The DKG protocol is multiple network round-trips and a few hundred milliseconds of cryptographic work on the device. For UX:

  • Show a "creating your wallet" screen for the duration. The ceremony is irreducible.
  • Derive and display addresses on success — call COMPUTE_EVM_ADDRESS (or the relevant helper) against loaded.publicKey and show the user the addresses they will receive funds at.
  • Do not gate the wallet on recovery acknowledgement. Recovery clause persistence happens during create itself; any guardian-side flow you ship on top of the SDK (notifying contacts, requesting backups of the recovery bundle) can run asynchronously after the ceremony returns.

Next

On this page