Skip to main content

Multi-Chain Signing

The boilerplate supports two blockchain ecosystems from a single set of MPC keyshares:

  • EVM chains (Ethereum, Base, etc.) — via the ECDSA session and viem
  • Solana — via the EdDSA session and @solana/kit

In both cases, the full private key is never reconstructed. The MPC protocol between the device and duo-server produces the signature directly.

Address derivation

Wallet addresses are derived from the public key in each keyshare immediately after keygen:

// 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));
};

// 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));
};

EVM signing

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.

// 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 hashedMessage = hashMessage(message);
const signature = await session.sign({ messageHash: normalizeMessage(hashedMessage), keyshare });
return normalizeSignature(signature);
},

async signTransaction(transaction, options): Promise<Hex> {
const txSerializer = options?.serializer ?? serializeTransaction;
const serializedTx = await txSerializer(transaction);
const hashedTx = keccak256(serializedTx);
const signature = await session.sign({ messageHash: normalizeMessage(hashedTx), keyshare });
const parsed = parseSignature(`0x${signature}`);
return txSerializer(transaction, parsed);
},

async signTypedData(parameters): Promise<Hex> {
const signature = await session.sign({ keyshare, messageHash: normalizeMessage(hashTypedData(parameters)) });
return normalizeSignature(signature);
},
});
}

The viem wallet client is created in 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

libs/chains/solana.ts provides createMpcSolanaSigner, a helper that wraps the EdDSA session and returns the @solana/kit transaction and message signer interfaces:

// 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'),
});
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'),
});
const sig = signatureBytes(new Uint8Array(Buffer.from(sigHex, 'hex')));
return Object.freeze({ [signerAddress]: sig });
}),
);
},
};
}

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.