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
- Opens your app — chat-app mini-app, web app, mobile app, in-game store.
- One-tap sign-in with whatever identity your product uses (LINE login, email, passkey, in-game account, etc.). No seed phrase, no extension install.
- Sees their wallet address the first time they open the app — derived deterministically from their user ID before they ever pay gas.
- Sends tokens, swaps, mints NFTs, checks out — all without holding the chain's native token. Your sponsor wallet pays gas for everyone.
- 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
| Constraint | How Zafeguard solves it |
|---|---|
| End users cannot manage a seed phrase | MPC 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 identity | BIP-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 ETH | A 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 code | A 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 model | Path |
|---|---|
| MPC nodes hosted by Zafeguard | Path A — component execution via caller-sdk |
| MPC nodes hosted in your own infrastructure | Path 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:
- Builds the ERC-20 transfer calldata.
- 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:
- First open — user signs in → backend assigns
addressIndex=42→ derives EVM owner address + Safe address → caches both in DB. - 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. - 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 shape | Right tier |
|---|---|
| Consumer chat-app or web wallet — each user has a logical wallet derived from one platform-managed root key | Use 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 key | Use 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 wallets | Warm Wallet — server-side EmbeddedAgent online. |
| 24/7 high-throughput operations with no host wanting to hold a share | Hot Wallet — pure cluster. |
| Reserve tier, air-gapped, multi-party witnessed signing | Cold 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
- Smart Account — Lazy Deploy + Gas Sponsorship — full code-complete reference for the Safe + sponsor workflow this page uses.
- Login with Zafeguard (OAuth) — if you want your embedded wallet's identity to be a Zafeguard account instead of your auth system.
- Calling Workspace APIs with an OAuth Access Token — let third-party apps talk to a user's Zafeguard workspace on their behalf.
- Key Ceremony — for the per-user-DKG variant where each end user holds their own share.
- Warm Wallet and Hot Wallet — institutional tiers if your product layer becomes server-side custody.
Signing & Pool-Backed Latency
Threshold signing with the LoadedKeyShare returned by EmbeddedAgent.create — the sign() flow, the presignature pool that cuts per-sign round-trips in half, auto-refill configuration, and the export/import handoff for cold-storage workflows.
Dynamic Configuration
Runtime configuration patterns for embedded wallets — how your application layer resolves per-user, per-tier, per-region signer shape and passes it to the SDK constructor without rewriting the app for each new combination.