Embedded Wallet Solution

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.

Signing & Pool-Backed Latency

A wallet that signs once is a demo. A wallet that signs ten thousand times across flaky mobile networks, low-battery devices, and slow cohort regions is a product. This page is about getting from the first to the second.

Two modes of signing matter in production:

  1. Inline signing — the device drives presigning + partial-sign against the cohort live. Two cohort round-trips per signature.
  2. Pool-backed signing — the device pre-minted presignatures while online and consumes them on subsequent sign() calls, skipping the presigning round-trip. One cohort round-trip per signature.

The cryptographic primitive: presigning produces a one-time joint nonce share that any single sign() later consumes. The device cannot mint presignatures alone — nonces must be jointly generated — so pre-minting requires cohort connectivity. Every sign() call still needs cohort connectivity at sign time to exchange the partial-signing round; the pool only eliminates the presigning round-trip, not the signing round-trip. Plan pool depth around cohort-round-trip latency, not around offline signing.

→ The canonical reference is Embedded Agent → Offline signing. This page covers production patterns around that reference.


The sign call

loaded.sign({ messageHash }) returns the threshold-many partial signatures the cohort produced; combine them via loaded.finalizeSignature(...) locally — no extra cohort round-trip — into a final (r, s, v):

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

const messageHash = Utils.sha256(new TextEncoder().encode(serializedTx));

const partials = await loaded.sign({ messageHash });
const sig = await loaded.finalizeSignature({
  messageHash: partials.messageHash,
  partials:    partials.partials,
  derivationPath: partials.derivationPath,
});
// sig is a fully combined JsSignature:
//   sig.r               — 32-byte r component (Buffer)
//   sig.s               — 32-byte s component (Buffer)
//   sig.v               — recovery byte for EVM signing flows
//   sig.der             — ASN.1 DER encoding (Buffer)
//   sig.rawRs           — 64 bytes (r || s)
//   sig.compactRecovery — 65 bytes (r || s || v)

loaded.sign(...) returns a complete (r, s, v) signature ready to embed in a transaction. The combining happens locally on the device after collecting partial contributions from the cohort. This is different from two other signing APIs in the same SDK that DO return partials:

  • agent.presignature.sign(presigId, ...) on ClusterAgent — returns one cluster node's partialSignatureB64. Used in the hot custody flow where partials are combined off-line via Scheme.finalizeSignature.
  • Utils.unwrap + Utils.computePartialSignature flow and Utils.unwrap + Utils.computePartialSignature flow — return one cold/warm device's partialSignatureB64. Used in the cold custody partial-ferry flow where partials are ferried back over an air-gap and combined on the online side. See the Offline Signer reference for both modes.

If your use case is a consumer warm-cold wallet where the device IS one of the threshold parties, loaded.sign(...) is the right call and you do not need to handle partials yourself.

Behind one call:

  1. The SDK consumes one presignature from the pool if available; otherwise it runs an inline presigning round-trip with the cohort.
  2. The cohort participants compute their partial signatures.
  3. The SDK combines the partials locally on the device — there is no cluster-side signature assembly. This is an architectural guarantee of the SDK, not a configurable option.

JsSignOptions accepts:

FieldEffect
messageHash: BufferThe pre-hashed message. SHA-256 for EVM/Bitcoin ECDSA, the chain's signing hash for Solana.
derivationPath?: number[]Non-hardened BIP-32 derivation components. Omit to sign at the root.
encoding?: SignatureEncodingOutput encoding. Defaults to SignatureEncoding.CompactRecovery.

The signer is single-curve. There is no chain parameter — the curve was committed at constructor time, and the chain-specific transaction shape (RLP / PSBT / Solana tx format) is composed by the corresponding chain component in your workflow.

Verifying the signature

The SDK exposes Scheme for local verification — useful during development and for verifying signature correctness:

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

const scheme = new Scheme(Curve.Secp256k1);
scheme.verifySignature(
  messageHash,
  sig.compactRecovery,
  loaded.publicKey,
  SignatureFormat.CompactRecovery,
  sig.v,
);

The presignature pool

A presignature is the one-time joint nonce share — minted online with the cohort, consumed later by the device alone. The pool is the device's stock.

Manual mint

const result = await loaded.presignature.mint({ count: 50 });
console.log(result.minted);     // 50 (always equal to count on success)
console.log(result.available);  // pool depth after this mint

JsMintPresignaturesOptions accepts only count: number. Each mint is count full presigning ceremonies run in parallel against the agent. Partial failure throws and leaves the pool unchanged.

Single-agent (2-of-2) signers only in v1. Multi-agent batch presigning is on the multi-agent driver roadmap; calls on multi-agent signers reject up front.

Reading pool depth

const stats = loaded.presignature.stats;
console.log(stats.available);   // current pool depth
// stats also surfaces the auto-refill policy this signer was constructed with

presignaturePoolStats is a synchronous getter on LoadedKeyShare — cheap to read repeatedly, useful for UI affordances ("you can sign N more times offline before needing a refresh").

Auto-refill

Auto-refill is constructor configuration, not a runtime method. Pass presignaturePool to the EmbeddedAgent constructor:

const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
  presignaturePool: {
    autoRefillWhen: 5,    // refill threshold (pool drops to here or below)
    autoRefillTo: 50,     // refill target (background mint to this depth)
  },
});

Behaviour:

  • After a sign() call drops the pool to autoRefillWhen or below, the SDK kicks a background task that mints autoRefillTo - currentDepth presignatures.
  • The background task does NOT block the in-flight sign() — the current signature uses the consumed entry (or falls back to inline presigning if the pool was already empty); the refilled entries become available for subsequent signs once the task completes.
  • Both fields are required together — passing one without the other rejects at construction.
  • autoRefillTo must be strictly greater than autoRefillWhen so refills always grow the pool.

To manage the pool manually instead, omit presignaturePool from the constructor and call loaded.presignature.mint({ count }) from your own scheduler.


Pool consumption and the inline fallback

The default loaded.sign({ messageHash }) call:

  • Pool has entries: consume one, run partial-sign only (one cohort round-trip per sign).
  • Pool is empty: fall back to inline presigning + partial-sign (two cohort round-trips per sign).
  • Cohort unreachable: throws.

sign() always needs cohort connectivity to exchange the partial-signing round, regardless of pool depth. The pool's job is latency optimisation — turning two round-trips into one — not enabling air-gapped signatures. For UX flows where you want to surface "this will be slower" (pool empty), check the depth before calling:

const stats = loaded.presignature.stats;
if (stats.available <= 0) {
  showLatencyWarning();   // next sign falls back to inline presigning
}
const sigPartials = await loaded.sign({ messageHash });
const sig = await loaded.finalizeSignature({
  messageHash: sigPartials.messageHash,
  partials: sigPartials.partials,
  derivationPath: sigPartials.derivationPath,
});

For genuinely offline-capable signing patterns where the device is disconnected at sign time, see Cold Wallet — it covers the controlled-connectivity tier where pool refill is a scheduled event and each sign opens a short cohort connection.


Pool survives export() / load()

The pool snapshot rides inside the export() blob. A device that pre-minted a batch can export its state and rehydrate later (same device or another device under the same user identity) with the pool intact:

// Mint a buffer before going offline
await loaded.presignature.mint({ count: 100 });

// Export — the pool snapshot rides in the blob
const blob = await loaded.export(exportPassphrase);
await secureStorage.put(`signer:${userId}`, blob);

// ... later, on the same or a different device ...
const restored = await signer.load({ signerId, blob, passphrase: exportPassphrase });
console.log(restored.presignature.stats.available);  // 100 (or fewer, if some were consumed)

This is useful for "transfer my wallet to a new phone" flows where the user already has connectivity and wants the new device pre-stocked.


Pool export and import for cold-handoff

For the air-gapped cold-storage shape, you can ship pool entries from one signer to another using exportPool / importPool:

// On the warm device (online) — seal a slice of the pool into a portable blob
const portable = await loaded.presignature.exportPool({
  count: 20,
  passphrase: handoffPassphrase,
});

// Ferry the blob through your air-gap mechanism (QR, signed USB, etc.) ...

// On the cold device — same signer identity, separate handle
const result = await coldLoaded.presignature.importPool({
  blob: portable,
  passphrase: handoffPassphrase,
});
console.log(result.imported);  // how many entries were appended

Properties:

  • Identity-bound. The blob is bound to the signer's signerId + root public key. Importing into a different signer is rejected up front.
  • Idempotent on duplicate entries. Re-importing the same blob silently skips entries the destination already holds (same agent_session_id).
  • One-shot on consumption. Consuming a presig on the source side does not invalidate an exported copy until the agent has run the matching sign call.
  • Treat the blob like a signing capability. A stolen blob lets the holder sign from any handle that also holds the device share.

→ See Embedded Agent → Offline signing → Export/import for the full design.


Wiring sign into a workflow

In production, signing is rarely standalone. Wrap it in a workflow that handles gas, policy, broadcast, audit:

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

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

// Application layer signs locally with the embedded handle, then submits the
// signed payload to a workflow that broadcasts + watches for confirmation.
const sigPartials = await loaded.sign({ messageHash });
const sig = await loaded.finalizeSignature({
  messageHash: sigPartials.messageHash,
  partials: sigPartials.partials,
  derivationPath: sigPartials.derivationPath,
});
const serializedTxWithSig = assembleSignedTx(serializedTx, sig);

const result = await workspace
  .call(ComponentModule.BROADCAST_EVM_TRANSACTION, {
    jsonRpcUrl,
    signedTxHex: serializedTxWithSig.toString('hex'),
  })
  .promise();

Alternatively, your workflow can do the signing via the workspace's MPC components if the signer is server-side rather than embedded. For embedded wallets the device-side loaded.sign(...) is typically the right cut.

→ See Smart Account Flow for an end-to-end workflow-driven multi-user wallet pattern.


Error handling

The SDK surfaces specific errors for the failure modes you need to distinguish:

try {
  const sigPartials = await loaded.sign({ messageHash });
  const sig = await loaded.finalizeSignature({
    messageHash: sigPartials.messageHash,
    partials: sigPartials.partials,
    derivationPath: sigPartials.derivationPath,
  });
} catch (err) {
  // The SDK throws either an MPC SDK error (from the Rust native side) or a
  // network-level error from the underlying transport. Common diagnostics:
  //
  // - Cohort unreachable: the underlying fetch / network call surfaces with
  //   its own error type. Retry with backoff; if persistent, surface as
  //   "service degraded" and prompt the user to retry later.
  // - Wrong storage state: the device share could not be read from secure
  //   storage. Re-run signer.load(...) to rehydrate the handle, or prompt
  //   the user for biometric / lock-screen unlock if the storage backend
  //   requires it.
  // - Invalid signature material: the presignature failed structural
  //   validation (rare; indicates a protocol bug or version mismatch).
  //   Capture for diagnostics and retry with a freshly minted presignature.
  throw err;
}

The SDK does not ship a closed catalog of named error classes for every wire condition — error sources include the native MPC library, the underlying transport, and the secure-storage backend. Match on instanceof Error plus the message / cause for the failure modes your UX needs to distinguish.


Next

On this page