Full Example

The following example provides a complete wallet implementation structure to use as a starting point. This code assumes MyWallet extends the Unisat wallet.

src/mywallet/controller.ts

src/mywallet/controller.tsTypeScript
import { networks } from '@btc-vision/bitcoin';
import { type UnisatChainInfo, UnisatChainType } from '@btc-vision/transaction';
import { AbstractRpcProvider, JSONRpcProvider } from 'opnet';
import { type WalletChainType, type WalletBase } from '../types';
import { type MyWallet } from './interface';

interface MyWalletWindow extends Window {
    mywallet?: MyWallet;
}

const notInstalledError = 'MY_WALLET is not installed';

class MyWalletInstance implements WalletBase {
    private walletBase: MyWalletWindow['mywallet'];
    private accountsChangedHookWrapper?: (accounts: Array<string>) => void;
    private chainChangedHookWrapper?: (network: UnisatChainInfo) => void;
    private disconnectHookWrapper?: () => void;
    private _isConnected: boolean = false;

    isInstalled() {
        // Check that we are in a browser environment
        if (typeof window === 'undefined') {
            return false;
        }
        this.walletBase = (window as unknown as MyWalletWindow).mywallet;
        return !!this.walletBase;
    }

    isConnected() {
        return !!this.walletBase && this._isConnected;
    }

    async canAutoConnect(): Promise<boolean> {
        // Check that we can connect to MyWallet
        // without showing the login window to the user
        return this.walletBase?.canAutoConnect() || false
    }

    getChainId(): void {
        throw new Error('Method not implemented.');
    }

    async connect(): Promise<string[]> {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }

        // Show login window (if needed)
        // Retrieve the logged in account(s)
        // set _isConnected to true
        // return the accounts
        const accounts: string[] = await this.walletBase.loginAndRetrieveAccounts()
        this._isConnected = true;
        return accounts;
    }

    async disconnect() {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }
        // If connected, disconnect
        // set _isConnected to false
        if (this._isConnected) this.walletBase.disconnect();
    }

    getWalletInstance(): MyWallet | null {
        // Return the current wallet instance available
        // in the global window object.  Ensure to return
        // complete interface (interface MyWallet in this case)
        return (this._isConnected && this.walletBase) || null;
    }

    public async getProvider(): Promise<AbstractRpcProvider | null> {
        if (!this._isConnected || !this.walletBase) return null;

        // Up to now, this function is the same for all Wallet
        // supporting AbstractRpcProvider.
        // You will need to update the way to retrieve the chain below
        const chain = await this.walletBase.getCurrentChain();
        const unisatChain = this.convertChainToUnisat(chain);
        switch (unisatChain) {
            case UnisatChainType.BITCOIN_MAINNET:
                return new JSONRpcProvider('https://mainnet.opnet.org', networks.bitcoin);
            case UnisatChainType.BITCOIN_TESTNET:
                return new JSONRpcProvider('https://testnet.opnet.org', networks.testnet);
            case UnisatChainType.BITCOIN_REGTEST:
                return new JSONRpcProvider('https://regtest.opnet.org', networks.regtest);
            // TODO: Add Fractal Mainnet & Testnet when available
            default:
                return null;
        }
    }

    async getSigner(): Promise<null> {
        throw new Error('Method reserved for future use.');
        return Promise.resolve(null);
    }

    async getPublicKey(): Promise<string> {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }
        // Return the string version of the current public key
        return this.walletBase.getPublicKey();
    }

    async getBalance(): Promise<WalletBalance | null> {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }
        return (await this.walletBase.getBalance()) as WalletBalance | null;
    }

    unisatChainToWalletNetwork = (chainType: UnisatChainType): WalletChainType => {
        switch (chainType) {
            case UnisatChainType.BITCOIN_MAINNET: return WalletChainType.BITCOIN_MAINNET;
            case UnisatChainType.BITCOIN_TESTNET: return WalletChainType.BITCOIN_TESTNET;
            case UnisatChainType.BITCOIN_REGTEST: return WalletChainType.BITCOIN_REGTEST;
            case UnisatChainType.BITCOIN_TESTNET4: return WalletChainType.BITCOIN_TESTNET4;
            case UnisatChainType.FRACTAL_BITCOIN_MAINNET: return WalletChainType.FRACTAL_BITCOIN_MAINNET;
            case UnisatChainType.FRACTAL_BITCOIN_TESTNET: return WalletChainType.FRACTAL_BITCOIN_TESTNET;
            case UnisatChainType.BITCOIN_SIGNET: return WalletChainType.BITCOIN_SIGNET;
            default: return WalletChainType.BITCOIN_REGTEST;
        }
    }

    async getNetwork(): Promise<WalletChainType> {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }
        const chainInfo = await this.walletBase.getChain();
        if (!chainInfo) {
            throw new Error('Failed to retrieve chain information');
        }

        return this.unisatChainToWalletNetwork(chainInfo.enum);
    }

    setAccountsChangedHook(fn: (accounts: string[]) => void): void {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }

        // Keep all needed information to be able to remove
        // this hook later.  It may be the hook function,
        // or it may be returned by the 'on', 'addListener' of
        // similar function.
        this.accountsChangedHookWrapper = (accounts: string[]) => {
            fn(accounts);
        };

        // Connect the above account changed hooks to the wallet
        this.walletBase.on('accountsChanged', this.accountsChangedHookWrapper);
    }

    removeAccountsChangedHook(): void {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }

        // Check that we have a hook connected to the wallet
        // if so, disconnect it and unset the related variables.
        if (this.accountsChangedHookWrapper) {
            this.walletBase.off('accountsChanged', this.accountsChangedHookWrapper);
            this.accountsChangedHookWrapper = undefined;
        }
    }

    setDisconnectHook(fn: () => void): void {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }

        this.disconnectHookWrapper = () => {
            fn();
        };

        this.walletBase.on('disconnect', this.disconnectHookWrapper);
    }

    removeDisconnectHook(): void {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }

        if (this.disconnectHookWrapper) {
            this.walletBase.removeListener('disconnect', this.disconnectHookWrapper);
            this.disconnectHookWrapper = undefined;
        }
    }

    setChainChangedHook(fn: (chainType: WalletChainType) => void): void {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }

        this.chainChangedHookWrapper = (chainInfo: UnisatChainInfo) => {
            fn(this.unisatChainToWalletNetwork(chainInfo.enum));
        };

        this.walletBase.on('chainChanged', this.chainChangedHookWrapper);
    }

    removeChainChangedHook(): void {
        if (!this.isInstalled() || !this.walletBase) {
            throw new Error(notInstalledError);
        }
        if (this.chainChangedHookWrapper) {
            this.walletBase.removeListener('chainChanged', this.chainChangedHookWrapper);
            this.chainChangedHookWrapper = undefined;
        }
    }

    async getMLDSAPublicKey(): Promise<string | null> {
        if (!this._isConnected || !this.walletBase?.web3) return null;

        return this.walletBase.web3.getMLDSAPublicKey();
    }

    async getHashedMLDSAKey(): Promise<string | null> {
        const mldsaPublicKey = await this.getMLDSAPublicKey();
        if (!mldsaPublicKey) return null;

        const publicKeyBuffer = Buffer.from(mldsaPublicKey, 'hex');
        const hash = MessageSigner.sha256(publicKeyBuffer);
        return hash.toString('hex');
    }

    unisatChainToWalletNetwork = (chainType: UnisatChainType): WalletChainType => {
        switch (chainType) {
            case UnisatChainType.BITCOIN_MAINNET: return WalletChainType.BITCOIN_MAINNET;
            case UnisatChainType.BITCOIN_TESTNET: return WalletChainType.BITCOIN_TESTNET;
            case UnisatChainType.BITCOIN_REGTEST: return WalletChainType.BITCOIN_REGTEST;
            case UnisatChainType.BITCOIN_TESTNET4: return WalletChainType.BITCOIN_TESTNET4;
            case UnisatChainType.FRACTAL_BITCOIN_MAINNET: return WalletChainType.FRACTAL_BITCOIN_MAINNET;
            case UnisatChainType.FRACTAL_BITCOIN_TESTNET: return WalletChainType.FRACTAL_BITCOIN_TESTNET;
            case UnisatChainType.BITCOIN_SIGNET: return WalletChainType.BITCOIN_SIGNET;
            default: return WalletChainType.BITCOIN_REGTEST;
        }
    }

    walletNetworkToUnisatChain = (network: WalletNetwork|WalletChainType): UnisatChainType => {
        switch (network) {
            case WalletNetwork.mainnet:
            case WalletChainType.BITCOIN_MAINNET: return UnisatChainType.BITCOIN_MAINNET;
            case WalletNetwork.testnet:
            case WalletChainType.BITCOIN_TESTNET: return UnisatChainType.BITCOIN_TESTNET;
            case WalletNetwork.regtest:
            case WalletChainType.BITCOIN_REGTEST: return UnisatChainType.BITCOIN_REGTEST;
            case WalletChainType.BITCOIN_TESTNET4: return UnisatChainType.BITCOIN_TESTNET4;
            case WalletChainType.FRACTAL_BITCOIN_MAINNET: return UnisatChainType.FRACTAL_BITCOIN_MAINNET;
            case WalletChainType.FRACTAL_BITCOIN_TESTNET: return UnisatChainType.FRACTAL_BITCOIN_TESTNET;
            case WalletChainType.BITCOIN_SIGNET: return UnisatChainType.BITCOIN_SIGNET;
            default: return UnisatChainType.BITCOIN_REGTEST;
        }
    }

    async switchNetwork(network: WalletNetwork|WalletChainType): Promise<void> {
        if (!this._isConnected || !this.walletBase) return;

        const unisatChainType = this.walletNetworkToUnisatChain(network)
        await this.walletBase.switchChain(unisatChainType);
    }

    async signMessage(message: string, messageType?: MessageType): Promise<string | null> {
        if (!this._isConnected || !this.walletBase) return null;

        return this.walletBase.signMessage(message, messageType);
    }

    async signMLDSAMessage(message: string): Promise<MLDSASignature | null> {
        if (!this._isConnected || !this.walletBase?.web3) return null;

        return this.walletBase.web3.signMLDSAMessage(message);
    }

    async verifyMLDSASignature(message: string, signature: MLDSASignature): Promise<boolean> {
        if (!this._isConnected || !this.walletBase?.web3) return false;

        return this.walletBase.web3.verifyMLDSASignature(message, signature);
    }
}

export default MyWalletInstance;

src/mywallet/interface.ts

Define an interface that matches the wallet's implementation. Method names, parameters, and return types must correspond to those exposed by the wallet extension.

The example below demonstrates the interface structure compatible with a Unisat based wallet:
src/mywallet/interface.tsTypeScript
export interface MyWallet {
    disconnect: () => Promise<void>;
    connect: () => void;
    myWalletVersion: number;

    canAutoConnect: () => boolean;
    loginAndRetrieveAccounts(): Promise<string[]>;
    switchNetwork(network: string): Promise<void>;

    getPublicKey(): Promise<string>;
    getCurrentChain(): Promise<string>;
    getAccounts(): Promise<string[]>;
    getBalance(): Promise<number>;
    //!!!! Add more functions here???
    on(event: 'accountsChanged', listener: (accounts: string[]) => void): void;
    on(event: 'chainChanged', listener: (chain: string) => void): void;
    on(event: 'disconnect', listener: () => void): void;

    off(event: string, listener: (accounts: string[]) => void): void;
    off(event: 'chainChanged', listener: (chain: string) => void): void;
    off(event: 'disconnect', listener: () => void): void;
}

export const logo =
    'data:image/svg+xml;base64,' +
    'PHN2ZyB3aWR0aD0iNDgxIiBoZWlnaHQ9IjExMiIgdmlld0JveD0iMCAwIDQ4MSAxMTIiIGZpbGw9' +
    // ....... A lots of lines here .......
    // Try to keep the image small,
    // between 32x32 and 128x128 pixels.
    'ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=';

src/wallets/controller.ts

Add MyWallet to the return type of getWalletInstance(). Add the necessary imports as well.

src/wallets/controller.tsTypeScript
import type { MyWallet } from './mywallet/interface';
...
static getWalletInstance(): OPWallet | MyWallet | null {
...
}
...
}

src/wallets/types.ts

Add your new wallet interface to the getWalletInstance() signature in the src/wallets/types.ts file.

src/wallets/types.tsTypeScript
import type { MyWallet } from './mywallet/interface';
...
getWalletInstance(): OPWallet | MyWallet | null;
...

src/wallets/types.ts

Add the type guards for MyWallet.

src/wallets/types.tsTypeScript
import { type MyWallet } from './wallets/mywallet/interface';
import { type OPWallet } from '@btc-vision/transaction';

...

export function isMyWallet(walletInstance: OPWallet|MyWallet|null): walletInstance is MyWallet {
    return typeof walletInstance == 'object' && (walletInstance as MyWallet)?.myWalletVersion !== undefined;
}