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

TypeScript
src/mywallet/controller.ts
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.

TypeScript
src/mywallet/interface.ts
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.

TypeScript
src/wallets/controller.ts
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.

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

src/wallets/types.ts

Add the type guards for MyWallet.

TypeScript
src/wallets/types.ts
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;
}