Building Custom Wallet Adapters
This page’s content is being updated
WalletAdapter Interface
All wallet adapters must implement this interface:Copy
interface WalletAdapter {
connect(): Promise<{ address: string; chainId: number }>;
disconnect(): Promise<void>;
signMessage(message: string): Promise<string>;
signTypedData(typedData: string): Promise<string>;
sendTransaction(tx: TransactionRequest): Promise<string>;
getAddress(): Promise<string>;
getChainId(): Promise<number>;
}
interface TransactionRequest {
to: string;
value?: string;
data?: string;
gasLimit?: string;
maxFeePerGas?: string;
maxPriorityFeePerGas?: string;
}
Basic Implementation
Copy
import { WalletAdapter, TransactionRequest } from '@testinprod-io/privacy-boost';
class MyCustomWalletAdapter implements WalletAdapter {
private wallet: MyWalletSDK;
private _address: string | null = null;
private _chainId: number | null = null;
constructor(wallet: MyWalletSDK) {
this.wallet = wallet;
}
async connect(): Promise<{ address: string; chainId: number }> {
const result = await this.wallet.requestAccess();
this._address = result.account;
this._chainId = result.network;
return {
address: this._address,
chainId: this._chainId,
};
}
async disconnect(): Promise<void> {
await this.wallet.disconnect();
this._address = null;
this._chainId = null;
}
async signMessage(message: string): Promise<string> {
if (!this._address) {
throw new Error('Wallet not connected');
}
return await this.wallet.personalSign(message, this._address);
}
async signTypedData(typedData: string): Promise<string> {
if (!this._address) {
throw new Error('Wallet not connected');
}
const data = JSON.parse(typedData);
return await this.wallet.signTypedDataV4(this._address, data);
}
async sendTransaction(tx: TransactionRequest): Promise<string> {
if (!this._address) {
throw new Error('Wallet not connected');
}
const txHash = await this.wallet.sendTransaction({
from: this._address,
to: tx.to,
value: tx.value || '0x0',
data: tx.data || '0x',
gas: tx.gasLimit,
maxFeePerGas: tx.maxFeePerGas,
maxPriorityFeePerGas: tx.maxPriorityFeePerGas,
});
return txHash;
}
async getAddress(): Promise<string> {
if (!this._address) {
throw new Error('Wallet not connected');
}
return this._address;
}
async getChainId(): Promise<number> {
if (!this._chainId) {
throw new Error('Wallet not connected');
}
return this._chainId;
}
}
Example: Frame Wallet Adapter
Copy
import { WalletAdapter, TransactionRequest } from '@testinprod-io/privacy-boost';
import { FrameSDK } from '@frame/sdk';
class FrameWalletAdapter implements WalletAdapter {
private frame: FrameSDK;
private address: string | null = null;
private chainId: number | null = null;
constructor() {
this.frame = new FrameSDK();
}
async connect(): Promise<{ address: string; chainId: number }> {
const accounts = await this.frame.request({
method: 'eth_requestAccounts',
});
const chainIdHex = await this.frame.request({
method: 'eth_chainId',
});
this.address = accounts[0];
this.chainId = parseInt(chainIdHex, 16);
return {
address: this.address,
chainId: this.chainId,
};
}
async disconnect(): Promise<void> {
// Frame doesn't have disconnect, just clear state
this.address = null;
this.chainId = null;
}
async signMessage(message: string): Promise<string> {
return await this.frame.request({
method: 'personal_sign',
params: [message, this.address],
});
}
async signTypedData(typedData: string): Promise<string> {
return await this.frame.request({
method: 'eth_signTypedData_v4',
params: [this.address, typedData],
});
}
async sendTransaction(tx: TransactionRequest): Promise<string> {
return await this.frame.request({
method: 'eth_sendTransaction',
params: [{
from: this.address,
to: tx.to,
value: tx.value,
data: tx.data,
gas: tx.gasLimit,
}],
});
}
async getAddress(): Promise<string> {
if (!this.address) throw new Error('Not connected');
return this.address;
}
async getChainId(): Promise<number> {
if (!this.chainId) throw new Error('Not connected');
return this.chainId;
}
}
Example: Hardware Wallet Adapter
Copy
import { WalletAdapter, TransactionRequest } from '@testinprod-io/privacy-boost';
import { LedgerConnector } from '@ledgerhq/wallet-adapter';
import { ethers } from 'ethers';
class LedgerWalletAdapter implements WalletAdapter {
private ledger: LedgerConnector;
private provider: ethers.JsonRpcProvider;
private address: string | null = null;
private chainId: number;
constructor(rpcUrl: string, chainId: number) {
this.ledger = new LedgerConnector();
this.provider = new ethers.JsonRpcProvider(rpcUrl);
this.chainId = chainId;
}
async connect(): Promise<{ address: string; chainId: number }> {
await this.ledger.connect();
this.address = await this.ledger.getAddress();
return {
address: this.address,
chainId: this.chainId,
};
}
async disconnect(): Promise<void> {
await this.ledger.disconnect();
this.address = null;
}
async signMessage(message: string): Promise<string> {
return await this.ledger.signPersonalMessage(message);
}
async signTypedData(typedData: string): Promise<string> {
const data = JSON.parse(typedData);
return await this.ledger.signTypedData(data);
}
async sendTransaction(tx: TransactionRequest): Promise<string> {
// Build transaction
const nonce = await this.provider.getTransactionCount(this.address!);
const feeData = await this.provider.getFeeData();
const transaction = {
to: tx.to,
value: tx.value || '0',
data: tx.data || '0x',
nonce,
gasLimit: tx.gasLimit || '21000',
maxFeePerGas: tx.maxFeePerGas || feeData.maxFeePerGas,
maxPriorityFeePerGas: tx.maxPriorityFeePerGas || feeData.maxPriorityFeePerGas,
chainId: this.chainId,
type: 2,
};
// Sign with Ledger
const signedTx = await this.ledger.signTransaction(transaction);
// Broadcast
const response = await this.provider.broadcastTransaction(signedTx);
return response.hash;
}
async getAddress(): Promise<string> {
if (!this.address) throw new Error('Not connected');
return this.address;
}
async getChainId(): Promise<number> {
return this.chainId;
}
}
Example: Multi-Wallet Adapter
Copy
import { WalletAdapter, TransactionRequest } from '@testinprod-io/privacy-boost';
class MultiWalletAdapter implements WalletAdapter {
private activeAdapter: WalletAdapter | null = null;
private adapters: Map<string, WalletAdapter>;
constructor(adapters: Record<string, WalletAdapter>) {
this.adapters = new Map(Object.entries(adapters));
}
setActiveWallet(name: string) {
const adapter = this.adapters.get(name);
if (!adapter) throw new Error(`Unknown wallet: ${name}`);
this.activeAdapter = adapter;
}
private getActive(): WalletAdapter {
if (!this.activeAdapter) throw new Error('No wallet selected');
return this.activeAdapter;
}
async connect(): Promise<{ address: string; chainId: number }> {
return this.getActive().connect();
}
async disconnect(): Promise<void> {
return this.getActive().disconnect();
}
async signMessage(message: string): Promise<string> {
return this.getActive().signMessage(message);
}
async signTypedData(typedData: string): Promise<string> {
return this.getActive().signTypedData(typedData);
}
async sendTransaction(tx: TransactionRequest): Promise<string> {
return this.getActive().sendTransaction(tx);
}
async getAddress(): Promise<string> {
return this.getActive().getAddress();
}
async getChainId(): Promise<number> {
return this.getActive().getChainId();
}
}
// Usage
// Note: WalletConnectAdapter is not directly exported. Access it via createWalletRegistry()
// or pass any adapter that implements the WalletAdapter interface.
const registry = createWalletRegistry();
const wcAdapter = registry.get('walletconnect')!.create({ projectId: '...' });
const multiAdapter = new MultiWalletAdapter({
metamask: new Eip1193WalletAdapter(window.ethereum),
ledger: new LedgerWalletAdapter(rpcUrl, 1),
walletconnect: wcAdapter,
});
multiAdapter.setActiveWallet('ledger');
await sdk.auth.connect(multiAdapter);
Testing Your Adapter
Copy
describe('CustomWalletAdapter', () => {
let adapter: MyCustomWalletAdapter;
beforeEach(() => {
adapter = new MyCustomWalletAdapter(mockWallet);
});
it('should connect and return address', async () => {
const result = await adapter.connect();
expect(result.address).toMatch(/^0x[a-fA-F0-9]{40}$/);
expect(result.chainId).toBeGreaterThan(0);
});
it('should sign messages', async () => {
await adapter.connect();
const signature = await adapter.signMessage('Hello, World!');
expect(signature).toMatch(/^0x[a-fA-F0-9]+$/);
});
it('should sign typed data', async () => {
await adapter.connect();
const typedData = JSON.stringify({
types: { /* ... */ },
primaryType: 'Message',
domain: { /* ... */ },
message: { /* ... */ },
});
const signature = await adapter.signTypedData(typedData);
expect(signature).toMatch(/^0x[a-fA-F0-9]+$/);
});
it('should send transactions', async () => {
await adapter.connect();
const txHash = await adapter.sendTransaction({
to: '0x...',
value: '0x0',
});
expect(txHash).toMatch(/^0x[a-fA-F0-9]{64}$/);
});
});
Error Handling
Copy
class MyCustomWalletAdapter implements WalletAdapter {
async signMessage(message: string): Promise<string> {
try {
return await this.wallet.sign(message);
} catch (error) {
if (error.code === 4001) {
throw new SDKError('WALLET_USER_REJECTED', 'User rejected the signature request');
}
throw new SDKError('WALLET_SIGNATURE_FAILED', error.message);
}
}
}
Best Practices
- Validate connection state before operations
- Handle user rejections gracefully
- Normalize chain IDs (some wallets return hex, some decimal)
- Cache address and chainId to avoid repeated requests
- Implement proper cleanup in disconnect
- Add timeout handling for hardware wallets
- Test with real wallets before deploying