Skip to main content

Building Custom Wallet Adapters

This page’s content is being updated
This guide covers creating custom wallet adapters for wallets not supported by the built-in adapters.

WalletAdapter Interface

All wallet adapters must implement this interface:
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

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

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

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

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

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

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

  1. Validate connection state before operations
  2. Handle user rejections gracefully
  3. Normalize chain IDs (some wallets return hex, some decimal)
  4. Cache address and chainId to avoid repeated requests
  5. Implement proper cleanup in disconnect
  6. Add timeout handling for hardware wallets
  7. Test with real wallets before deploying

Next Steps