Overview
Architecture overview for shipping a non-custodial embedded wallet inside your mobile or web app using Zafeguard MPC SDK and the workflow canvas.
Overview
Scenario. You are shipping a consumer-facing app — mobile, web, or both — and you want every user to have a real on-chain wallet without ever asking them to manage a seed phrase. The wallet should sign transactions on EVM, Solana, and Bitcoin from one key, recover when the user loses their device, and never let any single party (including you) move funds unilaterally.
This is the overview page. The sub-pages drill into each operational dimension with runnable code:
| Sub-page | What it covers |
|---|---|
| Key Ceremony | DKG, share placement, the real JsCreateOptions + JsRecoveryConfig shape, multi-curve via two signer instances. |
| Recovery | RecoveryKind discriminated union (Noop / Password / Social / Custodial), the sealed recoveryBundle, signer.load(...), credential rotation. |
| Signing & Pool-Backed Latency | loaded.sign(...) returning a final signature, the presignature-pool lifecycle, the export-blob / pool-import handoff. |
| Smart Account Flow | LINE LIFF-style production pattern: one MPC root key + per-user BIP-44 derivation + counterfactual Gnosis Safe + gas-sponsor workflow. Travels to Telegram, Discord, in-game, email-login. |
| Dynamic Configuration | Per-tier, per-region, per-risk cohort shape resolution. Multi-account derivation. Workflow routing. |
The recommended stack
@zafeguard/mpc-sdk→EmbeddedAgent. The user's device holds one threshold share; a cohort of cloud signing parties holds the rest. No full private key ever exists anywhere. Includes a presignature pool that cuts per-sign cohort round-trips from two to one.- Zafeguard workflows. Every signing operation is wrapped in a workflow that handles gas sponsorship, recovery escalation, policy approval, audit emission, and notifications. The workflow is triggered from your app, by schedule, or by webhook.
- Workflow-driven recovery. Social-guardian recovery runs as a workflow with notifications, time locks, geo checks, and reshare ceremonies as typed components — not as cryptographic code in your application.
This combination — embedded threshold MPC + a visual workflow canvas around it — is what lets your team ship a regulated-grade non-custodial wallet in days rather than quarters. The MPC primitive removes the seed-phrase failure mode; the workflow canvas removes the orchestration glue between signing, gas, policy, and notifications.
Architecture
Mobile app Cloud signing cohort
+----------------------+ +-------------------------+
| EmbeddedAgent | | Signing party A |
| - device share 1 | <--threshold---> - cloud share 2 |
| - presignature pool| signing | Signing party B |
+----------------------+ | - cloud share 3 |
| +-------------------------+
| |
| sign call inside a workflow |
v v
+---------------------------------------------------------------+
| Workflow |
| Policy check -> Gas sponsor -> Sign -> Submit -> Tx |
+---------------------------------------------------------------+
|
v
EVM · Solana · Bitcoin
The signing parties never communicate the full key. The user's device holds one share, your configured cohort holds the rest, and a configurable threshold of parties must agree to produce a signature. The signature is standard ECDSA, Ed25519, or Schnorr — indistinguishable on-chain from a single-signer transaction.
When this design fits
Use the embedded-wallet pattern when:
- You need true non-custodial ownership. The user holds a share of the signing material on their device; no single party can sign without them.
- You will not ask users to manage a seed phrase. Embedded MPC eliminates the seed phrase as a recovery surface and replaces it with a social-guardian flow you control.
- You ship a real product UI. Your signing UX is inside an app screen, not a hardware wallet popup or a browser extension flow.
- You want multi-chain from day one. The same MPC key signs on EVM, Solana, and Bitcoin — you do not duplicate the wallet integration per network.
- You want hosted operations on day one and self-hosted on day N. Built-in MPC ships immediately; the same SDK moves to embedded device + cloud cohort, and later to a fully self-hosted MPC cluster without rewrites.
If your product instead needs server-side institutional custody with no end-user device participation — hot-wallet treasuries, exchange backends, cold-storage signing rooms — see the Custody Whitelabel Solution.
Choosing a wallet tier per requirement
The embedded pattern is one of four wallet tiers Zafeguard supports. Pick by where the share-holding lives and what the operating shape is:
| Your requirement | Right tier |
|---|---|
| Consumer-facing app where each user holds one share on their device | Embedded Wallet (this section) |
| Server-side custody backend that signs at high throughput, cluster holds all shares | Hot Wallet |
| Server-side custody backend where your host is one of the threshold parties, online | Warm Wallet |
| Institutional reserves — air-gapped device, partial signatures ferried via QR / USB | Cold Wallet |
A typical product layer uses more than one. The smart-account flow described in Smart Account Flow runs against an MPC root key — that root key is usually held by a hot or warm tier, and the embedded pattern derives per-user accounts beneath it.
Five-minute primer
The minimum integration — install, configure the cohort, call create, call sign:
import { EmbeddedAgent, Curve, RecoveryKind } 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,
});
const loaded = await signer.create({
signerId: `user-${userId}`,
recovery: { kind: RecoveryKind.Password, password: userPassphrase },
passphrase: exportPassphrase,
});
// The export blob was sealed under `exportPassphrase` inside create()
// and is ready on `loaded.exportedBlob`. Persist it; no extra
// loaded.export(...) call required. (Call `loaded.export(passphrase)`
// later only when you want to re-seal under a different passphrase.)
await secureStorage.put(`signer:${userId}`, loaded.exportedBlob!);
if (loaded.recoveryBundle) {
await cloudBackup.put(`recovery:${userId}`, loaded.recoveryBundle);
}
const sigPartials = await loaded.sign({ messageHash });
const sig = await loaded.finalizeSignature({
messageHash: sigPartials.messageHash,
partials: sigPartials.partials,
derivationPath: sigPartials.derivationPath,
});Two passphrases, two threat models
create() takes two distinct passphrases — one named (passphrase), one nested inside recovery. They protect different artifacts on different recovery paths. Mixing them up is the most common integration mistake.
passphrase (export passphrase) | recovery.password (recovery credential) | |
|---|---|---|
| Seals | The loaded.exportedBlob — the share material wrapped at rest. | The loaded.recoveryBundle — the off-device envelope that reconstructs the share on a fresh device. |
| Unlocks via | signer.load({ signerId, blob, passphrase }) | signer.load({ signerId, recoveryBundle, recovery: { kind: RecoveryKind.Password, password } }) |
| When you use it | Every routine restart on the same device — process boots, reads the blob from storage, calls load. | Disaster recovery only — new device, no local blob. |
| Where it comes from | A machine-generated secret tied to the device's trust anchor: HSM-derived key, OS keychain entry, hardware-attested secret. Users never type it or see it. | The user themselves — typed passphrase, hardware key, or social attestation depending on RecoveryKind. This is the credential the user must remember years later. |
| Lifecycle | Rotate per device, per app reinstall, per security event. The export blob is cheap to re-produce by calling loaded.export(newPassphrase). | Rotate via loaded.rotateRecoveryCredential({ ... }) only with the user's consent; treat as account-level credential. |
| Loss outcome | Local blob is bricked; fall back to recovery. No share loss. | Account-level loss — if the user forgets and the recovery clause is the only path back to their share, the wallet is unrecoverable through that clause. |
A minimal two-credential setup that keeps the threat models separate:
const exportPassphrase = await hsm.deriveKey('signer-export', userId); // machine secret
const userPassphrase = await promptUserForRecoveryCredential(); // human secret
const loaded = await signer.create({
signerId: `user-${userId}`,
recovery: { kind: RecoveryKind.Password, password: userPassphrase }, // future restore
passphrase: exportPassphrase, // sealed blob NOW
});You can pass the same string for both — the snippet above would still compile and run — but it conflates two different threats: an attacker who steals the local storage (defeated by exportPassphrase) versus an attacker who phishes the user (defeated by userPassphrase). Sharing one secret across both lets either attack defeat the other.
If your product surface has no human-typed credential (e.g. a backend service signing on behalf of a workflow), choose RecoveryKind.Custodial or RecoveryKind.Noop instead — see Recovery for the full clause matrix.
Two artifacts, two storage locations
The two passphrases pair with two parallel sealed snapshots that come out of create(). They cover different failure modes and must live in different storage — putting both in the same place defeats the redundancy.
loaded.exportedBlob (export blob) | loaded.recoveryBundle (recovery bundle) | |
|---|---|---|
| What it contains | Encrypted snapshot of the device share + transport keys + cached cohort metadata + presignature pool. | A separate sealed copy of just the device share (no transport state, no pool). |
| Failure mode it survives | Process restart / app update / reinstall on the same device. | The original device is lost / destroyed / wiped. |
| Sealed by | passphrase (export passphrase). | The recovery credential — recovery.password / Social shards / Custodial RSA pubkey. |
| Restored with | signer.load({ signerId, blob, passphrase }) — fast, local, used on every restart. | signer.load({ signerId, recoveryBundle, recovery }) — slow, off-device, used only during disaster. |
| Where to persist | Device-local — OS keychain, Secure Enclave-wrapped store, encrypted DB on the same device. | Off-device — cloud backup, paper, Shamir-split across contacts, custodian's HSM. |
| Always produced? | Yes — every create({passphrase}) and reshare({passphrase}) seals one. | Only when recovery.kind !== Noop AND single-agent cohort. null otherwise. |
// Two snapshots, two storage locations.
await deviceSecureStorage.put(`signer:${userId}`, loaded.exportedBlob!); // device-local
if (loaded.recoveryBundle) {
await cloudBackup.put(`recovery:${userId}`, loaded.recoveryBundle); // off-device
}Lose both → wallet is unrecoverable. The cohort cannot reconstitute a missing device share without at least one of these two artifacts. The agent never sees either blob.
That is enough to wire the wallet end-to-end. The four sub-pages cover what production operations require around this minimum — share placement and address derivation, recovery clauses that survive device loss, presignature-pool lifecycle, and the configuration patterns that let one codebase serve every user tier you ship.
→ Continue with Key Ceremony.
Chain support
A signer instance is single-curve. Two curves cover every supported chain — apps that need both run two EmbeddedAgent instances under the same signerId namespace.
| Family | Networks | Address types | Curve |
|---|---|---|---|
| EVM | Ethereum mainnet, Polygon, Arbitrum, Base, Optimism, Avalanche, any EVM-compatible chain | Standard externally-owned account (0x…) | secp256k1 |
| Bitcoin | Mainnet, testnet, signet | P2WPKH (bech32), P2TR (taproot) | secp256k1 / Schnorr |
| Solana | Mainnet-beta, devnet | Base58-encoded public key | Ed25519 |
loaded.sign({ messageHash, derivationPath? }) returns a curve-appropriate signature; the chain-specific transaction shape (RLP for EVM, PSBT for Bitcoin, Solana transaction format) is composed by the corresponding chain component in your workflow. Addresses are derived from loaded.publicKey by your application or by the COMPUTE_EVM_ADDRESS / COMPUTE_PUBLIC_KEY workflow components.
→ EVM components · Solana components · Bitcoin components
Where to read deeper
- Key Ceremony — DKG and the real SDK option shapes.
- Recovery —
RecoveryKindvariants,signer.load(recovery-bundle path), credential rotation. - Signing & Pool-Backed Latency —
loaded.signreturning a final signature, presignature pool lifecycle. - Smart Account Flow — production blueprint: per-user Safe + gas-sponsor workflow, LINE LIFF-style pattern that travels to every embedded-wallet shape.
- Dynamic Configuration — tier, region, risk topologies.
- Hot Wallet · Warm Wallet · Cold Wallet — institutional tiers, choose by requirement.
- Embedded Agent reference — full SDK surface for every method used in this guide.
Overview
Real-world Zafeguard SDK recipes — production patterns for smart accounts, DeFi integrations, and gasless transactions.
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.