Derivation paths

How hierarchical key derivation works in threshold MPC — when to use a derivation path, the standard path layouts (BIP-32, BIP-44, BIP-84), and the precise rules for signing and verifying against a derived sub-key.

Derivation paths

Standard: BIP-32 hierarchical deterministic derivation, with BIP-44 path layout for cross-chain wallets. See Cryptographic foundations — Hierarchical wallets.

A derivation path lets one root MPC key act as the parent of an unbounded tree of sub-keys, each with its own public key + address. The root never signs directly in production; instead, each transaction signs against a child key derived from the root along a specific path. This is the institutional pattern for one MPC ceremony, many derived identities — per-customer accounts, per-asset addresses, per-project sub-keys, per-region treasury wallets, all derived from a single DKG'd root.

Every signing API in the SDK that takes a derivationPath argument runs the same BIP-32 hierarchical-derivation construction over the MPC share material. The path moves through the call chain sign → finalize → verify so the produced signature commits to the derived sub-key, not the root.

When to use a derivation path

  • One DKG, many user accounts. Run DKG once for a root key; derive a sub-key per end-user (m/44'/coin'/0'/0/userIndex). Lets you scale beyond N DKG ceremonies for N users.
  • Address rotation without re-DKG. Sign each transaction at a fresh child path so address reuse is bounded; the root key never appears on-chain.
  • Per-project scoping. Inside an institution, derive per-project sub-keys (m/44'/coin'/projectIndex'/...) so policy can attach to derivation prefixes.
  • Multi-asset wallets from one share. Bitcoin + Ethereum + Solana addresses all derive from one secp256k1 root via different coin-type prefixes (secp256k1 covers EVM + BTC; Ed25519 covers Solana via its own DKG).
  • Watch-only address tracking. Derived public keys can be computed from the root pubkey alone — no share material needed. See Scheme.computePublicKey(...) below.

The path format

A derivation path is an array of 32-bit unsigned integers. The high bit (0x80000000) marks an index as hardened:

Index valueFormHardened?
00No
0 + 0x800000000' (notation: trailing apostrophe)Yes
44 + 0x8000000044'Yes
6060No

Hardened derivation breaks the relationship between the parent public key and the child public key — you cannot derive a hardened child's public key from the parent's public key alone (you need the share material). Non-hardened derivation preserves the relationship; given the parent pubkey and a non-hardened path, anyone can compute the child pubkey. Standard wallets use hardened steps for the "account" boundary and non-hardened for the "address index" boundary.

Standard path layouts

The SDK does not impose a path layout — pass any number[] and the math works. The BIP-44 conventions exist so independent tools can agree on which path = which address. The common layouts the SDK is interoperable with:

BIP-32 / BIP-44

m / purpose' / coin_type' / account' / change / address_index

The five-level structure used by most ecosystem wallets. purpose is 44' for legacy BIP-44, 49' for P2SH-wrapped SegWit (BIP-49), 84' for native SegWit (BIP-84), 86' for Taproot (BIP-86).

Chaincoin_type'Common derivationNotes
Bitcoin0'm/44'/0'/0'/0/0 (legacy)BIP-44
Bitcoin0'm/84'/0'/0'/0/0 (SegWit)BIP-84
Bitcoin0'm/86'/0'/0'/0/0 (Taproot)BIP-86
Ethereum60'm/44'/60'/0'/0/0BIP-44; one of the most common defaults
Polygon, BSC, Avalanche, Base, Arbitrum, Optimism60'm/44'/60'/0'/0/0All EVM chains share coin_type 60'
Solana501'm/44'/501'/account'/0'All-hardened path; uses SLIP-0010 over Ed25519
Cosmos118'm/44'/118'/0'/0/0BIP-44

The full registry is SLIP-0044. Use a known coin_type' when you want any standard wallet (Ledger, MetaMask, Phantom) to recognise the same address derived from the same root.

Institutional / non-blockchain hierarchies

The same hierarchical-derivation primitive works for any institutional key tree — the path indices are just integers; you don't have to follow BIP-44. Example schemes:

  • Per-customer scoping: m/customer_id'/account_index'/operation_kind/0 (hardened customer boundary so leaks at lower levels don't expose other customers).
  • Per-project release: m/project_id'/release_index/artefact_index (non-hardened release/artefact so a CI runner can derive a sub-key for the build without holding share material).
  • Per-region treasury: m/region_code'/year'/quarter'/operation_kind/sequence (hardened time-period boundaries).
  • Per-device IoT: m/fleet_id'/device_id'/command_kind (hardened device boundary so a per-device compromise doesn't cross-contaminate).

Pick a layout, document it, stick to it. Whatever path you signed against, the same path must be supplied at verification.

Signing with a derivation path

Every signing surface that takes messageHash also takes an optional derivationPath: number[]. The path moves through the API: the cohort agents derive their sub-key shares in lockstep, the partials commit to the derived sub-key, and finalisation produces a signature that verifies against the derived public key.

Online — sign, finalize, and verify all carry the same derivationPath

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

const HARDENED = 0x80000000;
const ethPath = [44 | HARDENED, 60 | HARDENED, 0 | HARDENED, 0, 0];

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

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

// Sign at the derived sub-key. The result echoes the derivation path
// back so finalize + verify don't need to repeat the literal.
const signResult = await loaded.sign({ messageHash, derivationPath: ethPath });

const sig = await loaded.finalizeSignature({
  messageHash: signResult.messageHash,
  partials: signResult.partials,
  derivationPath: signResult.derivationPath,    // verifies against the derived pubkey
});

const ok = loaded.verifySignature({
  messageHash,
  signature: sig.compactRecovery,
  derivationPath: ethPath,                       // MUST match the sign-time path
});
// ok === true

loaded.finalizeSignature and loaded.verifySignature are convenience wrappers that close over the handle's public key + curve. For cross-host workflows where the host that runs sign() is not the host that finalises, ship signResult.partials to the other host and call Scheme.finalizeSignature(messageHash, partials, derivedPublicKey) directly — but you must derive the public key yourself first via Scheme.computePublicKey({ publicKey: rootPub, path: ethPath }).

Offline — loaded.presignature.sign carries the path; the coordinator combines and verifies

The offline-partial-sign flow takes the same derivationPath argument. Both the offline host AND the coordinator's agent.presignature.sign(...) call must pass the same path, or the partials won't combine into a valid signature.

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

// On the offline host — no cohort contact at sign time.
const presigId = loaded.presignature.list()[0].presignatureId;
const offline = await loaded.presignature.sign({
  messageHash,
  presignatureId: presigId,
  derivationPath: ethPath,
});

// Ferry `offline` to the online coordinator via your transport.

// On the online coordinator — collect cohort partials with the SAME path.
const cluster = ClusterAgent.connect('agent-1.example.com', 443, apiKey, true);
const agentPartial = await cluster.presignature.sign(offline.presigSessionId, {
  keyShareId: offline.keyShareId,
  messageHashHex: messageHash.toString('hex'),
  derivationPath: ethPath,                       // MUST match
});

// Combine + verify against the derived pubkey.
const scheme = new Scheme(Curve.Secp256k1);
const derivedPub = scheme.computePublicKey({
  publicKey: Buffer.from(offline.publicKeyHex, 'hex'),
  path: ethPath,
}).publicKey;

const finalised = scheme.finalizeSignature(
  messageHash,
  [offline.partialSignatureB64, agentPartial.partialSignatureB64],
  Buffer.from(offline.publicKeyHex, 'hex'),
);
const ok = scheme.verifySignature(messageHash, finalised.signature, derivedPub);
// ok === true

Watch-only address derivation

Scheme.computePublicKey({ publicKey, path }) derives a sub-key's public key from the root pubkey — no share material required. Use it to mint addresses for monitoring without exposing the share:

const scheme = new Scheme(Curve.Secp256k1);
const childPub = scheme.computePublicKey({
  publicKey: rootPublicKey,                     // root pubkey
  path: [44 | HARDENED, 60 | HARDENED, 0 | HARDENED, 0, addressIndex],
}).publicKey;

Hardened steps in the path are a hard wall here. If any step in the path is hardened, computing the child pubkey requires the share — not just the root pubkey. In practice you derive your account-level hardened boundary once (with the share) and persist the account-level pubkey, then derive non-hardened address-level children from that account pubkey for watch-only purposes.

The five rules

  1. Same path everywhere. The path you pass to loaded.sign, loaded.finalizeSignature, loaded.verifySignature, and the coordinator's agent.presignature.sign MUST match. A mismatch produces partials that don't combine, or a signature that doesn't verify.
  2. Hardened steps need the share. Cannot derive a hardened child's pubkey from the parent pubkey alone. Plan your tree so the hardened boundary sits where you're willing to invoke the share.
  3. Curve constrains the path. Ed25519 derivation (SLIP-0010) only supports hardened steps; secp256k1 and Schnorr accept both hardened and non-hardened.
  4. The path is public. Path indices are not secret; they appear in audit logs, transaction metadata, and address-explorer output. Don't put sensitive identifiers in the path.
  5. Root-pubkey verify must fail on a sub-key signature. A signature produced against a derived sub-key MUST NOT verify against the root pubkey. The end-to-end derivation-path test in napi/__test__/derivation-path-end-to-end.spec.ts enforces this check.

What's next?

On this page