Cold Wallet
Device-side EmbeddedAgent with a locally-stored presignature pool. The cold host generates its own share during DKG, holds it in HSM-backed storage, and signs against the cohort with a single short round-trip per call by consuming pre-minted presignatures. Refill is an explicit, witnessed event.
Cold Wallet
The cold wallet holds reserves. It signs rarely, only after multi-party approval, and runs on a controlled host that minimises its exposure to the public internet. The signing material — the cold party's share of the key — is generated on the cold host itself during DKG and never leaves the host; the rest of the cohort lives on the MPC agents.
The pattern is built on EmbeddedAgent. The cold host:
- Participates in DKG with the agent cohort so its share is locally generated.
- Pre-mints a batch of presignatures during planned "warming" events when officers are present and the host has cohort connectivity.
- Sits idle the rest of the time. When a signing request arrives, opens a single short cohort connection, consumes one cached presignature, runs the partial-signing round-trip, and combines the partials locally into the final signature.
The cached pool turns every signature into a one-round-trip operation against the cohort — much shorter cohort exposure than the inline path that runs presigning + signing back-to-back. Refilling the pool is the only operation that takes a long online window, and it is the one operation that operators schedule in advance.
→ Canonical SDK reference: EmbeddedAgent.
Why generate the share on the cold host
The strongest property of this pattern is that the cold party's share material is generated on the cold host during DKG. The host runs signer.create(...), contributes its share commitments to the cohort, finishes the DKG, and is the only place its share ever materialises. The cohort agents hold their own shares; no party ever assembles the root private key.
Compared to a pattern where the cluster generates every share and exports one to the cold device:
- No transit risk for the cold share. The share is computed on the cold host from random material the host contributed itself. Nothing carrying the cold share ever moves across a network.
- No long-lived sealed blob outside the host. The cluster never has the cold share to seal. Whatever the cluster persists is its own share, not the cold host's.
- Standard sign flow. Every signing call goes through
loaded.sign({ messageHash })returning a fully-combined(r, s, v). Your application gets a final signature, not a partial.
The cost: the cold host needs cohort connectivity twice — once during the DKG ceremony (one-time per key) and again at every signing event (short round-trip per sign). The pattern is "controlled-connectivity cold" rather than "permanently air-gapped cold".
Topology
+---------------------------------------+
| Cold host (your secure facility) |
| |
| EmbeddedAgent.create / load |
| -> LoadedKeyShare holds 1 share |
| -> presignature pool (cached |
| locally, refilled in batches) |
| |
| loaded.sign({ messageHash }) |
| -> short cohort round-trip |
| -> combines locally |
| -> returns final signature |
+----------------+----------------------+
|
| HTTPS to cohort agents — opened only
v at refill events + per-signature calls
+---------------------------------------+
| Agent cohort |
| |
| Each agent holds one share of the |
| cold key. Multi-agent cohort means |
| any one agent outage is tolerated. |
+---------------------------------------+
A typical institutional cold topology:
| Shape | Survives | When |
|---|---|---|
(host + 1 agent, threshold 2) | Minimum viable, both parties required | Tight control, smaller cohort; single-agent recovery allowed |
(host + 2 agents, threshold 2) | Host + any one of two agents | Survives one agent outage |
(host + 3 agents, threshold 3) | Host + any two of three agents | Default for reserves — best resilience + correlated-failure protection |
(host + 3 agents, threshold 3) is the recommended default for cold reserves: the cold host is mandatory (its share is required to reach threshold), and the cluster needs two of three agents — surviving one regional outage on the cluster side.
Provisioning the cold key
The cold key is provisioned by running DKG on the cold host with the agent cohort online. This is a witnessed event — officers are present, the host is in its secure facility, and the connectivity window is opened deliberately.
import { EmbeddedAgent, Curve, RecoveryKind, StorageKind } from '@zafeguard/mpc-sdk';
// On the cold host, with cohort connectivity open for the ceremony
const signer = new EmbeddedAgent({
agents: [
{ baseUrl: 'https://agent-1.zafeguard.com', apiKey: process.env.ZG_AGENT_1! },
{ baseUrl: 'https://agent-2.zafeguard.com', apiKey: process.env.ZG_AGENT_2! },
{ baseUrl: 'https://agent-3.zafeguard.com', apiKey: process.env.ZG_AGENT_3! },
],
threshold: 3,
curve: Curve.Secp256k1,
// Opt-in auto-refill — see the refill section below for trade-offs
// around manual vs auto-refill in cold contexts.
presignaturePool: { autoRefillWhen: 20, autoRefillTo: 100 },
});
const loaded = await signer.create({
signerId: 'cold-vault-eth-mainnet',
recovery: { kind: RecoveryKind.Noop }, // multi-agent cohort: the cohort IS the recovery
passphrase: hostSealingPassphrase, // seals loaded.exportedBlob in one round-trip
});
// `create()` already produced the sealed export blob — persist it
// directly without a separate `loaded.export()` call. The passphrase
// should be derived from an HSM operation, not memorised.
await hostSecureStorage.put(`cold-state:${loaded.signerId}`, loaded.exportedBlob!);
console.log(loaded.publicKey.toString('hex')); // root public key — derive treasury address
console.log(loaded.license?.licensee); // licensee identity bound at create timeThree constraints worth knowing:
- Multi-agent topology requires
recovery: { kind: 'Noop' }. The cohort agents themselves serve as the recovery path; sealing an additional bundle on top is rejected at construction. For a single-agent cohort (host + 1 agent),Password,Custodial, orSocialrecovery clauses become available. - The export passphrase is the host's sole secret. The cold key share is recoverable from
{ export blob + passphrase }. Source the passphrase from a hardware-backed operation (HSM derivation, dedicated KMS) — not a memorised string and not a value persisted alongside the blob. - The cold host is party 0 in the cohort.
agent[i]is partyi+1. This ordering is baked into the share material and into every later call; do not reorder the cohort between create + load.
Lost-host recovery uses cluster-side multi-party cold recovery — a quorum of agents export per-agent recovery bundles which are combined off-cluster into the root key. See Embedded Agent → Recovery.
The sign flow
loaded.sign({ messageHash }) is the entire signing API. When the pool has at least one cached presignature, the call runs as a single short round-trip against the cohort: the host pops a presignature locally, exchanges a partial-sign round with the cohort, combines the partials, and returns the final signature.
import { Utils } from '@zafeguard/mpc-sdk';
// A signing request arrives. Open cohort connectivity, sign, close.
const messageHash = Utils.sha256(new TextEncoder().encode(serializedTx));
const sigPartials = await loaded.sign({ messageHash });
const sig = await loaded.finalizeSignature({
messageHash: sigPartials.messageHash,
partials: sigPartials.partials,
derivationPath: sigPartials.derivationPath,
});
// sig is a final, combined ECDSA signature
console.log(sig.r.toString('hex')); // 32-byte r
console.log(sig.s.toString('hex')); // 32-byte s
console.log(sig.v); // recovery byte
console.log(sig.compactRecovery.toString('hex')); // 65 bytes — r || s || v
console.log(sig.der.toString('hex')); // DER-encoded for Bitcoin / generic ECDSAWhat one call does behind the scenes:
- Pool consume. The SDK pops one presignature from the in-memory pool (no network).
- Cohort round-trip. The SDK exchanges partial-sign messages with the agents.
- Local combine. The SDK combines the host's partial with the agents' partials locally on the host — returns the final signature.
The signature is NOT a partial — it is the fully-combined (r, s, v). The SDK combines partial signatures locally in your process; the cluster never assembles signatures.
If the pool is empty when sign() runs, the SDK falls back to an inline presigning round-trip followed by the signing round-trip — two cohort round-trips instead of one. Your operation still completes, but the cohort exposure is longer. Sizing the pool above your peak burst rate avoids this.
Presignature pool — the controlled connectivity event
The pool is the cold tier's central operational lever. Each presignature is one-time — minted online with the cohort, consumed locally by a single subsequent signature. Pool depth is the budget of single-round-trip signatures the host can perform before needing another refill window.
Manual refill (default for cold)
For strict connectivity discipline, omit presignaturePool from the constructor and run refills by hand inside a witnessed window:
// Inside a planned refill window — officers present, cohort connectivity open
const result = await loaded.mintPresignatures({ count: 200 });
console.log(`minted ${result.minted}, pool depth now ${result.available}`);
// Persist the post-refill pool snapshot so a host restart doesn't lose it.
const refilledBlob = await loaded.export(hostSealingPassphrase);
await hostSecureStorage.put(`cold-state:${loaded.signerId}`, refilledBlob);
// Close cohort connectivity after the refill completes.mintPresignatures runs count presigning ceremonies in parallel against the cohort and appends the results to the in-memory pool. Partial failure throws and leaves the pool unchanged. The pool snapshot is captured by loaded.export(...) so it survives host restarts.
Manual refill is the right default for cold: every refill is a deliberate, audited event with a clear connectivity window.
Optional: auto-refill
For warm-leaning cold operations where signing volume is more predictable, opt in to auto-refill at construction:
const signer = new EmbeddedAgent({
agents: [/* ... */],
threshold: 3,
curve: Curve.Secp256k1,
presignaturePool: { autoRefillWhen: 20, autoRefillTo: 100 },
});Behaviour:
- After a
sign()call drops the pool toautoRefillWhenor below, the SDK kicks a background mint to bring the pool back toautoRefillTo. - The mint runs out-of-band — the in-flight signature completes against its already-consumed entry; the refilled entries become available for subsequent signs once the background task finishes.
- Both fields are required together;
autoRefillTomust be strictly greater thanautoRefillWhen.
For cold contexts, auto-refill means the host's cohort connection opens whenever the pool dips. That trades the discipline of "connectivity only during witnessed windows" for "connectivity whenever signing volume needs it". Pick the manual or auto path based on which discipline matters more for your custody posture.
Pool depth sizing
Cold-tier pool depth should cover your planned signing volume between refill windows with comfortable headroom. A weekly refill cadence at 50 signs/day suggests a pool of at least 500 (1.4× peak weekly volume). Inspect current depth before declaring a refill complete:
console.log(loaded.presignaturePoolStats.available); // current depthAir-gapped sign — partial-ferry path
The loaded.sign({ messageHash }) flow above models controlled-connectivity cold: the host opens a brief cohort round-trip per signature. For reserves where that round-trip is unacceptable — permanent air-gap, signing room with no network at all — the same pool entry can be consumed through the offline partial-sign path.
The cold host calls loaded.presignature.sign({ presignatureId, messageHash }), which consumes one pool entry locally and returns this host's partial signature only — a single base64 PSIG envelope. No cohort contact at sign time. The partial is ferried (QR, USB, encrypted SD card) to an online coordinator, which runs ClusterAgent.presignature.sign against each cohort agent using the same presignatureId to collect their partials, then combines the threshold-many partials into the final signature via Utils.finalizeSignature.
This path is the right fit for the highest-tier cold vault where:
- The cold host is locked in a room with no network egress at sign time.
- The signing ceremony is rare and witnessed — the partial ferry is part of the same audited event.
- The pool was filled during a prior witnessed warming window; sign time consumes one entry but opens no network.
Two operational rules:
- Both sides MUST pick the same
presignatureId. A pool entry is one-time material — mismatched ids on the two sides produce partials that do not combine. - The cold host burns the entry locally even if the coordinator's collect-and-combine step later fails. Plan for the possibility that one signature attempt consumes a pool entry without producing a finalised signature; the cold host's pool depth drops by one, the coordinator retries against the next entry.
import { ClusterAgent, Curve, Utils } from '@zafeguard/mpc-sdk';
// ── Cold host (air-gapped signing room) ────────────────────────────
// Restore from the sealed blob — no cohort connectivity needed.
const coldHandle = await signer.load({
signerId: 'cold-vault-eth-mainnet',
blob: await hostSecureStorage.get('cold-state:cold-vault-eth-mainnet'),
passphrase: hostSealingPassphrase,
});
// Pick a pool entry and produce this host's partial — purely local.
const entries = coldHandle.presignature.list();
const chosen = entries[0].presignatureId;
const messageHash = Utils.sha256(new TextEncoder().encode(serializedTx));
const coldPartial = await coldHandle.presignature.sign({
presignatureId: chosen,
messageHash,
});
// coldPartial.partialSignatureB64 — this host's PSIG envelope
// coldPartial.presigSessionId — share with the coordinator
// coldPartial.keyShareId — share with the coordinator
// coldPartial.publicKeyHex — wallet root pubkey
// Ferry { presigSessionId, keyShareId, partialSignatureB64,
// publicKeyHex, messageHash } back to the coordinator.
// ── Online coordinator (combine + broadcast) ───────────────────────
// Run the same presignatureId against each cohort agent. Each call
// returns one cohort partial.
const cluster = ClusterAgent.connect('agent-1.zafeguard.com', 443, apiKey, true);
const agentPartial = await cluster.presignature.sign(coldPartial.presigSessionId, {
keyShareId: coldPartial.keyShareId,
messageHashHex: messageHash.toString('hex'),
});
// Combine the threshold-many partials locally — no extra cohort
// round-trip needed.
const sig = Utils.finalizeSignature({
messageHash,
partialSignatures: [coldPartial.partialSignatureB64, agentPartial.partialSignatureB64],
publicKey: Buffer.from(coldPartial.publicKeyHex, 'hex'),
curve: Curve.Secp256k1,
});
// Broadcast `sig.signature` through the chain-specific component.Partial-ferry vs the controlled-connectivity sign
loaded.sign({ messageHash }) | loaded.presignature.sign({ presignatureId, messageHash }) | |
|---|---|---|
| Cold host network at sign time | Brief cohort round-trip per signature | None — cold host is air-gapped |
| Returns | Threshold-many partials, combined locally into a final signature | One partial signature only (this host's) |
| Coordinator role | None — the cold host gathers + combines | Collects cohort partials via ClusterAgent.presignature.sign, combines via Utils.finalizeSignature |
| Pool entry consumed | Yes (one per sign) | Yes (one per sign, locally on the cold host) |
| Best fit | Controlled-connectivity cold — brief witnessed signing windows | Permanently air-gapped cold — no network at sign time, partial ferried back |
The two paths are complementary, not mutually exclusive. A vault can hold the share for both: routine drawdowns use the controlled-connectivity sign; rare large transfers use the partial-ferry path under a more restrictive multi-party approval gate.
→ See Embedded Agent → Air-gapped sign for the canonical SDK reference.
Persisting state
EmbeddedAgent.export(passphrase) serialises the live signer state (share material + recovery bundle + presignature pool snapshot) into a passphrase-sealed encrypted blob. The blob is the only persistent artefact your application stores — no separate secret files, no plaintext share material on disk.
Recommended cold-host storage shape:
- Sealed blob → HSM-backed secret store. Sealed-at-rest with an HSM-derived key. Never written to plaintext disk.
- Passphrase → HSM operation. Derived live from an HSM operation at the moment
export/loadruns. Never persisted next to the blob. - Recovery bundle (if non-Noop) → separate cold storage. Goes to a different secure location, accessed only during a recovery event.
// Persist
const blob = await loaded.export(passphrase);
await hostSecureStorage.put(`cold-state:${loaded.signerId}`, blob);
// Restore on the same host (or a standby host)
const blob2 = await hostSecureStorage.get(`cold-state:${loaded.signerId}`);
const restored = await signer.load({
signerId: 'cold-vault-eth-mainnet',
blob: blob2,
passphrase: hostSealingPassphrase,
});
console.log(restored.presignaturePoolStats.available); // pool depth restored from the blobThe pool depth at the moment of export is what load will see. If the host signs N times between exports, the next restore will reflect that. Snapshot policy: export after every refill, optionally after every sign for tightest durability, depending on your recovery-point objective.
Wiring the cold sign into a workflow
Cold-tier signing is wrapped in an approval workflow that runs policy checks and multi-party approval gates before reaching the signing component. The signing component calls a thin service in your custody backend that exposes a one-call endpoint over loaded.sign(...).
Cold-tier withdrawal request
|
v
Multi-party approval gate (M-of-N officer approvals + time-lock)
|
v
Policy check (velocity, denylist, destination, amount)
|
v
Sign on cold host (HTTP call to your cold signing service;
service runs loaded.sign({ messageHash }))
|
v
Broadcast (chain-specific broadcast component)
|
v
Audit emission + receipt
The cold host's secret material never leaves the host. The signing service receives a message hash + a workflow context, signs, and returns the bytes.
import { WorkspaceClient } from '@zafeguard/caller-sdk';
const workspace = new WorkspaceClient({ apiKey: process.env.ZAFEGUARD_API_KEY! });
await fetch(
`https://api.zafeguard.com/v1/sdk/workflows/${COLD_PAYOUT_WORKFLOW_ID}`,
{
method: 'POST',
headers: { 'X-Api-Key': process.env.ZAFEGUARD_API_KEY! },
body: JSON.stringify({
signerId: 'cold-vault-eth-mainnet',
to: recipientAddress,
amount,
chain: 'evm',
requestingOperatorId: req.user.id,
idempotencyKey: req.id,
}),
},
);The approval gate runs first; the signing component runs only after the gate clears. If the approval window expires or any approver rejects, the workflow terminates and the cold host is never contacted.
End-to-end audit trail
For one cold-tier signature, the execution log spans four layers:
- Approval workflow log. Every officer's approval, the time-lock window, the request payload, the requesting operator's identity.
- Policy execution log. Each policy component's evaluation, with input, decision, and reason.
- Cold host log. Your application captures
signerId,messageHash, derivation path (if any), pool depth before/after, and operating-officer identity at the momentloaded.sign(...)ran. - Broadcast + receipt log. Chain confirmation and on-chain transaction hash, independently verifiable.
The chain of evidence binds together: the workflow approval → the policy decisions → the cold host's signing record → the on-chain confirmation. An auditor reconstructing any historical cold-tier signature has a documented trail with no single party able to forge it alone.
Failure modes
Pool depleted at sign time. sign() falls back to inline presigning, opening a longer cohort connection than a pool-backed sign. The operation still completes if the cohort is reachable. Pool depth alarms in your custody dashboard should fire when depth drops to a configured floor, prompting an early refill.
Cohort unreachable at sign time. sign() throws with the underlying network error. Cold signing operations should be retried after operator intervention rather than blindly — surface the failure on the operations dashboard with the workflow run ID so officers can decide whether to abort the request or retry later.
Cold host down. The host's share is required to reach threshold. Mitigations: a standby host that loads the same export blob (same signerId, same passphrase) and takes over after an atomic failover; the cold host's storage is mirrored to standby so the standby comes up with the same pool depth and share material.
Cold host compromise. If the host is compromised, the attacker has one share + the cached pool. With (host + 3 agents, threshold 3), the attacker would also need to compromise two of three agents to sign — well below the threshold alone. Immediate mitigation: loaded.reshare({ passphrase }) against the cohort to rotate share material (preserves the root public key); rotate the host's storage passphrase; rebuild the host from a clean image.
Storage failure. The export blob is the host's only persistent state. Mitigations: HSM-backed storage with redundancy; periodic test-restores from the standby; recovery via cluster-side multi-party cold recovery if the primary + standby are both lost.
Next
- Warm Wallet — middle tier with restricted egress and one-shot signing.
- Hot Wallet — 24/7 cluster-managed tier.
- Embedded Agent reference — every constructor option, every method, every error.
- Embedded Agent → Presignature pool — the pool API in depth.
- Embedded Agent → Air-gapped sign — the partial-ferry path for the highest-tier cold vault.
- Regulated Design — how the cold-tier properties map to compliance controls.
Warm Wallet
Device-side EmbeddedAgent with restricted egress and one-shot signing. The warm host generates its own share during DKG, holds no presignature pool, and opens a single cohort connection per signing request — ideal for steady-state operating volume with conservative connectivity discipline.
Regulated Design
How a Zafeguard custody whitelabel maps to the controls regulators expect — threshold MPC, documented approval flows, segregation of duties, audit emission across SDK + workflow execution + cluster audit logs, key sovereignty via self-hosted clusters, and right-to-revoke.