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:
- Inline signing — the device drives presigning + partial-sign against the cohort live. Two cohort round-trips per signature.
- 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, ...)onClusterAgent— returns one cluster node'spartialSignatureB64. Used in the hot custody flow where partials are combined off-line viaScheme.finalizeSignature.Utils.unwrap+Utils.computePartialSignatureflow andUtils.unwrap+Utils.computePartialSignatureflow — return one cold/warm device'spartialSignatureB64. 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:
- The SDK consumes one presignature from the pool if available; otherwise it runs an inline presigning round-trip with the cohort.
- The cohort participants compute their partial signatures.
- 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:
| Field | Effect |
|---|---|
messageHash: Buffer | The 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?: SignatureEncoding | Output 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 mintJsMintPresignaturesOptions 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 withpresignaturePoolStats 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 toautoRefillWhenor below, the SDK kicks a background task that mintsautoRefillTo - currentDepthpresignatures. - 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.
autoRefillTomust be strictly greater thanautoRefillWhenso 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 appendedProperties:
- 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
- Dynamic Configuration — how tier and risk drive pool depth, cohort placement, and refill cadence.
- Embedded Agent → Offline signing — the canonical SDK reference for every method on this page.
- Custody Whitelabel — Cold Wallet — the institutional pattern that pushes pool export/import to its limit.
Recovery
Recovery for an embedded MPC wallet — the four lifecycle events, the sealed recovery bundle, restore() on a fresh device, credential rotation, and how to layer a product-level guardian flow on top of the SDK primitives.
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.