Skip to main content

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:

  • signMessage and signTransaction use signWithHash. 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. signTypedData stays on sign() — an EIP-712 payload cannot be recovered from its hash.
  • Every call attaches customTags. The boilerplate uses slot 0 to 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.