Custody Whitelabel Solution

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.

Warm Wallet

The warm wallet is the always-available middle tier. It holds one threshold share on a server-side host inside your custody backend; the rest of the cohort lives on the MPC agents. Like the cold wallet, the warm host generates its own share during DKG — share material never moves across a network. Unlike the cold wallet, the warm host does not pre-mint presignatures: every sign runs one full ceremony against the cohort.

The warm tier's defining property is restricted connectivity. The host is locked down by an egress allowlist that only permits outbound traffic to the cohort agent endpoints — no general internet, no DNS leaks, no incidental third-party calls from the signing host. The connection to the cohort opens for the duration of a signing ceremony and closes again.

Warm sits between two more extreme tiers:

  • Hot wallet uses ClusterAgent cluster-side only — no end-user host, no embedded share. Every signing operation drives a cluster ceremony. Suited for ultra-high-throughput operations where signing latency matters and the operator is happy delegating share-holding entirely to the cluster.
  • Cold wallet uses EmbeddedAgent with a locally-cached presignature pool — same DKG-on-host pattern as warm, but each sign needs only one short cohort round-trip thanks to the pre-minted pool. Suited for reserves where signing volume is predictable and connectivity windows can be scheduled.

Warm fits in the middle: the same on-host share-generation as cold, but with no persistent pool to manage. Every sign opens connectivity, runs presigning + signing inline, and closes. Operationally simpler than cold (no refill schedule, no pool depth alarm) and more restrictive than hot (you control one of the threshold parties).

This page covers the warm pattern, the recommended host configuration, the sign flow, and the operational shape that makes it a standard institutional tier.

→ Canonical SDK reference: EmbeddedAgent.


Three properties make this design the standard:

  1. The share is generated on the host. Same DKG pattern as cold — EmbeddedAgent.create(...) runs on the warm host and the host's share materialises locally during the ceremony. The cluster never has the host's share to seal, ship, or store; nothing carrying the host's share ever crosses a network.

  2. No presignature pool to maintain. A pool means recurring connectivity windows for refill, background mint tasks, depth alarms, and snapshot policies. Warm operations don't need that overhead. Every signing operation is self-contained: open connectivity → run one ceremony → close.

  3. Egress allowlist enforces "internet only when signing". The host's outbound firewall permits only the cohort agent endpoints. A compromised process on the warm host cannot exfiltrate to anywhere except the agents — which themselves enforce API authentication, audit, and per-key access control. The reachable surface from a breach is bounded by the cohort's own controls.

The trade-off versus cold is sign latency: warm pays for the full presigning ceremony every time (two cohort round-trips), whereas cold's pool-backed sign is a single round-trip. For most institutional working-volume operations the extra round-trip is invisible against the workflow's own policy gates and chain broadcast latency.


Topology

+------------------------------------+
|  Warm host (your custody backend)  |
|                                    |
|  EmbeddedAgent.create / load      |
|  -> LoadedKeyShare holds 1 share     |
|  -> NO presignature pool           |
|                                    |
|  loaded.sign({ messageHash })      |
|     -> opens cohort connection     |
|     -> runs presigning + signing   |
|     -> combines locally            |
|     -> returns final signature     |
|     -> closes cohort connection    |
+----------------+-------------------+
                 |
                 |  HTTPS — egress allowlist:
                 |  cohort agent endpoints only
                 v
+------------------------------------+
|  Agent cohort                      |
|                                    |
|  Each agent holds one share        |
|  of the warm key. Multi-agent      |
|  cohort tolerates single-agent     |
|  outage.                           |
+------------------------------------+

Recommended cohort shapes:

ShapeSurvivesWhen
(host + 2 agents, threshold 2)Host + any one of two agentsDefault for operating volume — survives one agent outage
(host + 3 agents, threshold 3)Host + any two of three agentsHigher resilience — survives one regional cluster outage

Both shapes use recovery: { kind: 'Noop' } (multi-agent topology requires it). Lost-host recovery uses cluster-side multi-party cold recovery via the agent cohort.


The warm host configuration is what makes the tier "warm" in practice. Five concrete recommendations:

1. Egress allowlist

Constrain the host's outbound traffic at the OS / network layer to only the cohort agent endpoints. Any process on the host attempting to reach an unallowed destination is dropped at the firewall.

A representative allowlist on a Linux host:

# /etc/iptables/warm-host-egress.conf
# Allow outbound only to cohort agent endpoints + system essentials.
-A OUTPUT -d agent-1.zafeguard.com -p tcp --dport 443 -j ACCEPT
-A OUTPUT -d agent-2.zafeguard.com -p tcp --dport 443 -j ACCEPT
-A OUTPUT -d agent-3.zafeguard.com -p tcp --dport 443 -j ACCEPT
-A OUTPUT -p udp --dport 53 -d ${TRUSTED_RESOLVER_IP} -j ACCEPT
-A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
-A OUTPUT -j DROP

The detail of "only the cohort, only port 443" is the property auditors look for. A compromised process on the warm host has nowhere to dial out except the agents — and the agents enforce authentication, per-key access, and audit on every call.

2. HSM-backed share storage

The export blob produced by loaded.export(passphrase) is the only persistent artefact, and the passphrase is its sole secret. Both should live in HSM-backed storage:

  • Sealed blob → HSM-backed secret store. Sealed-at-rest with an HSM-derived key.
  • Passphrase → HSM operation. Derived live from a hardware operation at the moment of export / load. Never persisted next to the blob.

If your host platform exposes a Secure Enclave (Apple silicon servers) or a dedicated HSM (Cavium, Thales, AWS CloudHSM, etc.), use the platform's adapter; otherwise wrap a custom adapter around your KMS.

3. Single-active enforcement

Run a standby host for high availability: same signerId, same passphrase, same export blob mirrored from the primary's secure storage. The standby loads the blob with signer.load(...) and is ready to take over within seconds of the primary's failure.

Critical constraint: only one host signs at a time. Both hosts holding the same share cannot run ceremonies concurrently against the same nonce — the cohort would observe inconsistent partials. Enforce single-active via a leader-election lock in your operations layer (a row in your custody database, a leader-election service, etc.). The failover sequence: detect primary down → leader-election promotes standby → standby starts accepting signing requests; the demoted primary refuses to sign until it's re-promoted.

4. Per-sign audit emission

Every loaded.sign(...) call should produce an audit event in your custody backend at three moments:

  • Pre-sign: the requesting workflow run ID, the operator identity, the destination, the amount, the message hash.
  • Post-sign success: the resulting signature's hash, the duration, the cohort latency observed.
  • Post-sign failure: the underlying error, the connection-state at failure.

The audit feed should go to your SIEM. Combined with the cohort's own audit logs (agent.sessions.auditLogs(...) against each agent), this gives you a complete sign-event reconstruction.

5. Multi-agent cohort, Noop recovery

For institutional warm wallets, use a multi-agent cohort. With agent.length >= 2, the SDK requires recovery: { kind: 'Noop' } — the cohort agents themselves serve as the recovery path. 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.

A single-agent (host + 1 agent) warm cohort permits richer recovery clauses (Password, Custodial, Social) but trades agent-outage resilience for that flexibility. Not recommended for institutional warm wallets unless your custody model specifically benefits from a single-agent shape.


Provisioning a warm wallet

The DKG ceremony runs on the warm host with the agent cohort online. This is a one-time per-key event; after DKG completes, the host's continuous cohort connectivity can close.

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

// On the warm 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,
  // NO presignaturePool field — every sign runs full presigning + signing.
});

const loaded = await signer.create({
  signerId: 'warm-treasury-eth-payouts',
  recovery: { kind: RecoveryKind.Noop },   // multi-agent cohort requirement
  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. Mirror to the
// standby host's storage for HA failover.
const exportBlob = loaded.exportedBlob!;
await hostSecureStorage.put(`warm-state:${loaded.signerId}`, exportBlob);
await standbyHostStorage.put(`warm-state:${loaded.signerId}`, exportBlob);

console.log(loaded.publicKey.toString('hex'));    // root public key — derive treasury address
console.log(loaded.license?.licensee);            // licensee identity bound at create time

The DKG ceremony is the only operation that opens a long cohort connection. After DKG, the host's normal operating mode is "idle, with cohort connectivity opened on demand for each signing request".

The DKG ceremony shape is identical to the cold wallet: same EmbeddedAgent.create(...) API, same multi-agent constraints, same Noop recovery requirement. The only difference is the constructor configuration — warm omits presignaturePool.


The sign flow

loaded.sign({ messageHash }) runs a complete one-shot signing ceremony per call: open connectivity → presign + sign with the cohort → combine locally → close connectivity.

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

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.compactRecovery.toString('hex'));  // 65 bytes — r || s || v
console.log(sig.der.toString('hex'));              // DER-encoded for Bitcoin / generic ECDSA

What one call does behind the scenes:

  1. Open cohort connection (TLS handshake to the agents).
  2. Presigning round. Host + cohort exchange messages to produce a one-time joint nonce share.
  3. Signing round. Host + cohort exchange partial signatures against the message hash.
  4. Local combine. The SDK combines partials locally on the host — returns the final signature.
  5. Close cohort connection.

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.

The two cohort round-trips (presigning + signing) are the cost of skipping the pool. For most operating-volume signing, the round-trip latency is dominated by the chain-broadcast step that follows; the user-visible signing latency rarely surfaces.


Operating warm in cold mode (offline partial signing)

The warm wallet's EmbeddedAgent handle supports four operating modes against the same on-host share. The first three are always available; the fourth runs without any cohort connectivity at sign time and turns warm into cold on demand.

ModeCohort connectivity at sign timeLatencyWhen to use
One-shot signYes, two round-trips (presign + sign)DefaultDefault warm operation. No pool maintenance.
Pre-mint presignaturesYes, one round-trip per mintN/A (mint event)Schedule bulk mints to amortise the heavier round
Auto-refill the poolYes, background mint when pool dips below triggerN/A (background)Set-and-forget pool buffering during operating hours
Offline partial signingNoLocal-onlyCold operation: host disconnected, partial ferries out, coordinator combines

The fourth mode is loaded.presignature.sign({ presignatureId, messageHash }) — added to EmbeddedAgent specifically so a warm host can flip into cold operation without changing identity, share material, or cohort. The caller picks which pool entry to consume via loaded.presignature.list() so the same presignatureId matches the cluster's record when the coordinator later collects the cohort's partials.

The flow

import { Utils } from '@zafeguard/mpc-sdk';
import { EmbeddedAgent, Curve, RecoveryKind, ClusterAgent, Scheme } from '@zafeguard/mpc-sdk';

// ─── Phase 1 — Online: pre-mint the offline budget ────────────────
// Host is online with cohort connectivity. Mint as many presignatures
// as the offline signing window will need; each entry covers one
// offline sign call.
await loaded.presignature.mint({ count: 200 });

// Persist the post-mint state. The pool snapshot rides inside the
// export blob so a host restart preserves the offline budget.
const refilledBlob = await loaded.export(hostSealingPassphrase);
await hostSecureStorage.put(`warm-state:${loaded.signerId}`, refilledBlob);

// ─── Phase 2 — Offline: host signs without cohort contact ─────────
// Network can drop here. The host produces only its own partial
// signature against a SPECIFIC cached presignature, locally. No agent
// round-trip. Caller picks which entry to consume via list().
const messageHash = Utils.sha256(new TextEncoder().encode(serializedTx));

const pool = loaded.presignature.list();
if (pool.length === 0) throw new Error('pool empty — refill while online');
const presignatureId = pool[0].presignatureId;

const offline = await loaded.presignature.sign({ messageHash, presignatureId });
// → {
//     partialSignatureB64,     // PSIG envelope — the device's partial
//     presigSessionId,         // bind the cohort's partial to the same presig
//     keyShareId,              // cluster-side key share id for the agent call
//     publicKeyHex,            // root pubkey — verify the combined sig
//     curve,
//   }

// Ferry `offline` to the online coordinator via your chosen transport
// (signed message bus, queue, HTTPS to a separate online service, etc).

// ─── Phase 3 — Coordinator (online): collect cohort partials + combine
// The coordinator has cohort connectivity. For each agent, it asks for
// that agent's partial against the same presigSessionId + message hash.
const cluster = ClusterAgent.connect('agent-1.zafeguard.com', 443, agentApiKey, true);
const agentPartial = await cluster.presignature.sign(offline.presigSessionId, {
  keyShareId: offline.keyShareId,
  messageHashHex: messageHash.toString('hex'),
});

// Combine the device's offline partial with the cohort's online partials.
const scheme = new Scheme(Curve.Secp256k1);
const finalised = scheme.finalizeSignature(
  messageHash,
  [offline.partialSignatureB64, agentPartial.partialSignatureB64],
  Buffer.from(offline.publicKeyHex, 'hex'),
);

// finalised.signature is a standard ECDSA signature, ready to broadcast.

What happens cryptographically

A presignature is a one-time joint nonce share, minted by the device + cohort together. The device caches its half (and the cluster-side presigSessionId); each cohort agent caches its half. At sign time:

  • The device computes its partial signature against (its share, its presig half, message hash) — purely local. No cohort interaction needed because the cohort already committed to its halves during the mint.
  • The coordinator asks each cohort agent for its partial against the same presigSessionId + message hash. Each agent computes its partial against (its share, its presig half, message hash) and returns it.
  • The coordinator combines threshold-many partials via Scheme.finalizeSignature into the final ECDSA signature.

No party — neither the device nor any agent — ever holds the full nonce or the full private key. The combined signature exists only on the coordinator, only after collecting threshold-many partials.

Why this turns warm into cold

The defining property of the cold tier is that the signing host can be disconnected from the cohort when the actual sign happens. With signOffline:

  • The pre-mint event is the only time the host needs cohort connectivity. Schedule it: once a week, once a month, after every batch — whatever your operating cadence allows.
  • Between pre-mint events, the host can sit completely offline. An attacker who compromises a disconnected host has one share + pool entries; without the cohort's partials they cannot produce a valid signature.
  • The same host can run in either mode per-call: loaded.sign(...) for online one-shot, loaded.presignature.sign(...) for the disconnected ferry path. No re-provisioning, no second share, no different signer identity.

When to mix the modes

A common operating pattern:

  • Business hours — host has cohort connectivity. Use loaded.sign(...) for live operations. Auto-refill keeps the pool warm in the background.
  • After hours — host disconnects. A scheduled job uses loaded.presignature.sign(...) against the cached pool for any pending batch jobs.
  • Disaster recovery — cohort regional outage. The host still signs from the cached pool until connectivity returns; ferry the partials to a healthy region's coordinator.
  • Air-gapped vault posture — host stays offline indefinitely. Officers convene at scheduled windows to refill the pool. Between windows, the host operates purely on cached material.

The mode mix is decided per workflow on the canvas — your warm signing service can route by request type and surface the appropriate API on the host.

Pool sizing

Each pool entry covers exactly one offline sign. Size the pool to your worst-case offline window:

  • Daily refill, 100 signs/day at peak → mint 200 (2× headroom).
  • Weekly refill, 50 signs/day at peak → mint 500 (1.4× weekly volume).
  • Air-gapped quarterly refill, 10 signs/month → mint 50 (1.6× quarterly volume).

Inspect current depth between operations:

const stats = loaded.presignature.stats;
console.log(`offline budget remaining: ${stats.available}`);

If signOffline runs against an empty pool it throws with a clear error — your application should surface this to operators rather than retry blindly. Without cohort connectivity there is no way to refill the pool inline, so the operator must either restore connectivity (and refill) or defer the operation.


Wiring the warm sign into a workflow

Warm-tier signing sits inside a policy workflow on Zafeguard. The workflow runs policy components before reaching the signing component — if any policy rejects, the workflow ends and the warm host is never contacted.

Warm payout request
        |
        v
   Policy check                 (velocity, denylist, geo, operator MFA)
        |
        v
   Above-threshold check        (amount > $X => escalate to cold workflow)
        |
        v
   Sign on warm host            (HTTP call to your warm signing service;
                                  service runs loaded.sign({ messageHash }))
        |
        v
   Broadcast                    (chain-specific broadcast component)
        |
        v
   Audit emission + receipt

The signing component is an HTTP call from the workflow to a service in your custody backend that exposes a thin endpoint over loaded.sign(...). The warm host's secret material never leaves the host; the 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/${WARM_PAYOUT_WORKFLOW_ID}`,
  {
    method: 'POST',
    headers: { 'X-Api-Key': process.env.ZAFEGUARD_API_KEY! },
    body: JSON.stringify({
      signerId: 'warm-treasury-eth-payouts',
      to: recipientAddress,
      amount,
      chain: 'evm',
      operatorId: req.user.id,
      idempotencyKey: req.id,
    }),
  },
);

Above-threshold operations flip from the warm workflow to the cold workflow with its own approval gate — see Cold Wallet.


When to use warm

Operational shapeRight tier
24/7 server-side signing, on-demand connectivity, no pool maintenanceWarm
Steady-state high-volume signing where pool refill is a planned eventCold
Cluster-only signing where no host wants to hold a shareHot
Consumer-facing app where each end-user holds one shareEmbedded Wallet Solution

A typical custody whitelabel runs all three tiers concurrently:

  • Hot for very-high-throughput cluster-managed services (gas-sponsorship relayer keys, automated trading flows).
  • Warm for the standard operating-volume payouts — customer withdrawals, scheduled rebalances.
  • Cold for reserves above a configured threshold — large transfers, treasury reallocations, infrequent withdrawals.

Each tier holds a different key share with a different connectivity discipline. The workflow canvas routes between them based on amount and policy.


Failure modes

Warm host down. The host's share is required to reach threshold. Mitigations: a standby host that loads the same export blob and takes over via leader election. Single-active enforcement prevents concurrent signing from both hosts.

Cohort agent down. With (host + 3 agents, threshold 3) the cohort can tolerate one agent down (the host plus any two agents reaches threshold). With tighter shapes the cohort blocks until the agent comes back. Monitor every agent's reachability and alert when fewer than threshold agents respond.

Egress allowlist drift. A misconfigured firewall change adding a new outbound destination weakens the restricted-egress property. The allowlist should be configuration-managed and reviewed on every change; alert on any rule that adds a new outbound destination.

Share compromise. If the warm host is compromised, the attacker has one share — not enough to sign alone. Immediate mitigations: 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. With (host + 3 agents, threshold 3), the attacker would also need to compromise three of three agents to sign — well below threshold alone.

Idempotency violation. Without an idempotency key on the triggering workflow, an operator retrying a stuck request produces a duplicate signature on a fresh nonce. The triggering layer is the one place to enforce idempotency; the warm host signs whatever the workflow hands it.


Next

On this page