Custody Whitelabel Solution

Hot Wallet

The 24/7 server-side hot-wallet tier — ClusterAgent cluster topology, threshold choices, the ceremony-driven signing flow, policy enforcement in workflows, and the audit emission pattern.

Hot Wallet

The hot wallet handles working capital. It pays gas, settles customer withdrawals, executes scheduled rebalances, and serves automated trading flows. It must be available 24/7, sign programmatically (no human at every keypress), and stay inside hard policy limits without anyone reviewing each operation.

The cryptographic primitive is threshold MPC — same as everything else on the platform. What makes the hot wallet a hot wallet is the operating shape: a server-side cluster with no end-user device participation, signing operations driven by policy-enforcing workflows rather than human prompts, and aggressive audit emission so every signature is reconciled.

This page covers the cluster topology choices, how ClusterAgent drives the cluster from your custody backend, the policy architecture that wraps signing, and the audit pattern.

→ For the canonical reference on every option, see @zafeguard/mpc-sdk → MPC Agent for the cluster-operator side and ClusterAgent client for the backend-integrator side.


Cluster topology

A hot-wallet cluster is an m-of-n threshold-MPC cluster. Each cluster node holds exactly one share of every provisioned hot-wallet key, never shares it, and runs ceremony state independently.

The cluster as a whole is one logical service from your backend's perspective — you ClusterAgent.connect(...) against one node, and the cluster's intra-cluster transport fans the ceremony out to the others. Every node is symmetric; there is no designated leader.

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

// host / port / apiKey / tls — positional
const agent = ClusterAgent.connect(
  'hot-node-1.your-custody.com',
  443,
  process.env.ZG_HOT_AGENT_KEY!,
  true,  // tls
);

// Health checks for your operations dashboard
const info   = await agent.node.info();
const health = await agent.node.health();
const ready  = await agent.node.ready();
console.log(info.nodeId, info.nodeIndex);
console.log(health.status, ready.db, ready.rabbitmq);

The client points at one entry node at a time. Operationally you'd front the cluster with a load balancer or have your backend round-robin across agent.connect(...) for each call so a single node going offline doesn't take signing offline.

Choosing the threshold

The threshold is committed at DKG time per key and reshape requires a reshare ceremony (same-cohort only). Three patterns dominate institutional hot-wallet design:

ThresholdPatternWhen to use
2-of-3Any single party can fail without interrupting signing. Default for most hot wallets.Default. Cost-of-signing-vs-cost-of-failure trade-off for most working capital tiers.
3-of-4Any two parties can fail. Higher cost-of-collusion.When operational complexity is acceptable in exchange for tolerating two simultaneous party failures.
3-of-5Hot-flavoured cold pattern.When the hot tier holds significant balance and the operating model can absorb the latency overhead.

Geographic and operational independence

The standard production layout places nodes in three independent failure domains:

  • Network independence. A correlated outage across all nodes freezes signing. Different cloud regions / providers, or one self-hosted node, give you genuinely independent failure modes.
  • Jurisdiction independence. Some compliance regimes require signing material to be unreachable from a single jurisdiction. Geographic separation addresses this.
  • Operational independence. Nodes operated by different teams reduce the chance that one operator's mistake takes the cluster offline.

→ See MPC Agent → Deployment for the cluster-operator perspective.


Provisioning a hot-wallet key

A hot-wallet key is provisioned once per asset (or per tenant, per custodian client — whatever your segregation model dictates) via a DKG ceremony on the cluster:

// One-time per key — driven by your backend during onboarding
const dkg = await agent.sessions.createKeyShare({
  curve: 'secp256k1',
  threshold: 2,
  // peers — list of cluster nodes participating (typically all cluster nodes)
  // ... see CreateKeyShareSessionRequest in the SDK reference
});

// Wait for the ceremony to converge
const result = await dkg.promise();
console.log(result.publicKeyHex);   // root public key — derive your treasury address
console.log(result.sessionId);

The DKG returns the sessionId (used to bind subsequent presigning and signing ceremonies to this key share) and the publicKeyHex (hex compressed public key). The cluster's per-node storage now holds the key share — visible to you via agent.keyShares.list() and agent.keyShares.get(id).

Multi-key topology

Most institutional designs do not use one hot-wallet key for everything. Common segregations:

  • One key per asset — separate USDC-treasury, ETH-treasury, BTC-treasury keys. A USDC compromise does not implicate ETH.
  • One key per tenant — custody-as-a-service operators provision one hot-wallet key per end client; metadata-driven audit segregates operations cleanly.
  • One key per workflow — narrow scope; the fee-paying gas wallet, the customer-withdrawal wallet, and the scheduled-rebalancer wallet are distinct keys with distinct policies.

The cluster hosts arbitrarily many provisioned keys. The narrower the key scope, the easier the policy and the audit story.


The cluster signing flow

Hot-wallet signing is one-shot: a single createSignMessage ceremony runs presigning and signing back-to-back inside the same ceremony, the cluster discards the ephemeral presignature on completion, and your backend combines the partials locally via Scheme.finalizeSignature. Nothing is persisted on any node between calls. The cluster always produces partial signatures only; assembly happens off-line in your process, never on the cluster.

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

// One-shot sign — presigning + signing run inside a single ceremony.
// `curve`, `threshold`, and `maxShares` are read off the stored key share
// record; the SDK request only needs `keyShareId`, `peers`, and the
// message hash.
const signSession = await agent.sessions.createSignMessage({
  keyShareId,
  peers,
  messageHashHex: Buffer.from(messageHash).toString('hex'),
});
const signResult = await signSession.promise();

// signResult.partialSignatures has one entry per participating node.
const partials = signResult.partialSignatures!.map(p => p.partialSignatureB64);

// Combine locally — never on the cluster
const scheme = new Scheme(Curve.Secp256k1);
const finalised = scheme.finalizeSignature(
  messageHash,
  partials,
  Buffer.from(signResult.publicKeyHex!, 'hex'),
);

// finalised is a standard ECDSA signature, ready to embed in your chain transaction

That is the entire hot-wallet signing path. One call, one ceremony, one final signature. No pool to manage, no background mint worker, no per-message ferry — every signing operation is self-contained.

Why hot stays one-shot

The hot tier is optimised for operational simplicity: the cluster owns every share, your backend triggers a ceremony per signature, and there is nothing stateful living between calls. Pre-minted presignatures are useful when the signing party is outside the cluster — a warm host that holds one share and wants to amortise round-trips to the cohort, or a cold device that needs material ferried across an air-gap. The hot tier has neither shape, so it stays one-shot.

If your operating model wants pre-minted presignatures, that is a signal to move the signing party out of the cluster:

  • Warm wallet — server-side EmbeddedAgent that holds one share locally and consumes a presignature pool against the cohort for low-latency repeated signing.
  • Cold wallet — air-gapped the cold-wallet flow that consumes a sealed per-message presignature pack ferried in from the hot cluster.

Both patterns are documented in their own pages with the full provisioning, signing, and recovery flow.


Policy enforcement

Hot-wallet signing in production is wrapped in a workflow. The workflow runs policy components before the cluster signing components — if any policy rejects, the workflow ends and signing never executes.

Withdrawal request (operator-triggered)
        |
        v
+--------------------------------------------------+
| Per-asset velocity limit                         |
|   max 50 ETH per hour, sliding window            |
+----+---------------------------------------------+
     |
     v
+--------------------------------------------------+
| Per-destination denylist                         |
|   sanctioned addresses, in-house abuse list      |
+----+---------------------------------------------+
     |
     v
+--------------------------------------------------+
| Per-operator authorisation                       |
|   operator is in role 'treasury-l1'              |
|   operator MFA fresh within 5 minutes            |
+----+---------------------------------------------+
     |
     v
+--------------------------------------------------+
| Above-threshold check                            |
|   amount > $1M => escalate to cold-path quorum   |
+----+---------------------------------------------+
     |
     v
+--------------------------------------------------+
| Sign — presigning + cluster sign + combine       |
|   driven by ClusterAgent, partials combined locally  |
+----+---------------------------------------------+
     |
     v
   Submit (chain broadcast) + receipt watch + audit

Each box is a workflow component. Adding a new policy is adding a component to the workflow definition on the workspace dashboard. Removing a policy is removing the component. The workflow's execution log captures which version of the workflow ran for any historical signing operation.

Triggering the workflow from your application

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

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

const [{ runId }] = await fetch(
  `https://api.zafeguard.com/v1/sdk/workflows/${HOT_PAYOUT_WORKFLOW_ID}`,
  {
    method: 'POST',
    headers: { 'X-Api-Key': process.env.ZAFEGUARD_API_KEY! },
    body: JSON.stringify({
      keyShareId,
      to: recipientAddress,
      amount,
      chain: 'evm',
      operatorId: req.user.id,
      operatorMfaProof: req.headers['x-mfa-token'],
      idempotencyKey: req.id,
    }),
  },
).then(r => r.json());

// Stream the run status back to the operator's dashboard
const events = new EventSource(
  `https://api.zafeguard.com/v1/sdk/workflows/executions/${runId}/stream`,
  { headers: { 'X-Api-Key': process.env.ZAFEGUARD_API_KEY! } },
);
events.onmessage = (e) => updateOperatorDashboard(JSON.parse(e.data));

Idempotency is essential. If the operator clicks twice or the network fails partway through, the workflow looks up the idempotency key and reuses the in-flight or completed run rather than producing a duplicate signature.


Audit emission

Every component execution emits a structured event captured in the workflow execution log. For institutional audit you want this stream piped to your SIEM.

Two surfaces are useful:

Workflow execution stream (SSE) — for live UI feedback and per-run capture:

GET /v1/sdk/workflows/executions/:runId/stream

Cluster audit logs — for the MPC-ceremony side specifically:

const logs = await agent.sessions.auditLogs({
  sessionId,                  // optional — filter to one ceremony
  failuresOnly: true,
  limit: 100,
});

agent.sessions.auditLogs returns the cluster-internal ceremony events (envelope sealing failures, peer disconnects, license refusals) — the cryptographic layer of the audit story. Combined with the workflow execution log, it covers the application and cryptographic layers in one trail.

What an auditor gets, end-to-end for any signing operation:

  • Workflow trigger with operator identity and MFA proof.
  • Every policy stage evaluation with its outcome.
  • The ceremony sessionId from the cluster, with its cross-node event log.
  • The signed payload and chain confirmation.
  • The workflow version that ran, so the policy in force at the time is recoverable.

The platform does not write your compliance report — but it gives the report a structured, queryable source of truth.


Failure modes

One cluster node down. The threshold (e.g., 2-of-3) lets signing continue with the remaining nodes. Detect via agent.node.health() polling against each node URL; alert when fewer than threshold + 1 are responsive so the runbook can act before a second node goes down. Direct your ClusterAgent.connect(...) calls away from the failed node.

Policy false-positives. A legitimate operation is rejected by a policy. The workflow execution log shows which policy and why. Use a separate "policy override" workflow with its own approval gate to re-execute the original request. Every override is itself an audited workflow run.

Above-threshold escalation. Operations above a configured value (your policy decides) flip from the hot workflow to the cold workflow. The hot workflow does not sign; it transfers the request to the cold-wallet approval pipeline. Operators see this on their dashboard as "this operation requires cold-tier approval — N approvals needed, time-lock window starts now."

This is the right shape: the hot wallet is for in-policy operating volume; anything large enough to matter is handled by the cold tier with multi-party approval.


Next

On this page