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/web3.js
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/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/solana.ts — Solana address from EdDSA keyshare
export const computeSolanaAddress = (keyshare: Keyshare): string => {
const publicKeyBuffer = Buffer.from(keyshare.publicKeyHex, 'hex');
return new solana.PublicKey(publicKeyBuffer).toBase58();
};
EVM signing
libs/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/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/solana.ts provides SilentShardSolanaSigner, a class that wraps the EdDSA session and implements the Solana signing interface:
// libs/solana.ts
export class SilentShardSolanaSigner {
constructor(
private readonly session: EddsaSession,
private readonly connection: Connection,
private readonly keyshare: Keyshare,
) {
this.address = computeSolanaAddress(keyshare);
this.sender = new solana.PublicKey(bs58.decode(this.address));
}
async signBytes(bytes: Buffer) {
const signatureRes = await this.session.sign({
keyshare: this.keyshare,
messageHash: bytes.toString('hex'),
});
return Buffer.from(signatureRes, 'hex');
}
async signVersionedTransaction(transaction: solana.VersionedTransaction) {
const messageBytes = transaction.message.serialize();
const sigBytes = await this.signBytes(Buffer.from(messageBytes));
transaction.addSignature(this.sender, sigBytes);
return transaction;
}
async sendTransaction(transaction, options) {
const signedTransaction = await this.signTransaction(transaction);
return this.connection.sendRawTransaction(signedTransaction.serialize(), options);
}
}
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.