Skip to main content

Signer Interface

Learn how to integrate hardware wallets, browser extensions, and other signing mechanisms using the pluggable Signer interface.

Overview​

The Signer interface allows you to use different signing mechanisms beyond simple key pairs:

  • Hardware Wallets - Ledger, Trezor, etc.
  • Browser Extensions - MetaMask-style wallets
  • HSM - Hardware Security Modules for enterprise
  • Mobile Wallets - iOS/Android wallet apps
  • Remote Signers - Cloud-based signing services

Signer Interface​

interface Signer {
// Get the public key (synchronous, cached)
getPublicKey(): Uint8Array;

// Sign a message (asynchronous, may require user interaction)
sign(message: Uint8Array): Promise<Uint8Array>;

// Sign for a specific public key (for multi-key wallets)
signFor(publicKey: string, message: Uint8Array): Promise<Uint8Array>;
}

Built-in Signers​

KeyPairSigner​

The default signer that wraps a KeyPair:

import { KeyPair, KeyPairSigner } from '@convex-world/convex-ts';

const keyPair = KeyPair.fromSeed(seed);
const signer = new KeyPairSigner(keyPair);

// Use with Convex client
convex.setSigner(signer);
convex.setAddress('#1678');

// Or use KeyPair directly (automatically wrapped)
convex.setAccount('#1678', keyPair);

Custom Signer Implementation​

Basic Custom Signer​

import { Signer } from '@convex-world/convex-ts';

class CustomSigner implements Signer {
private publicKey: Uint8Array;

constructor(publicKey: Uint8Array) {
this.publicKey = publicKey;
}

getPublicKey(): Uint8Array {
return this.publicKey;
}

async sign(message: Uint8Array): Promise<Uint8Array> {
// Implement your signing logic
const signature = await yourSigningFunction(message);
return signature;
}

async signFor(publicKey: string, message: Uint8Array): Promise<Uint8Array> {
// Verify the public key matches
const ourPubKeyHex = Buffer.from(this.publicKey).toString('hex');
if (publicKey !== ourPubKeyHex) {
throw new Error('Public key mismatch');
}
return this.sign(message);
}
}

// Use it
const signer = new CustomSigner(publicKeyBytes);
convex.setSigner(signer);
convex.setAddress('#1678');

Hardware Wallet Signer​

Example for a hardware wallet that requires user confirmation:

class HardwareWalletSigner implements Signer {
private wallet: HardwareWalletDevice;
private publicKey: Uint8Array;

constructor(wallet: HardwareWalletDevice) {
this.wallet = wallet;
// Get and cache public key
this.publicKey = wallet.getPublicKeySync();
}

getPublicKey(): Uint8Array {
// Return cached value (synchronous)
return this.publicKey;
}

async sign(message: Uint8Array): Promise<Uint8Array> {
// This will prompt user on hardware device
console.log('Please confirm transaction on your hardware wallet...');

try {
const signature = await this.wallet.signMessage(message);
return signature;
} catch (error) {
if (error.code === 'USER_REJECTED') {
throw new Error('Transaction rejected by user');
}
throw error;
}
}

async signFor(publicKey: string, message: Uint8Array): Promise<Uint8Array> {
const ourPubKeyHex = Buffer.from(this.publicKey).toString('hex');
if (publicKey !== ourPubKeyHex) {
throw new Error(`This hardware wallet does not control ${publicKey}`);
}
return this.sign(message);
}
}

// Usage
const wallet = await HardwareWalletDevice.connect();
const signer = new HardwareWalletSigner(wallet);

convex.setSigner(signer);
convex.setAddress('#1678');

// User will be prompted on device
await convex.transfer('#456', 1_000_000_000);

Browser Extension Signer​

Example for a browser extension wallet:

class ExtensionWalletSigner implements Signer {
private publicKey: Uint8Array;
private extension: any;

constructor(extension: any, publicKey: Uint8Array) {
this.extension = extension;
this.publicKey = publicKey;
}

static async connect(): Promise<ExtensionWalletSigner> {
// Connect to browser extension
if (!(window as any).convexWallet) {
throw new Error('Convex wallet extension not installed');
}

const extension = (window as any).convexWallet;
await extension.connect();

const accounts = await extension.getAccounts();
if (accounts.length === 0) {
throw new Error('No accounts found in wallet');
}

const publicKey = Buffer.from(accounts[0].publicKey, 'hex');
return new ExtensionWalletSigner(extension, publicKey);
}

getPublicKey(): Uint8Array {
return this.publicKey;
}

async sign(message: Uint8Array): Promise<Uint8Array> {
// Extension shows popup for user approval
const messageHex = Buffer.from(message).toString('hex');
const signatureHex = await this.extension.signMessage(messageHex);
return Buffer.from(signatureHex, 'hex');
}

async signFor(publicKey: string, message: Uint8Array): Promise<Uint8Array> {
const ourPubKeyHex = Buffer.from(this.publicKey).toString('hex');
if (publicKey !== ourPubKeyHex) {
throw new Error('Public key mismatch');
}
return this.sign(message);
}
}

// Usage in browser
const signer = await ExtensionWalletSigner.connect();
convex.setSigner(signer);

// Get address from wallet
const address = await window.convexWallet.getAddress();
convex.setAddress(address);

// User approves in extension popup
await convex.transfer('#456', 1_000_000_000);

Multi-Key Wallets​

Some wallets manage multiple keys. Use signFor() to specify which key to use:

class MultiKeyWalletSigner implements Signer {
private keys: Map<string, Uint8Array>;
private defaultKey: Uint8Array;

constructor(keys: Array<{ publicKey: Uint8Array; privateKey: Uint8Array }>) {
this.keys = new Map();
for (const key of keys) {
const pubKeyHex = Buffer.from(key.publicKey).toString('hex');
this.keys.set(pubKeyHex, key.privateKey);
}
this.defaultKey = keys[0].publicKey;
}

getPublicKey(): Uint8Array {
return this.defaultKey;
}

async sign(message: Uint8Array): Promise<Uint8Array> {
const defaultKeyHex = Buffer.from(this.defaultKey).toString('hex');
return this.signFor(defaultKeyHex, message);
}

async signFor(publicKey: string, message: Uint8Array): Promise<Uint8Array> {
const privateKey = this.keys.get(publicKey);
if (!privateKey) {
throw new Error(`No private key for ${publicKey}`);
}

// Sign with Ed25519
const signature = await sign(message, privateKey);
return signature;
}
}

// Usage with multiple addresses
const signer = new MultiKeyWalletSigner([
{ publicKey: pubKey1, privateKey: privKey1 },
{ publicKey: pubKey2, privateKey: privKey2 }
]);

convex.setSigner(signer);

// Use first address
convex.setAddress('#1678');
await convex.transfer('#456', 1_000_000_000);

// Switch to second address (same signer)
convex.setAddress('#9999');
await convex.transfer('#789', 2_000_000);

Remote Signing Service​

Example for cloud-based signing:

class RemoteSignerService implements Signer {
private apiUrl: string;
private apiKey: string;
private publicKey: Uint8Array;

constructor(apiUrl: string, apiKey: string, publicKey: Uint8Array) {
this.apiUrl = apiUrl;
this.apiKey = apiKey;
this.publicKey = publicKey;
}

getPublicKey(): Uint8Array {
return this.publicKey;
}

async sign(message: Uint8Array): Promise<Uint8Array> {
const messageHex = Buffer.from(message).toString('hex');

const response = await fetch(`${this.apiUrl}/sign`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: messageHex,
publicKey: Buffer.from(this.publicKey).toString('hex')
})
});

if (!response.ok) {
throw new Error(`Signing failed: ${response.statusText}`);
}

const data = await response.json();
return Buffer.from(data.signature, 'hex');
}

async signFor(publicKey: string, message: Uint8Array): Promise<Uint8Array> {
const ourPubKeyHex = Buffer.from(this.publicKey).toString('hex');
if (publicKey !== ourPubKeyHex) {
throw new Error('Public key mismatch');
}
return this.sign(message);
}
}

// Usage
const signer = new RemoteSignerService(
'https://signing.example.com',
process.env.API_KEY!,
publicKeyBytes
);

convex.setSigner(signer);
convex.setAddress('#1678');

Testing Signers​

Mock Signer for Tests​

class MockSigner implements Signer {
private publicKey: Uint8Array;
private shouldFail: boolean;

constructor(shouldFail = false) {
// Generate random public key
this.publicKey = crypto.getRandomValues(new Uint8Array(32));
this.shouldFail = shouldFail;
}

getPublicKey(): Uint8Array {
return this.publicKey;
}

async sign(message: Uint8Array): Promise<Uint8Array> {
if (this.shouldFail) {
throw new Error('Mock signing failure');
}
// Return mock signature
return crypto.getRandomValues(new Uint8Array(64));
}

async signFor(publicKey: string, message: Uint8Array): Promise<Uint8Array> {
return this.sign(message);
}
}

// Use in tests
describe('Transaction tests', () => {
it('should handle signing failures', async () => {
const signer = new MockSigner(true);
convex.setSigner(signer);

await expect(
convex.transfer('#456', 1_000_000_000)
).rejects.toThrow('Mock signing failure');
});
});

Error Handling​

User Rejection​

try {
await convex.transfer('#456', 1_000_000_000);
} catch (error) {
if (error.message.includes('rejected')) {
console.log('User cancelled the transaction');
} else if (error.message.includes('timeout')) {
console.log('Signing timed out');
} else {
console.error('Signing error:', error);
}
}

Timeout Protection​

class TimeoutSigner implements Signer {
private inner: Signer;
private timeout: number;

constructor(inner: Signer, timeout = 30000) {
this.inner = inner;
this.timeout = timeout;
}

getPublicKey(): Uint8Array {
return this.inner.getPublicKey();
}

async sign(message: Uint8Array): Promise<Uint8Array> {
return Promise.race([
this.inner.sign(message),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Signing timeout')), this.timeout)
)
]);
}

async signFor(publicKey: string, message: Uint8Array): Promise<Uint8Array> {
return this.inner.signFor(publicKey, message);
}
}

// Usage
const hardwareSigner = new HardwareWalletSigner(wallet);
const timeoutSigner = new TimeoutSigner(hardwareSigner, 60000); // 60s timeout

convex.setSigner(timeoutSigner);

Best Practices​

✅ Do​

  • Cache public keys (they don't change)
  • Make signing operations async
  • Provide clear user feedback during signing
  • Implement proper error handling
  • Validate public key matches before signing
  • Use timeouts for user interactions

❌ Don't​

  • Block the main thread during signing
  • Assume signing always succeeds
  • Ignore user rejection
  • Store private keys in custom signers (use secure storage)
  • Skip signature validation

Next Steps​

See Also​