Embedded Wallet Solution

Smart Account Flow

Build an embedded smart-account wallet where every end user gets a deterministic Gnosis Safe address derived from one MPC root key, lazy-deploys on first use, and pays no gas — a workflow-driven gas sponsor covers every transaction. The pattern travels from chat-app mini-apps (LINE / Telegram / Discord) to email-login dApps to in-game wallets.

Smart Account Flow

This page is the end-to-end recipe for the most common embedded-wallet shape in production: each end user gets their own deterministic on-chain account (a Gnosis Safe), the address is showable before any gas is paid, the Safe lazy-deploys on the first transaction, and the user never holds native tokens because a sponsor wallet pays gas for everyone.

The pattern travels:

  • Chat-app mini-app wallets (LINE LIFF, Telegram Mini App, Discord Activity, WhatsApp Business app) — swap the chat-app SDK for the equivalent; the wallet shape stays identical.
  • Email / passkey-login dApps — no chat-app; the user identity is your auth system; everything else is the same.
  • In-game wallets — the game's account ID becomes the per-user index; gas sponsorship is the studio.
  • Embedded merchant wallets — buyers get a Safe; the merchant covers gas; checkout flow stays single-tap.

What ties them together: one MPC root key + per-user BIP-32 derivation + Gnosis Safe counterfactual addressing + a gas-sponsor address + workflow-driven execution. Every one of those primitives is a Zafeguard component or a Zafeguard SDK call.

→ Canonical SDK reference: @zafeguard/caller-sdk.


What the end user sees

  1. Opens your app — chat-app mini-app, web app, mobile app, in-game store.
  2. One-tap sign-in with whatever identity your product uses (LINE login, email, passkey, in-game account, etc.). No seed phrase, no extension install.
  3. Sees their wallet address the first time they open the app — derived deterministically from their user ID before they ever pay gas.
  4. Sends tokens, swaps, mints NFTs, checks out — all without holding the chain's native token. Your sponsor wallet pays gas for everyone.
  5. Optional: exports their key to take their wallet off-platform — a one-time encrypted delivery.

The wallet supports every major EVM chain (mainnet, L2s, sidechains). Adding a chain is one entry in your chain config.


Why Zafeguard fits this product

ConstraintHow Zafeguard solves it
End users cannot manage a seed phraseMPC threshold signing — there is no single private key to lose. The user's controlling key material is split across MPC nodes at provisioning time.
Every user needs their own address, deterministically derived from their identityBIP-32 / BIP-44 derivation off one MPC root key — every chat-app user / email / game account maps to a unique deterministic address via an addressIndex.
Users cannot pay gas in ETHA second derived address acts as a gas sponsor — also an MPC key your platform controls. It pays gas for every user's transaction.
Need multiple EVM chains without duplicating wallet codeA single workflow triggered with a chainId and an RPC URL handles all of them.
Need smart-contract wallets per user (recovery, batching, social)Address derivation pipes into Zafeguard's Gnosis Safe components — every user gets a counterfactual Safe address that auto-deploys on first transaction.

Architecture

                    Your app (front-end)
                              |
                              v  HTTPS
+-----------------------------------------------------+
| Mini-app / web app / mobile app                     |
| Loads, gates with PIN / passkey, talks to backend.  |
+-----------------------------------------------------+
                              |
                              v
+-----------------------------------------------------+
| Your backend                                        |
|                                                     |
|   User authentication                               |
|   PIN / passkey gate                                |
|   DB of user -> addressIndex                        |
|   Calls Zafeguard via @zafeguard/caller-sdk         |
+-----------------------------------------------------+
                              |
                              |  SDK
                              v
+-----------------------------------------------------+
| Zafeguard                                           |
|                                                     |
|   MPC root key + per-user derivation                |
|   Workflow runs (Safe deployment, transfers, swaps) |
|   Component executions                              |
+-----------------------------------------------------+
                              |
                              |  MPC threshold-sign + broadcast
                              v
+-----------------------------------------------------+
| Public chain RPC                                    |
| Ethereum mainnet, L2s, etc.                         |
+-----------------------------------------------------+

Your backend never sees a private key. It calls Zafeguard's SDK with public inputs (a user index, calldata, a destination), and Zafeguard signs + relays the transaction using the MPC root key.


Pattern 1 — Deterministic per-user address

When a new user signs in, your backend derives their address once and caches it. The address is a pure function of (MPC_ROOT_KEY_ID, addressIndex) — replayable, recoverable, predictable.

Two paths get you to the same address depending on how you run your MPC nodes. Both produce identical results — pick by your operations model.

Path A — Zafeguard-managed nodes (via component execution)

If your MPC root key is hosted on Zafeguard's managed cluster, the public key derivation happens inside a workflow component call. Your backend never connects directly to a node:

import { WorkspaceClient, ComponentModule } from '@zafeguard/caller-sdk';

const workspace = new WorkspaceClient({ apiKey: process.env.ZAFEGUARD_API_KEY! });
const KEY_ID = process.env.ZAFEGUARD_KEY_ID!;

async function deriveUserEvmAddress(addressIndex: number): Promise<string> {
  // Non-hardened derivation path — every index < 2^31. Public-only
  // derivation works because no level requires the parent private key,
  // so the COMPUTE_PUBLIC_KEY component runs as a pure public-key tweak
  // (no threshold ceremony on the cluster).
  const derivationPath = [44, 60, 0, 0, addressIndex];

  // 1. Ask the cluster to derive the public key at that path.
  const { publicKey } = await workspace
    .call(ComponentModule.COMPUTE_PUBLIC_KEY, { keyId: KEY_ID, derivationPath })
    .promise();

  // 2. Compute the EVM address from the derived public key.
  const { address } = await workspace
    .call(ComponentModule.COMPUTE_EVM_ADDRESS, { publicKey })
    .promise();

  return address;
}

The COMPUTE_PUBLIC_KEY component talks to the cluster's MPC servers. You can pin which server it queries via the server config ('OFFICIAL_1' | 'OFFICIAL_2' | 'OFFICIAL_3') — any server holding a share suffices because deriving a public key from share commitments does not require a threshold ceremony. See Components → MPC → COMPUTE_PUBLIC_KEY for the reference.

Key concept — WorkspaceClient: the entry point for every Zafeguard component call. Each call goes through workspace.call(ComponentModule.COMPONENT_NAME, inputs).promise(). The .promise() waits for the result over a live stream — no polling required.

Key concept — KEY_ID: the identifier for your MPC root key, created once in the Zafeguard dashboard. Every user address and the gas-sponsor address derive from this single key via BIP-44 paths. Private key material never leaves the MPC nodes.

Path B — Self-hosted nodes (via @zafeguard/mpc-sdk)

If you run your own MPC cluster (self-hosted nodes), your backend talks to a node directly via ClusterAgent and derives the child public key locally with Scheme.computePublicKey. No workflow component call required:

import { ClusterAgent, Scheme, Curve } from '@zafeguard/mpc-sdk';
import { keccak256 } from 'viem';

// One-time per process: connect to any node holding the root key share.
const agent = ClusterAgent.connect(
  process.env.SELF_HOSTED_NODE_HOST!,    // e.g. 'node-1.your-internal.example'
  Number(process.env.SELF_HOSTED_NODE_PORT ?? '443'),
  process.env.SELF_HOSTED_NODE_API_KEY!,
  true,                                   // tls
);

const KEY_SHARE_ID = process.env.SELF_HOSTED_KEY_SHARE_ID!;
const scheme = new Scheme(Curve.Secp256k1);

// Cache the root public key on first call — subsequent derivations are local.
let rootPubCache: Buffer | undefined;

async function getRootPublicKey(): Promise<Buffer> {
  if (rootPubCache) return rootPubCache;
  // Fetch the root public key from the self-hosted node. Any node in the
  // cluster holding this key share returns the same hex.
  const ks = await agent.keyShares.get(KEY_SHARE_ID);
  rootPubCache = Buffer.from(ks.publicKeyHex, 'hex');
  return rootPubCache;
}

async function deriveUserEvmAddress(addressIndex: number): Promise<string> {
  // Non-hardened path — every index < 2^31. Public-only derivation works
  // because no level requires the parent private key.
  const derivationPath = [44, 60, 0, 0, addressIndex];

  const rootPub = await getRootPublicKey();

  // Derive the child public key at the path — fully local, no network call.
  // `Scheme.computePublicKey` runs as a pure public-key tweak on non-hardened
  // indices; the MPC nodes are not involved.
  const { publicKey } = scheme.computePublicKey({
    publicKey: rootPub,
    path: derivationPath,
  });

  // Compute the EVM address from the uncompressed public key — standard keccak.
  // (Use Scheme.computePublicKey with `compressed: false` if you need the
  // uncompressed form for libraries that expect it.)
  const uncompressed = scheme.computePublicKey({
    publicKey: rootPub,
    path: derivationPath,
    compressed: false,
  }).publicKey;
  // uncompressed[0] = 0x04 marker; strip it before hashing.
  const address = '0x' + keccak256(uncompressed.subarray(1)).slice(-40);
  return address;
}

The first call to getRootPublicKey() is a single HTTP round-trip to the self-hosted node (or any node in the cluster — they all hold the same root public key). After that, every per-user derivation is a local computation with no network involved.

When to use which path:

Operations modelPath
MPC nodes hosted by ZafeguardPath A — component execution via caller-sdk
MPC nodes hosted in your own infrastructurePath B — direct via mpc-sdk's ClusterAgent + Scheme.computePublicKey
Mixed deployment (some Zafeguard, some self-hosted)Either path works; pick by whichever node your backend has direct network access to

Whichever path you use, the cached (userId, addressIndex) mapping in your DB is the same. Your backend assigns each new user an addressIndex (a monotonic counter) on first sign-in and persists the pair; from then on every derivation is a lookup.


Pattern 2 — Counterfactual Gnosis Safe per user

Each user gets a Gnosis Safe address calculated before any deployment gas is paid. The wallet UI can show "your address" on first sign-in; the Safe deploys lazily on the user's first transaction.

import { keccak256, toBytes } from 'viem';

async function deriveUserSafeAddress(
  userOwnerAddress: string,
  userId: string,
  jsonRpcUrl: string,
): Promise<string> {
  // The salt nonce makes the Safe address deterministic per user — re-deriving
  // with the same userId yields the same Safe address even after a database wipe.
  const saltNonce = keccak256(toBytes(userId));

  const { safeAddress } = await workspace
    .call(ComponentModule.GET_EVM_GNOSIS_SAFE_ADDRESS, {
      jsonRpcUrl,
      owners: [userOwnerAddress],
      threshold: 1,
      saltNonce,
    })
    .promise();

  return safeAddress;
}

For the full lazy-deploy + gas-sponsor reference (the workflow shape that combines deployment with a transaction in one batched call), see Smart Account — Lazy Deploy + Gas Sponsorship.


Pattern 3 — Gasless transfer via workflow

The user taps Send 10 USDC → 0xRecipient. Your backend:

  1. Builds the ERC-20 transfer calldata.
  2. Triggers a workflow that wraps the calldata in a Gnosis Safe execution call, signs it with the user's MPC-derived owner key, has the gas-sponsor key broadcast the outer transaction, and waits for the receipt.
import { encodeFunctionData, parseUnits } from 'viem';

const erc20TransferCalldata = encodeFunctionData({
  abi: [
    {
      name: 'transfer',
      type: 'function',
      inputs: [
        { name: 'to', type: 'address' },
        { name: 'amount', type: 'uint256' },
      ],
      outputs: [{ type: 'bool' }],
    },
  ],
  functionName: 'transfer',
  args: [recipientAddress, parseUnits('10', 6)],   // USDC has 6 decimals
});

// Trigger the product's Safe-execution workflow. It owns the orchestration —
// signing, gas estimation, broadcast, receipt wait.
const { runId } = await workflow.trigger({
  userId: user.id,
  jsonRpcUrl: CHAIN_RPC[chainId],
  addressIndex: user.addressIndex,
  to: USDC_CONTRACT,
  calldata: erc20TransferCalldata,
  value: '0',
});

// Stream the run's status to the front-end so the UI can show
// "pending → signed → broadcast → confirmed" without polling.
const result = await workflow.execution.get(runId);

The workflow itself is built once in the Zafeguard visual builder and reused for every user. Adding a new chain doesn't require a new workflow — only a new RPC URL in the chain config.

The workflow signs the inner Safe call with the user's MPC-derived owner key, but the outer EVM transaction is sent from a separate gas-sponsor address (also an MPC key your platform controls). The user never holds the native token; the sponsor's balance pays gas for everyone.

If the sponsor address runs low, the wallet UI gates new transactions and surfaces a "service top-up needed" message instead of failing on-chain.


Pattern 4 — Token swap via Uniswap

The wallet ships with a swap screen. The pattern is the same gasless-Safe pattern from Pattern 3, but the calldata is built by Zafeguard's Uniswap components rather than by hand.

// Step A — quote the swap (cached server-side; the UI runs a 30s countdown
// and re-fetches if the user hesitates).
const quote = await workspace
  .call(ComponentModule.GET_UNISWAP_SWAP_QUOTE, {
    jsonRpcUrl,
    tokenIn: WETH,
    tokenOut: USDC,
    amountIn: parseUnits('0.1', 18).toString(),
  })
  .promise();

// Step B — build the swap calldata once the user accepts the quote.
const swap = await workspace
  .call(ComponentModule.BUILD_EVM_UNISWAP_SWAP_CALLDATA, {
    jsonRpcUrl,
    tokenIn: WETH,
    tokenOut: USDC,
    recipient: user.safeAddress,
    amountIn: parseUnits('0.1', 18).toString(),
    amountOutMinimum: applySlippage(quote.amountOut, '0.5%'),
  })
  .promise();

// Step C — execute through the same Safe-execution workflow.
const { runId } = await workflow.trigger({
  userId: user.id,
  jsonRpcUrl,
  addressIndex: user.addressIndex,
  to: swap.to,
  calldata: swap.calldata,
  value: swap.value,
});

The same workflow handles both an ERC-20 transfer and a Uniswap swap — the inputs differ, but the orchestration (Safe execution + gas sponsor + receipt) is identical.


Putting it all together

A complete user journey, end to end:

  1. First open — user signs in → backend assigns addressIndex=42 → derives EVM owner address + Safe address → caches both in DB.
  2. First send — user taps "Send 10 USDC" → backend triggers the Safe-execution workflow with calldata → workflow signs with addressIndex=42, gas sponsor broadcasts → confirmation streams back over the workflow status channel → UI updates.
  3. First swap — same pattern, calldata built by Uniswap component instead of hand-encoded.

Your backend code is mostly business logic — derive an index, hold a user record, trigger a workflow. The cryptography, gas-sponsorship orchestration, chain RPC interactions, and Safe deployment are all behind the SDK calls and the workflow components.

For user-initiated wallet recovery / key export (taking the wallet off-platform), the right primitive depends on which path you're on — RECOVER_CHILD_PRIVATE_KEY component for Zafeguard-managed nodes, ClusterAgent.exportRecoveryBundle + RecoveryBundle.recover for self-hosted nodes. See Embedded Wallet → Recovery and Custody Whitelabel — Cold Wallet → Lost-device recovery for both flows.


Picking the right MPC tier for this shape

The smart-account flow described above runs against an MPC root key that's typically held in one of three places. Pick by your product shape:

Your shapeRight tier
Consumer chat-app or web wallet — each user has a logical wallet derived from one platform-managed root keyUse this smart-account pattern as-is. The root key is a platform-managed ClusterAgent cluster key; per-user shares are derivation paths.
Each end user really holds a share on their own device, no platform root keyUse the Key Ceremony flow per user instead — EmbeddedAgent.create(...) runs DKG; no central root.
Server-side custody backend that needs warm-tier signing in addition to per-user walletsWarm Wallet — server-side EmbeddedAgent online.
24/7 high-throughput operations with no host wanting to hold a shareHot Wallet — pure cluster.
Reserve tier, air-gapped, multi-party witnessed signingCold Wallet — the cold-wallet flow partial-ferry.

The smart-account flow on this page composes naturally with all of them. The user-facing Safe addresses derive from whichever root key you set up; the gas-sponsor address derives from another. Whether the root key is held by a platform-managed cluster, a warm-tier host, or a cold-tier device only changes WHICH SDK call signs (workflow components vs loaded.sign vs Utils.unwrap + Utils.computePartialSignature flow) — the workflow shape and user UX are identical.


Why this pattern travels

The same architecture works for any embedded-wallet product:

  • LINE Mini App wallet — LIFF login provides the user ID; everything else identical.
  • Telegram Mini App wallet — Telegram's WebApp SDK provides the user ID.
  • Discord activity wallet — Discord's Activities SDK provides the user ID.
  • In-game wallet — the game's account ID is the user ID.
  • Branded consumer wallet — strip the chat-app and use email or passkey login.

The Zafeguard side — root MPC key, per-user addressIndex derivation, gas sponsor, Safe components, execution workflow — does not change.


Next

On this page