Embedded Agent

Presignature pool

Pre-mint presignatures online, then consume them on later sign() calls to cut the per-signature cohort round-trips from two to one. Manual mint, opt-in auto-refill, plus export / import for cold-storage handoff.

Presignature pool

What is a presignature?

A presignature is a one-time cryptographic commitment to a nonce — generated jointly by the cohort before any specific message is known. It's the cached output of an expensive multi-party round that can later be redeemed against any message to produce a signature.

Threshold ECDSA splits signing into two phases:

  1. Presigning — the cohort runs a multi-party protocol to agree on a fresh nonce. Each party contributes randomness; no party ever sees the full nonce. The output is a single presignature entry — a tuple of per-party share material.
  2. Signing — pair the presignature with a message hash, each party contributes a partial signature, combine them locally. The expensive presigning round-trip is already done; this phase is much cheaper.

Presignature ≠ partial signature. A presignature is the cached nonce material from phase 1 (one per future signature). A partial signature is one party's contribution from phase 2 (one per cohort member per signature). The pool stores the former; loaded.sign(...) returns the latter.

Presigning is the heavier round; signing reuses a pre-minted presignature against any message hash. The pool batches presigning so per-message signing is one round-trip instead of two.

EmbeddedAgent exposes a presignature pool so the heavier round is batchable. Mint a batch of presignatures while the device is online, and every subsequent sign() call pops one from the pool and skips the presigning round-trip — cutting per-sign cohort round-trips from two to one. When the pool empties, sign() falls back to inline presigning automatically.

The pool is a latency optimisation, not an offline-signing mechanism. Every sign() call still needs cohort connectivity to exchange its partial-signing round; the pool only eliminates the presigning round-trip. Plan pool depth around your peak signing rate vs. cohort-round-trip latency.


Picking a pool entry to sign with

Every pool entry carries a stable presignatureId — the unique identifier the cohort agreed on during presigning. When you sign with the pool, you tell the SDK which entry to consume:

// List the public details of every pool entry — id, curve, threshold.
const entries = loaded.presignature.list();
// [{ presignatureId: 'pre-...-abc', curve: 'Secp256k1', threshold: 2, maxShares: 2 }, ...]

// Sign a message against a specific entry. The id must come from list()
// (or the create() return below) — the SDK does not auto-pick, because
// the same id must match the cluster's matching pool entry on every
// other cohort node when their partials are collected.
const messageHash = Utils.sha256(new TextEncoder().encode('msg'));
const partial = await loaded.presignature.sign({
  presignatureId: entries[0].presignatureId,
  messageHash,
});

The caller picks the id because every other cohort node has the same id in its own pool — passing the matching id is how the cohort agrees on which presignature to spend on this signature.


Manual mint and consume

The simplest pattern: mint N presignatures, drain them by signing N times, mint more.

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

const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});
const loaded = await signer.create({
  keyId: 'warm-cold-wallet',
  recovery: { kind: RecoveryKind.Noop },
  passphrase: 'demo-passphrase',
});

// Mint 50 presignatures in parallel. Each one is a full presigning
// ceremony with the agent. The pool grows by exactly 50 on success;
// partial failure throws and leaves the pool unchanged.
const result = await loaded.mintPresignatures({ count: 50 });
console.log(`minted ${result.minted}, pool depth now ${result.available}`);
// { minted: 50, available: 50 }

// Each sign() pops one entry off the pool and exchanges only the
// partial-signing round with the agent. The presigning round-trip is
// skipped because the cached presignature already carries the joint
// nonce share.
for (let i = 0; i < 50; i++) {
  const messageHash = Utils.sha256(new TextEncoder().encode(`message ${i}`));
  // sign() returns the cohort's partials; combine them via
  // loaded.finalizeSignature locally.
  const result = await loaded.sign({ messageHash });
  const sig = await loaded.finalizeSignature({
    messageHash: result.messageHash,
    partials: result.partials,
    derivationPath: result.derivationPath,
  });
  const ok = loaded.verifySignature({ messageHash, signature: sig.compactRecovery });
  if (!ok) throw new Error(`message ${i} did not verify`);
}
console.log('pool depth now:', loaded.presignaturePoolStats.available);
// 0

// The next sign() runs inline presigning (one extra agent round-trip)
// because the pool is empty.
const overflow = Utils.sha256(new TextEncoder().encode('overflow'));
await loaded.sign({ messageHash: overflow });

What's saved is the per-signature presigning round-trip. Inline (no pool): sign() runs presigning + sign = 2 cohort round-trips. With pool: sign() runs sign only = 1 cohort round-trip per signature, plus one batched mint up front.


Pool survives export() / load()

The pool snapshot rides inside the encrypted blob export() produces. A device that pre-mints a batch and exports its state can load() the blob on the same device later (or transfer it to another device) and continue signing against the pool.

const loaded = await signer.create({
  keyId: 'warm-cold-wallet',
  recovery: { kind: RecoveryKind.Noop },
  passphrase: 'demo-passphrase',
});

await loaded.mintPresignatures({ count: 100 });
const blob = await loaded.export('cold-passphrase');
await secureStorage.put('signer-state', blob);

// ─── Process restart, fresh signer instance, same passphrase ────────
const signer2 = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
});
const restored = await signer2.load({
  keyId: 'warm-cold-wallet',
  blob: await secureStorage.get('signer-state'),
  passphrase: 'cold-passphrase',
});
console.log('restored pool depth:', restored.presignaturePoolStats.available);
// 100 — the pool snapshot rode through the blob

Snapshot semantics. export() captures the pool depth at the moment it runs. If the device pre-mints 5, exports, then signs 3 BEFORE the next export, the next export will snapshot 2 valid entries. If the device crashed between mint and export and then re-loaded an older blob, the agent would reject the first sign calls as "unknown presignature session" — the stale entries were consumed in a previous lifetime. Callers that want "every signature is durable" semantics call export() after every mutation (or use the auto-refill policy below, which writes back to memory but not to your storage adapter).


Opt-in auto-refill

Manual mint is fine for batch workflows. For consumer apps that want the pool kept warm without operator intervention, configure the constructor with an auto-refill policy. The SDK will then fire a background mint task whenever sign() drains the pool to the trigger threshold.

const signer = new EmbeddedAgent({
  agents: [{ baseUrl: NODE_URL, apiKey: API_KEY }],
  threshold: 2,
  curve: Curve.Secp256k1,
  presignaturePool: {
    autoRefillWhen: 5,    // when pool drops to <= 5 after a sign
    autoRefillTo: 50,     // background-mint up to 50
  },
});
const loaded = await signer.create({
  keyId: 'always-warm-wallet',
  recovery: { kind: RecoveryKind.Noop },
  passphrase: 'demo-passphrase',
});

// Seed the pool once. After this every sign() that drops the pool
// to <= 5 triggers a fire-and-forget mint back up to 50.
await loaded.mintPresignatures({ count: 50 });

// The next 45 signs each consume an entry. The 45th sign drops the
// pool to 5 — that's <= autoRefillWhen, so a background task fires
// to mint 45 more presigs back up to 50. The 45th sign returns
// without waiting on the refill; the in-flight signature uses the
// entry it just consumed.
for (let i = 0; i < 100; i++) {
  await loaded.sign({ messageHash: msg(i) });
}

What auto-refill does NOT do

  • It doesn't block the in-flight sign. The signature returns as soon as the consumed entry + agent partial assemble — refilling happens out-of-band.
  • It doesn't surface refill errors to the in-flight sign. If the background mint fails (agent unreachable, network error), the error is logged via stderr but the in-flight signature is unaffected. The next sign that observes a low pool retries the refill from scratch.
  • It doesn't persist refilled entries to storage. Auto-refill mutates the in-memory pool only; the persisted blob from export() snapshots whatever depth was current at export time. If you want auto-refilled entries durable, call export() periodically (or after each refill, by polling presignaturePoolStats.available).
  • It doesn't fire events. Subscribe-style notifications (presignature:low, presignature:depleted) are designed but unshipped. Poll presignaturePoolStats.available if you want to surface depletion to the UI.

Concurrency: at-most-one refill per signer

If the device fires 10 parallel sign() calls and every one observes the pool below the threshold, they will NOT spawn 10 parallel refills. An atomic latch on the signer ensures at most one refill task is in flight; subsequent triggers collapse into the running task until it completes (or fails) and the latch clears.

Pool observability for SLO monitoring

loaded.presignaturePoolStats is a synchronous getter — read it on every sign() (or on a poll loop) to track pool depth without any cohort round-trip. It returns { available, autoRefillWhen?, autoRefillTo? }. For latency-sensitive workloads (high-frequency trading, automated market makers) wire this into your metrics stack:

import client from 'prom-client'; // or whichever metrics library

const poolDepthGauge = new client.Gauge({
  name: 'mpc_presignature_pool_depth',
  help: 'Current depth of the device-side presignature pool',
  labelNames: ['walletKeyId'],
});

setInterval(() => {
  const { available } = loaded.presignaturePoolStats;
  poolDepthGauge.set({ walletKeyId: loaded.keyId }, available);
}, 1_000);

Alert on available < autoRefillWhen × 2 to catch the case where auto-refill is failing (agent unreachable, network partition, rate-limit hit) before the pool hits zero and sign() falls back to inline presigning (one extra cohort round-trip, adding ~50–500 ms of tail latency depending on cohort geography). The auto-refill failure path is fire-and-forget by design — without active monitoring, the only signal you get is a latency spike on production traffic.


Pool export and import (cold-storage handoff)

The pool can travel between handles as a sealed blob, independent of the signer-state export above. Useful for cold-storage handoffs — pre-mint on a connected device, ship the sealed blob out-of-band to an air-gapped device that already holds the same signer's state, import on the cold device.

// Source handle: mint a batch + export the pool.
const sourceBlob = await sourceHandle.exportPresignaturePool({
  passphrase: 'cold-handoff-pw',
});

// Transfer `sourceBlob` over your channel of choice (USB stick, QR,
// encrypted message). The blob is opaque ciphertext — safe to handle.

// Destination handle (same keyId, same root public key, fresh pool):
const result = await destHandle.importPresignaturePool({
  blob: sourceBlob,
  passphrase: 'cold-handoff-pw',
});
console.log(result);
// { imported: N, skippedAsDuplicate: M, available: N+existing }

Cryptographic invariants

  • Cohort fingerprint binding. The blob carries a SHA-256 of keyId || rootPublicKey. The import side validates this against the destination handle's identity. A blob from signer A cannot be imported into signer B — importPresignaturePool throws on mismatch.
  • Passphrase-sealed. Same scrypt + AES-256-GCM construction as LoadedKeyShare.export(). Wrong passphrase = AEAD tag check failure = clean error, no plaintext leak.
  • Format version pinning. The current format is zg-presig-pool/v1. A future v2 blob would fail open on this build with a clear "unsupported blob format" error rather than risk reading mismatched semantics.
  • Cross-format isolation. Pool blobs and signer-state blobs use different AAD strings, so a pool blob cannot accidentally be opened as a signer-state blob (or vice-versa) — the AEAD tag mismatch catches the confusion immediately.
  • Idempotent imports. Duplicate detection runs by agent_session_id (unique per presigning ceremony). Re-importing the same blob into the same pool counts every entry as skippedAsDuplicate and grows the pool by zero. Safe to call import multiple times if you lose track of which device already has which blob.

What the blob does NOT contain

  • The blob carries ONLY the device's half of each presignature, not the agent's half. A stolen blob alone signs nothing — the holder would also need the device's MPC key share (which lives in the separate export() blob, sealed under a different passphrase) AND a live network path to the agent at sign time. The two blobs are independent secrets; even a stolen pool blob can't be used to forge a signature without the share blob AND the agent.

Air-gapped sign — partial-ferry path

The pool can be used in a second mode where the cold device never opens a network socket at sign time. Instead of calling loaded.sign({ messageHash }) — which performs the partial-signing cohort round-trip — the cold device calls loaded.presignature.sign({ presignatureId, messageHash }). That call consumes one pool entry locally, computes only this device's partial signature, and returns a single base64 PSIG envelope. No cohort contact.

An online coordinator runs the matching call against each cohort agent (ClusterAgent.presignature.sign(presignatureId, { keyShareId, messageHashHex, derivationPath })), collects all the partials, and combines them into the final (r, s, v) via Utils.finalizeSignature on the coordinator side.

Two operational rules:

  • Both sides MUST pick the same presignatureId. A pool entry is one-time material — the cold device consumes it locally; the coordinator tells each cohort agent to consume the same id. Mismatched ids produce partials that do not combine.
  • The cold device burns the entry locally even if the coordinator's call later fails. Plan for the possibility that one party signed an entry that never produced a finalised signature; treat the entry as spent on the cold side and ask the coordinator to retry against a fresh entry.
import { ClusterAgent, Utils } from '@zafeguard/mpc-sdk';

// ── Cold device (air-gapped) ───────────────────────────────────────
// Receives `messageHash` + the chosen `presignatureId` over the
// secure ferry channel. The call runs locally — no network.
const entries = coldHandle.presignature.list();
const chosen = entries[0].presignatureId;

const coldPartial = await coldHandle.presignature.sign({
  presignatureId: chosen,
  messageHash,
});
// coldPartial.partialSignatureB64       — this device's PSIG envelope
// coldPartial.presigSessionId           — pass to the coordinator
// coldPartial.keyShareId                — pass to the coordinator
// coldPartial.publicKeyHex              — wallet root pubkey (verify-side)

// ── Online coordinator (combine + broadcast) ───────────────────────
// Run agent.presignature.sign against each cohort agent using the
// SAME presignatureId. Each returns one base64 PSIG envelope.
const cluster = ClusterAgent.connect('agent-1.example', 443, apiKey, true);
const agentPartial = await cluster.presignature.sign(coldPartial.presigSessionId, {
  keyShareId:      coldPartial.keyShareId,
  messageHashHex:  messageHash.toString('hex'),
});

const sig = Utils.finalizeSignature({
  messageHash,
  partialSignatures: [coldPartial.partialSignatureB64, agentPartial.partialSignatureB64],
  publicKey:         Buffer.from(coldPartial.publicKeyHex, 'hex'),
  curve:             Curve.Secp256k1,
});

This is the truly-offline path the SDK supports today. The cold device runs loaded.presignature.sign purely locally; the coordinator does the cohort coordination.


When to reach for which tool

GoalTool
Sign N messages with one batched presigning roundmintPresignatures({ count: N }) then sign() × N
Keep a permanent offline buffer of M presignaturespresignaturePool: { autoRefillWhen: K, autoRefillTo: M } on the constructor
Transfer pool entries between devices for cold-storage signingexportPresignaturePool on the source, importPresignaturePool on the destination
Sign on a permanently air-gapped device — no network at sign timeloaded.presignature.sign({ presignatureId, messageHash }) on the cold device, ClusterAgent.presignature.sign on the coordinator, combine with Utils.finalizeSignature
Inspect current pool depth before deciding to mintloaded.presignaturePoolStats.available
Persist the pool across process restartsloaded.export(passphrase) → restart → signer.load(); the pool rides inside the encrypted blob

Next

  • Recovery → — share rotation (reshare()), sealed-bundle backup (RecoveryKind), and the cold-recovery root-key reconstruction path.
  • Topology → — pick the cohort shape that fits your trust model.

On this page