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 value | Form | Hardened? |
|---|---|---|
0 | 0 | No |
0 + 0x80000000 | 0' (notation: trailing apostrophe) | Yes |
44 + 0x80000000 | 44' | Yes |
60 | 60 | No |
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).
| Chain | coin_type' | Common derivation | Notes |
|---|---|---|---|
| Bitcoin | 0' | m/44'/0'/0'/0/0 (legacy) | BIP-44 |
| Bitcoin | 0' | m/84'/0'/0'/0/0 (SegWit) | BIP-84 |
| Bitcoin | 0' | m/86'/0'/0'/0/0 (Taproot) | BIP-86 |
| Ethereum | 60' | m/44'/60'/0'/0/0 | BIP-44; one of the most common defaults |
| Polygon, BSC, Avalanche, Base, Arbitrum, Optimism | 60' | m/44'/60'/0'/0/0 | All EVM chains share coin_type 60' |
| Solana | 501' | m/44'/501'/account'/0' | All-hardened path; uses SLIP-0010 over Ed25519 |
| Cosmos | 118' | m/44'/118'/0'/0/0 | BIP-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 === trueloaded.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 === trueWatch-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
- Same path everywhere. The path you pass to
loaded.sign,loaded.finalizeSignature,loaded.verifySignature, and the coordinator'sagent.presignature.signMUST match. A mismatch produces partials that don't combine, or a signature that doesn't verify. - 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.
- Curve constrains the path. Ed25519 derivation (SLIP-0010) only supports hardened steps; secp256k1 and Schnorr accept both hardened and non-hardened.
- 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.
- 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.tsenforces this check.
What's next?
loaded.sign(...)→ — the online sign call that emits partials bound to the derived sub-key.loaded.finalizeSignature(...)→ — combine partials at the derived path.loaded.verifySignature(...)→ — verify against the derived pubkey.- Presignature pool → — pool-backed pattern; same derivation-path semantics apply.