Multi-Chain Signing
The boilerplate signs for three chains from two MPC keyshares:
- EVM chains (Ethereum, Base, etc.) — the ECDSA session and viem
- Bitcoin Testnet4 — the same ECDSA session and bitcoinjs-lib
- Solana — the EdDSA session and @solana/kit
In every case, the full private key is never reconstructed. The MPC protocol between the device and duo-server produces the signature directly.
On a deployment with policy evaluation enabled, none of the snippets below produce a signature until an admin has saved a policy that allows the request — the seeded default policy has no rules and denies everything. See Policy Engine.
Address derivation
Wallet addresses are derived from the public key in each keyshare immediately after keygen:
// src/libs/chains/viem.ts — EVM address from ECDSA keyshare
export const computeECDSAAddress = (keyshare: Keyshare): Hex => {
const compressed = hexToBytes(`0x${keyshare.publicKeyHex}`);
const uncompressed = secp256k1.ProjectivePoint.fromHex(compressed).toRawBytes(false);
return publicKeyToAddress(bytesToHex(uncompressed));
};
// src/libs/chains/solana.ts — Solana address from EdDSA keyshare
export const computeSolanaAddress = (keyshare: Keyshare): Address => {
const publicKeyBuffer = Buffer.from(keyshare.publicKeyHex, 'hex');
return address(bs58.encode(publicKeyBuffer));
};
// src/libs/chains/bitcoin.ts — P2WPKH address from the same ECDSA keyshare
export const computeBitcoinAddress = (keyshare: Keyshare): string | null => {
const pubkeyBuf = Buffer.from(keyshare.publicKeyHex, 'hex');
const payment = payments.p2wpkh({ pubkey: pubkeyBuf, network: networks.testnet });
return payment.address ?? null;
};
EVM signing
src/libs/chains/viem.ts wraps the ECDSA session into a viem Account object. This allows the app to use the full viem wallet client API — including signing messages, transactions, and typed data — without any modification.
// src/libs/chains/viem.ts
export function createSilentShardAccount(session: EcdsaSession, keyshare: Keyshare): Account {
return toAccount({
address: computeECDSAAddress(keyshare),
publicKey: `0x${keyshare.publicKeyHex}`,
async signMessage({ message }): Promise<Hex> {
const signature = await session.signWithHash({
hashAlgo: 'KECCAK256',
message: normalizeMessage(toPrefixedMessage(message)),
keyshare,
customTags: { 0: policyTransactionTypes.eip191 },
});
return normalizeSignature(signature);
},
async signTransaction(transaction, options): Promise<Hex> {
const txSerializer = options?.serializer ?? serializeTransaction;
const serializedTx = await txSerializer(transaction);
const signature = await session.signWithHash({
hashAlgo: 'KECCAK256',
message: normalizeMessage(serializedTx),
keyshare,
customTags: { 0: policyTransactionTypes.eip1559 },
});
const parsed = parseSignature(`0x${signature}`);
return txSerializer(transaction, parsed);
},
async signTypedData(parameters): Promise<Hex> {
const signature = await session.sign({
keyshare,
messageHash: normalizeMessage(hashTypedData(parameters)),
customTags: { 0: policyTransactionTypes.eip712 },
});
return normalizeSignature(signature);
},
});
}
Two things to notice:
signMessageandsignTransactionusesignWithHash. It sends the full message and names the hash algorithm, so the backend can see what it is co-signing and apply transaction policies to it.signTypedDatastays onsign()— an EIP-712 payload cannot be recovered from its hash.- Every call attaches
customTags. The boilerplate uses slot0to label the transaction type (EIP-191 message, EIP-1559 transaction, EIP-712 typed data), which the backend's policy hook reads. See Policy Engine for how that tag is used.
The viem wallet client is created in src/hooks/mpc/useViem.ts using this account. Sending a transaction is standard viem — walletClient.sendTransaction(...) — and the MPC signing happens transparently inside signTransaction.
Solana signing
src/libs/chains/solana.ts provides createMpcSolanaSigner, a helper that wraps the EdDSA session and returns the @solana/kit transaction and message signer interfaces:
// src/libs/chains/solana.ts
export function createMpcSolanaSigner(
session: EddsaSession,
keyshare: Keyshare,
): TransactionPartialSigner & MessagePartialSigner {
const signerAddress = computeSolanaAddress(keyshare);
return {
address: signerAddress,
signMessages: async (messages) => {
return Promise.all(
messages.map(async (message) => {
const sigHex = await session.sign({
keyshare,
messageHash: Buffer.from(message.content).toString('hex'),
customTags: { 0: policyTransactionTypes.rawBytes },
});
const sig = signatureBytes(new Uint8Array(Buffer.from(sigHex, 'hex')));
return Object.freeze({ [signerAddress]: sig });
}),
);
},
signTransactions: async (transactions) => {
return Promise.all(
transactions.map(async (tx) => {
const sigHex = await session.sign({
keyshare,
messageHash: Buffer.from(tx.messageBytes).toString('hex'),
customTags: { 0: policyTransactionTypes.solanaTransaction },
});
const sig = signatureBytes(new Uint8Array(Buffer.from(sigHex, 'hex')));
return Object.freeze({ [signerAddress]: sig });
}),
);
},
};
}
EdDSA signs the message bytes directly, so sign() already gives the backend the full content — no signWithHash needed. The customTags slot 0 labels the request the same way as on EVM.
Bitcoin signing
src/libs/chains/bitcoin.ts wraps the same ECDSA session into a bitcoinjs-lib SignerAsync. The app builds a PSBT, and each input is signed by the MPC session:
// src/libs/chains/bitcoin.ts
export function createMpcBitcoinSigner(session: EcdsaSession, keyshare: Keyshare): SignerAsync {
const publicKey = Buffer.from(keyshare.publicKeyHex, 'hex');
return {
publicKey,
async sign(hash: Buffer) {
const sig = await session.sign({
keyshare,
messageHash: Buffer.from(hash).toString('hex'),
customTags: { 0: policyTransactionTypes.bitcoin },
});
// Strip recovery byte — Bitcoin needs 64-byte r‖s
return Buffer.from(sig, 'hex').subarray(0, 64);
},
};
}
Security note
At no point is the full private key assembled on the device or on the server. The session.sign() call executes the Distributed Signature Generation (DSG) protocol — the device and duo-server each contribute their share, and the resulting signature is produced cooperatively without either side ever learning the other's secret.