diff --git a/packages/use-wallet/src/__tests__/wallets/algovoi.test.ts b/packages/use-wallet/src/__tests__/wallets/algovoi.test.ts new file mode 100644 index 000000000..7c21522b0 --- /dev/null +++ b/packages/use-wallet/src/__tests__/wallets/algovoi.test.ts @@ -0,0 +1,158 @@ +import { Store } from '@tanstack/store' +import { StorageAdapter } from 'src/storage' +import { LOCAL_STORAGE_KEY, State, DEFAULT_STATE } from 'src/store' +import { AlgoVoiWallet } from 'src/wallets/algovoi' +import { WalletId } from 'src/wallets/types' + +// Mock logger +vi.mock('src/logger', () => ({ + logger: { + createScopedLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }) + } +})) + +// Mock storage adapter +vi.mock('src/storage', () => ({ + StorageAdapter: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() + } +})) + +// Mock AlgoVoi provider +const mockProvider = { + id: 'algovou', + version: '0.1.0', + isAlgoVoi: true, + enable: vi.fn(), + disable: vi.fn(), + signTransactions: vi.fn() +} + +// Set up global window with AlgoVoi provider +Object.defineProperty(global, 'window', { + value: { + algorand: mockProvider, + setTimeout: global.setTimeout, + clearTimeout: global.clearTimeout, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }, + writable: true, + configurable: true +}) + +function createWalletWithStore(store: Store): AlgoVoiWallet { + return new AlgoVoiWallet({ + id: WalletId.ALGOVOI, + metadata: {}, + getAlgodClient: () => ({}) as any, + store, + subscribe: vi.fn() + }) +} + +describe('AlgoVoiWallet', () => { + let wallet: AlgoVoiWallet + let store: Store + let mockInitialState: State | null = null + + const account1 = { + name: 'AlgoVoi Account 1', + address: 'mockAddress1' + } + const account2 = { + name: 'AlgoVoi Account 2', + address: 'mockAddress2' + } + + beforeEach(() => { + vi.clearAllMocks() + + mockProvider.enable.mockReset() + mockProvider.disable.mockReset() + mockProvider.signTransactions.mockReset() + + vi.mocked(StorageAdapter.getItem).mockImplementation((key: string) => { + if (key === LOCAL_STORAGE_KEY && mockInitialState !== null) { + return JSON.stringify(mockInitialState) + } + return null + }) + + store = new Store(DEFAULT_STATE) + wallet = createWalletWithStore(store) + }) + + afterEach(() => { + mockInitialState = null + }) + + describe('connect', () => { + it('should connect successfully and return accounts', async () => { + mockProvider.enable.mockResolvedValue({ + accounts: [account1.address, account2.address] + }) + + const accounts = await wallet.connect() + + expect(mockProvider.enable).toHaveBeenCalled() + expect(accounts).toHaveLength(2) + expect(accounts[0].address).toBe(account1.address) + expect(accounts[1].address).toBe(account2.address) + }) + + it('should throw if no accounts are returned', async () => { + mockProvider.enable.mockResolvedValue({ accounts: [] }) + + await expect(wallet.connect()).rejects.toThrow('No accounts found!') + }) + }) + + describe('disconnect', () => { + it('should call provider.disable and clean up state', async () => { + mockProvider.disable.mockResolvedValue(undefined) + + // First connect + mockProvider.enable.mockResolvedValue({ accounts: [account1.address] }) + await wallet.connect() + + // Then disconnect + await wallet.disconnect() + + expect(mockProvider.disable).toHaveBeenCalled() + }) + }) + + describe('resumeSession', () => { + it('should do nothing if no session exists', async () => { + await wallet.resumeSession() + expect(mockProvider.enable).not.toHaveBeenCalled() + }) + + it('should re-enable if a session exists', async () => { + // First connect to create session state + mockProvider.enable.mockResolvedValue({ accounts: [account1.address] }) + await wallet.connect() + + // Resume + mockProvider.enable.mockResolvedValue({ accounts: [account1.address] }) + await wallet.resumeSession() + + expect(mockProvider.enable).toHaveBeenCalledTimes(2) + }) + }) + + describe('metadata', () => { + it('should have correct default metadata', () => { + expect(AlgoVoiWallet.defaultMetadata.name).toBe('AlgoVoi') + expect(AlgoVoiWallet.defaultMetadata.icon).toContain('data:image/svg+xml;base64,') + }) + }) +}) diff --git a/packages/use-wallet/src/utils.ts b/packages/use-wallet/src/utils.ts index 1e66888c1..9df277275 100644 --- a/packages/use-wallet/src/utils.ts +++ b/packages/use-wallet/src/utils.ts @@ -1,5 +1,6 @@ import algosdk from 'algosdk' import { WalletId, type JsonRpcRequest, type WalletAccount, type WalletMap } from './wallets/types' +import { AlgoVoiWallet } from './wallets/algovoi' import { BiatecWallet } from './wallets/biatec' import { CustomWallet } from './wallets/custom' import { DeflyWallet } from './wallets/defly' @@ -17,6 +18,7 @@ import { W3Wallet } from './wallets/w3wallet' export function createWalletMap(): WalletMap { return { + [WalletId.ALGOVOI]: AlgoVoiWallet, [WalletId.BIATEC]: BiatecWallet, [WalletId.CUSTOM]: CustomWallet, [WalletId.DEFLY]: DeflyWallet, diff --git a/packages/use-wallet/src/wallets/algovoi.ts b/packages/use-wallet/src/wallets/algovoi.ts new file mode 100644 index 000000000..33cfa56f5 --- /dev/null +++ b/packages/use-wallet/src/wallets/algovoi.ts @@ -0,0 +1,267 @@ +import algosdk from 'algosdk' +import { WalletState, addWallet, type State } from 'src/store' +import { byteArrayToBase64, flattenTxnGroup, isSignedTxn, isTransactionArray } from 'src/utils' +import { BaseWallet } from 'src/wallets/base' +import type { Store } from '@tanstack/store' +import { + WalletId, + type WalletAccount, + type WalletConstructor, + type WalletTransaction +} from 'src/wallets/types' + +/** + * AlgoVoi provider interface — matches the ARC-0027 compliant object + * injected at `window.algorand` by the AlgoVoi browser extension. + */ +interface AlgoVoiProvider { + id: string + version: string + isAlgoVoi: boolean + enable(options?: { genesisHash?: string }): Promise<{ accounts: string[] }> + disable(options?: { genesisHash?: string }): Promise + signTransactions(txns: WalletTransaction[], indexesToSign?: number[]): Promise<(string | null)[]> + signBytes?(data: Uint8Array, signer: string): Promise<{ sig: Uint8Array }> +} + +declare global { + interface Window { + algorand?: AlgoVoiProvider + } +} + +const ICON = `data:image/svg+xml;base64,${btoa(` + + + + + + + AV + +`)}` + +/** Timeout for enable/sign requests (3 minutes). */ +const REQUEST_TIMEOUT = 180_000 + +/** + * Wait for the AlgoVoi provider to be injected into the page. + * The extension fires `algorand#initialized` once the provider is ready. + */ +function waitForProvider(timeout: number): Promise { + return new Promise((resolve, reject) => { + // Already injected + if (window.algorand?.isAlgoVoi) { + return resolve(window.algorand) + } + + const timer = window.setTimeout(() => { + window.removeEventListener('algorand#initialized', handler) + reject(new Error('AlgoVoi extension not detected — timed out waiting for provider')) + }, timeout) + + function handler() { + window.clearTimeout(timer) + window.removeEventListener('algorand#initialized', handler) + if (window.algorand?.isAlgoVoi) { + resolve(window.algorand) + } else { + reject(new Error('algorand#initialized fired but AlgoVoi provider not found')) + } + } + + window.addEventListener('algorand#initialized', handler) + }) +} + +export class AlgoVoiWallet extends BaseWallet { + private provider: AlgoVoiProvider | null = null + + protected store: Store + + constructor({ + id, + store, + subscribe, + getAlgodClient, + metadata = {} + }: WalletConstructor) { + super({ id, metadata, getAlgodClient, store, subscribe }) + this.store = store + } + + static defaultMetadata = { + name: 'AlgoVoi', + icon: ICON + } + + private async getProvider(): Promise { + if (this.provider) return this.provider + this.logger.info('Waiting for AlgoVoi provider...') + this.provider = await waitForProvider(REQUEST_TIMEOUT) + this.logger.info('AlgoVoi provider detected') + return this.provider + } + + public connect = async (): Promise => { + this.logger.info('Connecting...') + const provider = await this.getProvider() + const result = await provider.enable() + + if (result.accounts.length === 0) { + this.logger.error('No accounts found!') + throw new Error('No accounts found!') + } + + const walletAccounts = result.accounts.map((address: string, idx: number) => ({ + name: `AlgoVoi Account ${idx + 1}`, + address + })) + + const walletState: WalletState = { + accounts: walletAccounts, + activeAccount: walletAccounts[0] + } + + addWallet(this.store, { + walletId: this.id, + wallet: walletState + }) + + this.logger.info('Connected successfully', walletState) + return walletAccounts + } + + public disconnect = async (): Promise => { + this.logger.info('Disconnecting...') + try { + const provider = await this.getProvider() + await provider.disable() + } catch { + // Extension may not be available — clean up local state regardless + } + this.onDisconnect() + this.logger.info('Disconnected') + } + + public resumeSession = async (): Promise => { + try { + const state = this.store.state + const walletState = state.wallets[this.id] + + if (!walletState) { + this.logger.info('No session to resume') + return + } + + this.logger.info('Resuming session...') + const provider = await this.getProvider() + const result = await provider.enable() + + if (result.accounts.length === 0) { + throw new Error('No accounts found!') + } + + this.logger.info('Session resumed successfully') + } catch (error: any) { + this.logger.error('Error resuming session:', error.message) + this.onDisconnect() + throw error + } + } + + private processTxns( + txnGroup: algosdk.Transaction[], + indexesToSign?: number[] + ): WalletTransaction[] { + const txnsToSign: WalletTransaction[] = [] + + txnGroup.forEach((txn, index) => { + const isIndexMatch = !indexesToSign || indexesToSign.includes(index) + const signer = txn.sender.toString() + const canSignTxn = this.addresses.includes(signer) + + const txnString = byteArrayToBase64(txn.toByte()) + + if (isIndexMatch && canSignTxn) { + txnsToSign.push({ txn: txnString }) + } else { + txnsToSign.push({ txn: txnString, signers: [] }) + } + }) + + return txnsToSign + } + + private processEncodedTxns( + txnGroup: Uint8Array[], + indexesToSign?: number[] + ): WalletTransaction[] { + const txnsToSign: WalletTransaction[] = [] + + txnGroup.forEach((txnBuffer, index) => { + const decodedObj = algosdk.msgpackRawDecode(txnBuffer) + const isSigned = isSignedTxn(decodedObj) + + const txn: algosdk.Transaction = isSigned + ? algosdk.decodeSignedTransaction(txnBuffer).txn + : algosdk.decodeUnsignedTransaction(txnBuffer) + + const isIndexMatch = !indexesToSign || indexesToSign.includes(index) + const signer = txn.sender.toString() + const canSignTxn = !isSigned && this.addresses.includes(signer) + + const txnString = byteArrayToBase64(txn.toByte()) + + if (isIndexMatch && canSignTxn) { + txnsToSign.push({ txn: txnString }) + } else { + txnsToSign.push({ txn: txnString, signers: [] }) + } + }) + + return txnsToSign + } + + public signTransactions = async ( + txnGroup: T | T[], + indexesToSign?: number[] + ): Promise<(Uint8Array | null)[]> => { + try { + this.logger.debug('Signing transactions...', { txnGroup, indexesToSign }) + let txnsToSign: WalletTransaction[] = [] + + if (isTransactionArray(txnGroup)) { + const flatTxns: algosdk.Transaction[] = flattenTxnGroup(txnGroup) + txnsToSign = this.processTxns(flatTxns, indexesToSign) + } else { + const flatTxns: Uint8Array[] = flattenTxnGroup(txnGroup as Uint8Array[]) + txnsToSign = this.processEncodedTxns(flatTxns, indexesToSign) + } + + const provider = await this.getProvider() + + this.logger.debug('Sending transactions to AlgoVoi for signing...', txnsToSign) + + const signTxnsResult = await provider.signTransactions(txnsToSign) + + // Convert base64 results to Uint8Array + const result = signTxnsResult.map((value) => { + if (value === null) return null + // Decode base64 to Uint8Array + const binaryStr = atob(value) + const bytes = new Uint8Array(binaryStr.length) + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i) + } + return bytes + }) + + this.logger.debug('Transactions signed successfully', result) + return result + } catch (error: any) { + this.logger.error('Error signing transactions:', error.message) + throw error + } + } +} diff --git a/packages/use-wallet/src/wallets/index.ts b/packages/use-wallet/src/wallets/index.ts index dcacbe419..7a76a5300 100644 --- a/packages/use-wallet/src/wallets/index.ts +++ b/packages/use-wallet/src/wallets/index.ts @@ -1,3 +1,4 @@ +export * from './algovoi' export * from './base' export * from './biatec' export * from './custom' diff --git a/packages/use-wallet/src/wallets/types.ts b/packages/use-wallet/src/wallets/types.ts index c49e99284..581d66188 100644 --- a/packages/use-wallet/src/wallets/types.ts +++ b/packages/use-wallet/src/wallets/types.ts @@ -1,3 +1,4 @@ +import { AlgoVoiWallet } from './algovoi' import { CustomWallet, CustomWalletOptions } from './custom' import { DeflyWallet, type DeflyWalletConnectOptions } from './defly' import { DeflyWebWallet } from './defly-web' @@ -17,6 +18,7 @@ import type { State } from 'src/store' import { W3Wallet } from './w3wallet' export enum WalletId { + ALGOVOI = 'algovoi', BIATEC = 'biatec', DEFLY = 'defly', DEFLY_WEB = 'defly-web', @@ -63,6 +65,7 @@ export type WalletConnectSkinOption = string | WalletConnectSkin export type WalletKey = WalletId | `${WalletId.WALLETCONNECT}:${string}` export type WalletMap = { + [WalletId.ALGOVOI]: typeof AlgoVoiWallet [WalletId.BIATEC]: typeof BiatecWallet [WalletId.CUSTOM]: typeof CustomWallet [WalletId.DEFLY]: typeof DeflyWallet @@ -80,6 +83,7 @@ export type WalletMap = { } export type WalletOptionsMap = { + [WalletId.ALGOVOI]: Record [WalletId.BIATEC]: WalletConnectOptions [WalletId.CUSTOM]: CustomWalletOptions [WalletId.DEFLY]: DeflyWalletConnectOptions