diff --git a/packages/core/src/manager.ts b/packages/core/src/manager.ts index f0adb4899..8570d3ee0 100644 --- a/packages/core/src/manager.ts +++ b/packages/core/src/manager.ts @@ -193,9 +193,9 @@ export class WalletManager extends StateBase { }); newChainRecords.forEach((chainRecord) => { const index = this.chainRecords.findIndex( - (chainRecord2) => chainRecord2.name !== chainRecord.name + (chainRecord2) => chainRecord2.name === chainRecord.name ); - if (index == -1) { + if (index === -1) { this.chainRecords.push(chainRecord); } else { this.chainRecords[index] = chainRecord; @@ -235,9 +235,9 @@ export class WalletManager extends StateBase { } const index = this.walletRepos.findIndex( - (repo2) => repo2.chainName !== repo.chainName + (repo2) => repo2.chainName === repo.chainName ); - if (index == -1) { + if (index === -1) { this.walletRepos.push(repo); } else { this.walletRepos[index] = repo; diff --git a/packages/test/__tests__/chain-wallet-base.test.ts b/packages/test/__tests__/chain-wallet-base.test.ts index 4cf93ed9f..2c30360d3 100644 --- a/packages/test/__tests__/chain-wallet-base.test.ts +++ b/packages/test/__tests__/chain-wallet-base.test.ts @@ -1,54 +1,67 @@ -import { ChainWalletBase, ChainRecord } from "@cosmos-kit/core"; -import { chains } from "chain-registry"; +import { ChainWalletBase, ChainRecord } from '@cosmos-kit/core'; +import { chains } from 'chain-registry'; import { mockExtensionInfo as walletInfo } from '../src/mock-extension/extension/registry'; +import { initActiveWallet } from '../src/utils'; +import { getMockFromExtension } from '../src/mock-extension/extension/utils'; +import { MockClient } from '../src/mock-extension/extension/client'; // Mock global window object global.window = { - // @ts-ignore - localStorage: { - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn() - } + // @ts-ignore + localStorage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, }; - const chainRecord: ChainRecord = { - name: 'cosmoshub', - chain: chains.find(c => c.chain_name === 'cosmoshub'), - clientOptions: { - preferredSignType: 'direct' - }, + name: 'cosmoshub', + chain: chains.find((c) => c.chain_name === 'cosmoshub'), + clientOptions: { + preferredSignType: 'direct', + }, }; -const chainWallet = new ChainWalletBase(walletInfo, chainRecord); +// const chainWallet = new ChainWalletBase(walletInfo, chainRecord); -async function connectAndFetchAccount() { - try { - await chainWallet.update({ connect: true }); - console.log('Connected and account data fetched:', chainWallet.data); - } catch (error) { - console.error('Failed to connect or fetch account data:', error); - } -} +// async function connectAndFetchAccount() { +// try { +// await chainWallet.update({ connect: true }); +// console.log('Connected and account data fetched:', chainWallet.data); +// } catch (error) { +// console.error('Failed to connect or fetch account data:', error); +// } +// } -connectAndFetchAccount(); +// connectAndFetchAccount(); describe('ChainWalletBase', () => { - let chainWallet; - beforeEach(() => { - chainWallet = new ChainWalletBase(walletInfo, chainRecord); - // Mocking necessary methods and properties - // jest.spyOn(chainWallet, 'connectChains').mockResolvedValue(undefined); - jest.spyOn(chainWallet.client, 'getSimpleAccount').mockResolvedValue({ - namespace: 'cosmos', - chainId: 'cosmoshub-4', - address: 'cosmos1...' - }); - }); - - it('should update and fetch account data', async () => { - await expect(chainWallet.update({ connect: true })).resolves.not.toThrow(); - expect(chainWallet.data.address).toBe('cosmos1...'); - }); + let chainWallet: ChainWalletBase; + beforeEach(async () => { + await initActiveWallet([chainRecord.chain]); + + chainWallet = new ChainWalletBase(walletInfo, chainRecord); + + chainWallet.initingClient(); + const mockWallet = await getMockFromExtension(); + chainWallet.initClientDone(new MockClient(mockWallet)); + + // Mocking necessary methods and properties + // jest.spyOn(chainWallet, 'connectChains').mockResolvedValue(undefined); + // jest.spyOn(chainWallet.client, 'getSimpleAccount').mockResolvedValue({ + // namespace: 'cosmos', + // chainId: 'cosmoshub-4', + // address: 'cosmos1...', + // }); + }); + + it('should update and fetch account data', async () => { + await chainWallet.connect(); + + expect( + chainWallet.data.address.startsWith(chainRecord.chain.bech32_prefix) + ).toBe(true); + expect(chainWallet.data.address).toBe(chainWallet.address); + }); }); diff --git a/packages/test/__tests__/cosmos-kit.test.ts b/packages/test/__tests__/cosmos-kit.test.ts index 2d6a3266d..b4f4ecaf4 100644 --- a/packages/test/__tests__/cosmos-kit.test.ts +++ b/packages/test/__tests__/cosmos-kit.test.ts @@ -1,27 +1,39 @@ import { getMockFromExtension } from '../src/mock-extension/extension/utils'; import { MockWallet } from '../src/mocker'; +import { getChainInfoByChainId, initActiveWallet } from '../src/utils'; describe('Wallet functionality', () => { const wallet = new MockWallet(); it('should handle key retrieval', async () => { - const key = await wallet.getKey('cosmos'); - expect(key.bech32Address).toBe('cosmos1...'); + const chain = getChainInfoByChainId('cosmoshub-4'); + + await initActiveWallet([chain]); + + const key = await wallet.getKey(chain.chain_id); + expect(key.bech32Address.startsWith('cosmos')).toBe(true); }); // Add more tests as needed + // Some `Wallet` functions has been tested in `wallet-manager.test.ts` using `ChainWalletContext` }); describe('getMockFromExtension', () => { - it('returns the provided mock', async () => { - const mock = new MockWallet(); - // @ts-ignore - const result = await getMockFromExtension({ mock }); - expect(result).toEqual(mock); - }); + // it('returns the provided mock', async () => { + // const mock = new MockWallet(); + // // @ts-ignore + // const result = await getMockFromExtension({ mock }); + // expect(result).toEqual(mock); + // }); it('instantiates MockWallet if no mock is provided', async () => { const result = await getMockFromExtension(); expect(result).toBeInstanceOf(MockWallet); }); + + it('should be only one MockWallet instance in an environment', async () => { + const result = await getMockFromExtension(); + const mock = await getMockFromExtension(); + expect(result).toBe(mock); + }); }); diff --git a/packages/test/__tests__/wallet-manager.test.ts b/packages/test/__tests__/wallet-manager.test.ts index b2f054458..70bad0b88 100644 --- a/packages/test/__tests__/wallet-manager.test.ts +++ b/packages/test/__tests__/wallet-manager.test.ts @@ -1,9 +1,63 @@ -import { WalletManager, Logger, ChainRecord, Session } from '@cosmos-kit/core'; +import { + WalletManager, + Logger, + ChainWalletContext, + DirectSignDoc, + SuggestTokenTypes, + Session, +} from '@cosmos-kit/core'; import { chains, assets } from 'chain-registry'; -import { MockExtensionWallet } from '../src/mock-extension/extension'; +import { Chain } from '@chain-registry/types'; +import { MockExtensionWallet } from '../src/mock-extension'; import { mockExtensionInfo as walletInfo } from '../src/mock-extension/extension/registry'; +import { getChainWalletContext } from '../../react-lite/src/utils'; +import { ORIGIN } from '../src/utils'; +import { ACTIVE_WALLET, KeyChain } from '../src/key-chain'; +import { + BrowserStorage, + BETA_CW20_TOKENS, + CONNECTIONS, +} from '../src/browser-storage'; +import { MockClient } from '../src/mock-extension/extension/client'; -const logger = new Logger(); function logoutUser() { +import { + Registry, + makeAuthInfoBytes, + makeSignDoc, + encodePubkey, + coins, + makeSignBytes, +} from '@cosmjs/proto-signing'; +import { toBase64, fromBase64 } from '@cosmjs/encoding'; +import { Secp256k1, Secp256k1Signature, sha256 } from '@cosmjs/crypto'; +import { serializeSignDoc } from '@cosmjs/amino'; +import { getADR36SignDoc, initActiveWallet } from '../src/utils'; +import { MsgSend } from 'cosmjs-types/cosmos/bank/v1beta1/tx'; +import { Coin } from 'cosmjs-types/cosmos/base/v1beta1/coin'; +import { TxRaw } from 'cosmjs-types/cosmos/tx/v1beta1/tx'; +import { getMockFromExtension } from '../src/mock-extension/extension/utils'; + +// Mock global window object +// @ts-ignore +global.window = { + localStorage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn(), + length: 0, + }, + // @ts-ignore + navigator: { + userAgent: + "'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'", + }, + addEventListener: jest.fn(), +}; + +const logger = new Logger(); +function logoutUser() { console.log('Session expired. Logging out user.'); // Code to log out user } @@ -11,50 +65,348 @@ const logger = new Logger(); function logoutUser() { // Session duration set for 30 minutes const userSession = new Session({ duration: 30 * 60 * 1000, // 30 minutes in milliseconds - callback: logoutUser + callback: logoutUser, }); // Start the session when the user logs in userSession.update(); -const mainWalletBase = new MockExtensionWallet(walletInfo) +export let walletManager: WalletManager; +let client: MockClient; +let context: ChainWalletContext; -describe('WalletManager', () => { - let walletManager: WalletManager; +const initChainsCount = 2; - beforeEach(() => { - walletManager = new WalletManager( - chains, - [mainWalletBase], - logger, - true, // throwErrors - true, // subscribeConnectEvents - false // disableIframe - ); - }); +let initialChain: Chain; +let suggestChain: Chain; + +const mainWalletBase = new MockExtensionWallet(walletInfo); +beforeAll(async () => { + const liveChainsOfType118 = chains.filter( + (chain) => chain.slip44 === 118 && chain.status === 'live' + ); + const startIndex = liveChainsOfType118.findIndex( + (chain) => chain.chain_name === 'cosmoshub' + ); + + const endIndex = startIndex + initChainsCount; + const enabledChains = liveChainsOfType118.slice(startIndex, endIndex); + initialChain = enabledChains[0]; + suggestChain = liveChainsOfType118[endIndex]; + + await initActiveWallet(enabledChains); + + walletManager = new WalletManager( + enabledChains, + [mainWalletBase], + logger, + true, // throwErrors + true, // subscribeConnectEvents + false, // disableIframe + assets // assetLists + ); +}); + +describe('WalletManager', () => { it('should initialize with provided configurations', () => { expect(walletManager.throwErrors).toBe(true); expect(walletManager.subscribeConnectEvents).toBe(true); expect(walletManager.disableIframe).toBe(false); - expect(walletManager.chainRecords).toHaveLength(2); // Assuming `convertChain` is mocked + + expect(walletManager.chainRecords).toHaveLength(initChainsCount); + expect(walletManager.walletRepos).toHaveLength(initChainsCount); + + const mainWallet = walletManager.getMainWallet(walletInfo.name); + expect(mainWallet).toBe(mainWalletBase); + expect(mainWallet.getChainWalletList(false)).toHaveLength(initChainsCount); }); it('should handle onMounted lifecycle correctly', async () => { + // `getParser` is a static method of `Bowser` class, unable to mock directly. // Mock environment parser - jest.mock('Bowser', () => ({ - getParser: () => ({ - getBrowserName: jest.fn().mockReturnValue('chrome'), - getPlatform: jest.fn().mockReturnValue({ type: 'desktop' }), - getOSName: jest.fn().mockReturnValue('windows') - }) - })); + // jest.mock('Bowser', () => ({ + // getParser: () => ({ + // getBrowserName: jest.fn().mockReturnValue('chrome'), + // getPlatform: jest.fn().mockReturnValue({ type: 'desktop' }), + // getOSName: jest.fn().mockReturnValue('windows'), + // }), + // })); await walletManager.onMounted(); - expect(walletManager.walletRepos).toHaveLength(2); // Depends on internal logic - expect(logger.debug).toHaveBeenCalled(); // Check if debug logs are called + expect(window.addEventListener).toHaveBeenCalledWith( + walletInfo.connectEventNamesOnWindow[0], + expect.any(Function) + ); + + expect(window.localStorage.getItem).toHaveBeenCalledWith( + 'cosmos-kit@2:core//current-wallet' + ); + + expect((mainWalletBase.client as MockClient).client).toBe( + await getMockFromExtension() + ); }); - // Add more tests as needed for each method + it('should connect wallet', async () => { + // import { useChain } from "@cosmos-kit/react"; + // mock `useChain` hook + + const walletRepo = walletManager.getWalletRepo(initialChain.chain_name); + walletRepo.activate(); + + const chainWallet = walletManager.getChainWallet( + initialChain.chain_name, + walletInfo.name + ); + + expect(walletRepo.isActive).toBe(true); + expect(chainWallet.isActive).toBe(true); + + context = getChainWalletContext(initialChain.chain_id, chainWallet); + + expect(context.wallet.name).toBe(walletInfo.name); + expect(context.isWalletDisconnected).toBe(true); + + await chainWallet.connect(); + + expect(chainWallet.isWalletConnected).toBe(true); + expect(chainWallet.address.startsWith(initialChain.bech32_prefix)).toBe( + true + ); + }); + + it('should suggest chain and addChain', async () => { + // @ts-ignore + client = context.chainWallet.client; + + await client.addChain({ + name: suggestChain.chain_name, + chain: suggestChain, + assetList: assets.find( + ({ chain_name }) => chain_name === suggestChain.chain_name + ), + }); + + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + const connections = BrowserStorage.getItem(CONNECTIONS); + + expect(connections[activeWallet.id][suggestChain.chain_id]).toContain( + ORIGIN + ); + + walletManager.addChains([suggestChain], assets); + + const walletRepos = walletManager.walletRepos; + const mainWallet = walletManager.getMainWallet(walletInfo.name); + const chainWalletMap = mainWallet.chainWalletMap; + + expect(walletManager.chainRecords).toHaveLength(initChainsCount + 1); + expect(walletRepos).toHaveLength(initChainsCount + 1); + expect(chainWalletMap.size).toBe(initChainsCount + 1); + + const newWalletRepo = walletManager.getWalletRepo(suggestChain.chain_name); + const newChainWallet = newWalletRepo.getWallet(walletInfo.name); + + expect(newChainWallet.address).toBeFalsy(); + await newChainWallet.connect(); + expect(newChainWallet.address.startsWith(suggestChain.bech32_prefix)).toBe( + true + ); + }); + + it('should sign direct (using ChainWalletContext)', async () => { + const registry = new Registry(); + const txBody = { + messages: [], + memo: '', + }; + const txBodyBytes = registry.encodeTxBody(txBody); + + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + const address = activeWallet.addresses[initialChain.chain_id]; + + const pubKeyBuf = activeWallet.pubKeys[initialChain.chain_id]; + const pubKeyBytes = new Uint8Array(pubKeyBuf); + const pubkey = encodePubkey({ + type: 'tendermint/PubKeySecp256k1', + value: toBase64(pubKeyBytes), + }); + + const authInfoBytes = makeAuthInfoBytes( + [{ pubkey, sequence: 0 }], + coins(1000, 'ucosm'), + 1000, + undefined, + undefined + ); + + const accountNumber = 1; + const signDoc = makeSignDoc( + txBodyBytes, + authInfoBytes, + initialChain.chain_id, + accountNumber + ) as DirectSignDoc; + + const { signature, signed } = await context.signDirect(address, signDoc); + + const valid = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)), + sha256(makeSignBytes(signed)), + pubKeyBytes + ); + + expect(valid).toBe(true); + }); + + it('should sign amino (using ChainWalletContext)', async () => { + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + const address = activeWallet.addresses[initialChain.chain_id]; + const pubKeyBuf = activeWallet.pubKeys[initialChain.chain_id]; + + const signDoc = { + msgs: [], + fee: { amount: [], gas: '1000' }, + chain_id: initialChain.chain_id, + memo: '', + account_number: '1', + sequence: '0', + }; + + const { signature, signed } = await context.signAmino(address, signDoc); + + const valid = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(fromBase64(signature.signature)), + sha256(serializeSignDoc(signed)), + pubKeyBuf + ); + + expect(valid).toBe(true); + }); + + it('should sign arbitrary (using ChainWalletContext)', async () => { + const data = 'cosmos-kit'; + + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + const address = activeWallet.addresses[initialChain.chain_id]; + const pubKeyBuf = activeWallet.pubKeys[initialChain.chain_id]; + + const { signature, pub_key } = await context.signArbitrary(address, data); + + const signDoc = getADR36SignDoc( + address, + Buffer.from(data).toString('base64') + ); + const valid = await Secp256k1.verifySignature( + Secp256k1Signature.fromFixedLength(fromBase64(signature)), + sha256(serializeSignDoc(signDoc)), + pubKeyBuf + ); + + expect(valid).toBe(true); + expect(toBase64(pubKeyBuf)).toEqual(pub_key.value); + }); + + it('should getOfflineSignerDirect (using ChainWalletContext)', async () => { + const offlineSignerDirect = context.getOfflineSignerDirect(); + expect(offlineSignerDirect.signDirect).toBeTruthy(); + }); + + it('should getOfflineSignerAmino (using ChainWalletContext)', async () => { + const offlineSignerAmino = context.getOfflineSignerAmino(); + // @ts-ignore + expect(offlineSignerAmino.signDirect).toBeFalsy(); + expect(offlineSignerAmino.signAmino).toBeTruthy(); + }); + + it('should getOfflineSigner (using ChainWalletContext)', async () => { + // default preferredSignType - 'amino', packages/core/src/bases/chain-wallet.ts, line 41 + expect(context.chainWallet.preferredSignType).toBe('amino'); + + const offlineSigner = context.getOfflineSigner(); + // @ts-ignore + expect(offlineSigner.signAmino).toBeTruthy(); + // @ts-ignore + expect(offlineSigner.signDirect).toBeFalsy(); + }); + + it('should suggest cw20 token (using ChainWalletContext)', async () => { + const chainId = 'pacific-1'; + const chainName = 'sei'; + const contractAddress = + 'sei1hrndqntlvtmx2kepr0zsfgr7nzjptcc72cr4ppk4yav58vvy7v3s4er8ed'; + // symbol = 'SEIYAN' + + await context.suggestToken({ + chainId, + chainName, + type: SuggestTokenTypes.CW20, + tokens: [{ contractAddress }], + }); + + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + const betaTokens = BrowserStorage.getItem(BETA_CW20_TOKENS); + const connections = BrowserStorage.getItem(CONNECTIONS); + + expect(connections[activeWallet.id][chainId]).toContain(ORIGIN); + expect(betaTokens[chainId][contractAddress].coinDenom).toBe('SEIYAN'); + }, 15000); // set timeout to 15 seconds, in case slow network. + + it('should send proto tx (using ChainWalletContext)', async () => { + const registry = new Registry(); + + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + const address = activeWallet.addresses[initialChain.chain_id]; + + const coin = Coin.fromPartial({ denom: 'ucosm', amount: '1000' }); + const msgSend = MsgSend.fromPartial({ + fromAddress: address, + toAddress: 'archway1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc52fs6vt', + amount: [coin], + }); + const message = { typeUrl: '/cosmos.bank.v1beta1.MsgSend', value: msgSend }; + const txBody = { messages: [message], memo: '' }; + const txBodyBytes = registry.encodeTxBody(txBody); + + const pubKeyBuf = activeWallet.pubKeys[initialChain.chain_id]; + const pubKeyBytes = new Uint8Array(pubKeyBuf); + const pubkey = encodePubkey({ + type: 'tendermint/PubKeySecp256k1', + value: toBase64(pubKeyBytes), + }); + + const authInfoBytes = makeAuthInfoBytes( + [{ pubkey, sequence: 0 }], + coins(1000, 'ucosm'), + 1000, + undefined, + undefined + ); + + const accountNumber = 1; + const signDoc = makeSignDoc( + txBodyBytes, + authInfoBytes, + initialChain.chain_id, + accountNumber + ) as DirectSignDoc; + + const { signature, signed } = await context.signDirect(address, signDoc); + + const txRaw = TxRaw.fromPartial({ + bodyBytes: signed.bodyBytes, + authInfoBytes: signed.authInfoBytes, + signatures: [fromBase64(signature.signature)], + }); + const txRawBytes = Uint8Array.from(TxRaw.encode(txRaw).finish()); + + // `BroadcastMode.SYNC` There is a problem with enum definition at runtime. + // @ts-expect-error + const result = await context.sendTx(txRawBytes, 'sync'); + + // since this is a mock tx, the tx will not succeed, but we will still get a response with a txhash. + expect(toBase64(result)).toHaveLength(64); + }, 15000); // set timeout to 15 seconds, in case slow network. }); diff --git a/packages/test/jest.config.js b/packages/test/jest.config.js index 49970653f..cb4ed4739 100644 --- a/packages/test/jest.config.js +++ b/packages/test/jest.config.js @@ -1,19 +1,19 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ const path = require('path'); module.exports = { - preset: "ts-jest", - testEnvironment: "node", - transform: { - "^.+\\.tsx?$": [ - "ts-jest", - { - babelConfig: false, - tsconfig: "tsconfig.json", - }, - ], - }, - transformIgnorePatterns: [`/node_modules/*`], - testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", - moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], - modulePathIgnorePatterns: ["dist/*"] + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'], }; diff --git a/packages/test/package.json b/packages/test/package.json index 223ce501b..940a84572 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -46,7 +46,7 @@ "prepare": "npm run build", "lint": "eslint --ext .tsx,.ts .", "format": "eslint --ext .tsx,.ts --fix .", - "test": "jest", + "test": "jest --forceExit", "test:watch": "jest --watch", "test:debug": "node --inspect node_modules/.bin/jest --runInBand", "clear": "rm -rf ./dist" @@ -80,4 +80,4 @@ "@keplr-wallet/types": "^0.12.58" }, "browserslist": "> 0.5%, last 2 versions, not dead" -} \ No newline at end of file +} diff --git a/packages/test/src/browser-storage.ts b/packages/test/src/browser-storage.ts new file mode 100644 index 000000000..269563122 --- /dev/null +++ b/packages/test/src/browser-storage.ts @@ -0,0 +1,50 @@ +import { Chain } from '@chain-registry/types'; + +export const CONNECTIONS = 'connections' as const; +export const BETA_CW20_TOKENS = 'beta-cw20-tokens' as const; + +export type TKey = typeof CONNECTIONS | typeof BETA_CW20_TOKENS; + +export type TConnectionsValue = { + [walletId: string]: { + [chainId: string]: string[]; + }; +}; + +export type TCW20Token = { + coinDenom: string; + coinMinimalDenom: string; + coinDecimals: number; + chain: Chain; + coinGeckoId: string; + icon: string; +}; + +export type TBetaCW20TokenValue = { + [chainId: string]: { + [contractAddress: string]: TCW20Token; + }; +}; + +export type TValueMap = { + [CONNECTIONS]: TConnectionsValue; + [BETA_CW20_TOKENS]: TBetaCW20TokenValue; +}; + +export type TBrowserStorageMap = { + [K in TKey]: TValueMap[K]; +}; + +export class BrowserStorageClass { + private storage = new Map(); + + setItem(key: K, value: TBrowserStorageMap[K]): void { + this.storage.set(key, value); + } + + getItem(key: K): TBrowserStorageMap[K] | undefined { + return this.storage.get(key); + } +} + +export const BrowserStorage = new BrowserStorageClass(); diff --git a/packages/test/src/key-chain.ts b/packages/test/src/key-chain.ts new file mode 100644 index 000000000..a4946c974 --- /dev/null +++ b/packages/test/src/key-chain.ts @@ -0,0 +1,37 @@ +export const KEYSTORE = 'keystore' as const; +export const ACTIVE_WALLET = 'active-wallet' as const; + +export type TKey = typeof KEYSTORE | typeof ACTIVE_WALLET; + +export type TWallet = { + addressIndex: number; + name: string; + cipher: string; // mnemonic, should be encrypted in real environment. + addresses: Record; + pubKeys: Record; + walletType: string; + id: string; +}; + +export type TValueMap = { + [KEYSTORE]: { [walletId: string]: TWallet }; + [ACTIVE_WALLET]: TWallet; +}; + +export type TKeyChainMap = { + [K in TKey]: TValueMap[K]; +}; + +export class KeyChainClass { + private storage = new Map(); + + setItem(key: K, value: TKeyChainMap[K]): void { + this.storage.set(key, value); + } + + getItem(key: K): TKeyChainMap[K] | undefined { + return this.storage.get(key); + } +} + +export const KeyChain = new KeyChainClass(); diff --git a/packages/test/src/mock-extension/extension/client.ts b/packages/test/src/mock-extension/extension/client.ts index c957c6ae5..2d92cba44 100644 --- a/packages/test/src/mock-extension/extension/client.ts +++ b/packages/test/src/mock-extension/extension/client.ts @@ -45,7 +45,7 @@ export class MockClient implements WalletClient { } } - async addChain(chainInfo: ChainRecord) { + async addChain(chainInfo: ChainRecord): Promise { const suggestChain = chainRegistryChainToKeplr( chainInfo.chain, chainInfo.assetList ? [chainInfo.assetList] : [] @@ -61,7 +61,7 @@ export class MockClient implements WalletClient { chainInfo.preferredEndpoints?.rpc?.[0]; } - await this.client.experimentalSuggestChain(suggestChain); + return await this.client.experimentalSuggestChain(suggestChain); } async disconnect() { diff --git a/packages/test/src/mock-extension/extension/utils.ts b/packages/test/src/mock-extension/extension/utils.ts index ac541902d..3aa6cfd2f 100644 --- a/packages/test/src/mock-extension/extension/utils.ts +++ b/packages/test/src/mock-extension/extension/utils.ts @@ -5,8 +5,13 @@ interface MockWindow { mock?: Mock; } +let mockWallet = null; export const getMockFromExtension: ( mockWindow?: MockWindow ) => Promise = async (_window: any) => { - return new MockWallet(); + if (!mockWallet) { + mockWallet = new MockWallet(); + } + + return mockWallet; }; diff --git a/packages/test/src/mocker/index.ts b/packages/test/src/mocker/index.ts index b35b8b5b6..01c135882 100644 --- a/packages/test/src/mocker/index.ts +++ b/packages/test/src/mocker/index.ts @@ -1,16 +1,51 @@ -// @ts-nocheck +import { Chain } from '@chain-registry/types'; import { AminoSignResponse, + encodeSecp256k1Signature, OfflineAminoSigner, + Secp256k1HdWallet, StdSignature, StdSignDoc, } from '@cosmjs/amino'; -import { OfflineDirectSigner, OfflineSigner } from '@cosmjs/proto-signing'; -import { DirectSignResponse } from '@cosmjs/proto-signing'; +import { Secp256k1, sha256, stringToPath } from '@cosmjs/crypto'; +import { + DirectSignResponse, + makeSignBytes, + OfflineDirectSigner, + OfflineSigner, +} from '@cosmjs/proto-signing'; import { BroadcastMode } from '@cosmos-kit/core'; -import Long from 'long'; +import type { ChainInfo } from '@keplr-wallet/types'; +import * as bech32 from 'bech32'; +import type { SignDoc } from 'cosmjs-types/cosmos/tx/v1beta1/tx'; +import deepmerge from 'deepmerge'; -import { Key, Mock, MockSignOptions } from '../mock-extension/extension/types'; +import { + BETA_CW20_TOKENS, + BrowserStorage, + CONNECTIONS, +} from '../browser-storage'; +import { + ACTIVE_WALLET, + KeyChain, + KEYSTORE, + TValueMap, + TWallet, +} from '../key-chain'; +import { Key, Mock, MockSignOptions } from '../mock-extension'; +import { ORIGIN } from '../utils'; +import { + generateWallet, + getADR36SignDoc, + getChainInfoByChainId, + getChildKey, + getContractInfo, + getHdPath, +} from '../utils'; +import { + CosmJSOfflineSigner, + CosmJSOfflineSignerOnlyAmino, +} from './offline-signer'; export class MockWallet implements Mock { defaultOptions = { @@ -28,7 +63,34 @@ export class MockWallet implements Mock { } async enable(chainIds: string | string[]): Promise { - // Simulate enabling logic + if (typeof chainIds === 'string') chainIds = [chainIds]; + + const validChainIds = []; + chainIds.forEach((chainId: string) => { + const validChain = getChainInfoByChainId(chainId); + if (validChain) validChainIds.push(validChain.chain_id); + }); + + if (validChainIds.length === 0) { + // return { error: 'Invalid chain ids' }; + throw new Error('Invalid chain ids'); + } + + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + + const connections = BrowserStorage.getItem(CONNECTIONS); + const newConnections = { ...(connections ?? {}) }; + + if (!newConnections[activeWallet.id]) newConnections[activeWallet.id] = {}; + validChainIds.forEach((chainId) => { + newConnections[activeWallet.id][chainId] = Array.from( + new Set([...(newConnections[activeWallet.id][chainId] ?? []), ORIGIN]) + ); + }); + + BrowserStorage.setItem(CONNECTIONS, newConnections); + + // return { success: 'Chain enabled' }; } async suggestToken(chainId: string, contractAddress: string): Promise { @@ -39,40 +101,95 @@ export class MockWallet implements Mock { chainId: string, contractAddress: string ): Promise { - // Simulate suggesting a CW20 token + // `chainId` should be added to `CONNECTIONS` if not present. + // since the mock env, no end user approval is required, + // `enable` function can be treated as `add connection` approval. + await this.enable(chainId); + + const res = await getContractInfo(chainId, contractAddress); + const chainInfo = getChainInfoByChainId(chainId); + + if (typeof res.message === 'string' && res.message.includes('invalid')) { + throw new Error('Invalid Contract Address'); + } + + const cw20Token = { + coinDenom: res.data.symbol, + coinMinimalDenom: contractAddress, + coinDecimals: res.data.decimals, + chain: chainInfo, + coinGeckoId: '', + icon: '', + }; + + const betaTokens = BrowserStorage.getItem(BETA_CW20_TOKENS); + + const newBetaTokens = { + ...(betaTokens || {}), + ...{ + [chainId]: { + ...(betaTokens?.[chainId] ?? {}), + [contractAddress]: cw20Token, + }, + }, + }; + + BrowserStorage.setItem(BETA_CW20_TOKENS, newBetaTokens); + + // return { success: 'token suggested' }; } async getKey(chainId: string): Promise { + const chainInfo: Chain = getChainInfoByChainId(chainId); + + if (!chainInfo || !(chainInfo.status === 'live')) + throw new Error('Invalid chainId'); + + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + + const pubKey = activeWallet.pubKeys?.[chainId]; + + const decoded = bech32.decode(activeWallet.addresses[chainId]); + const address = new Uint8Array(bech32.fromWords(decoded.words)); + return { - name: 'Test Key', + name: activeWallet.name, algo: 'secp256k1', - pubKey: new Uint8Array(), - address: new Uint8Array(), - bech32Address: 'cosmos1...', + pubKey, + address, + bech32Address: activeWallet.addresses[chainId], isNanoLedger: false, }; } - async getOfflineSigner( - chainId: string - ): Promise { - return { - // Implement Offline Signer logic as needed - } as OfflineAminoSigner & OfflineDirectSigner; + getOfflineSigner( + chainId: string, + signOptions?: MockSignOptions + ): OfflineAminoSigner & OfflineDirectSigner { + return new CosmJSOfflineSigner( + chainId, + this, + deepmerge(this.defaultOptions ?? {}, signOptions ?? {}) + ); } - async getOfflineSignerOnlyAmino( - chainId: string - ): Promise { - return { - // Implement Offline Amino Signer logic as needed - } as OfflineAminoSigner; + getOfflineSignerOnlyAmino( + chainId: string, + signOptions?: MockSignOptions + ): OfflineAminoSigner { + return new CosmJSOfflineSignerOnlyAmino( + chainId, + this, + deepmerge(this.defaultOptions ?? {}, signOptions ?? {}) + ); } - async getOfflineSignerAuto(chainId: string): Promise { - return { - // Implement Auto Signer logic as needed - } as OfflineSigner; + async getOfflineSignerAuto( + chainId: string, + signOptions?: MockSignOptions + ): Promise { + const _signOpts = deepmerge(this.defaultOptions ?? {}, signOptions ?? {}); + return new CosmJSOfflineSigner(chainId, this, _signOpts); } async signAmino( @@ -81,26 +198,62 @@ export class MockWallet implements Mock { signDoc: StdSignDoc, signOptions?: MockSignOptions ): Promise { - return { - signed: signDoc, - signature: new Uint8Array(), - }; + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + + const chainInfo: Chain = getChainInfoByChainId(chainId); + + const hdPath = stringToPath( + getHdPath(chainInfo.slip44 + '', activeWallet.addressIndex + '') + ); + const wallet = await Secp256k1HdWallet.fromMnemonic(activeWallet.cipher, { + prefix: chainInfo.bech32_prefix, + hdPaths: [hdPath], + }); + + const { signed, signature } = await wallet.signAmino(signer, signDoc); + return { signed, signature }; } async signDirect( chainId: string, signer: string, signDoc: { - bodyBytes?: Uint8Array | null; authInfoBytes?: Uint8Array | null; - chainId?: string | null; accountNumber?: Long | null; + bodyBytes?: Uint8Array | null; + chainId?: string | null; }, signOptions?: MockSignOptions ): Promise { + // Or use DirectSecp256k1HdWallet - signDirect + + const key = getChildKey(chainId, signer); + + const _signDoc = { + ...signDoc, + accountNumber: signDoc.accountNumber + ? BigInt(signDoc.accountNumber.toString()) + : null, + }; + const hash = sha256(makeSignBytes(_signDoc)); + const signature = await Secp256k1.createSignature( + hash, + new Uint8Array(key.privateKey) + ); + + const signatureBytes = new Uint8Array([ + ...signature.r(32), + ...signature.s(32), + ]); + + const stdSignature = encodeSecp256k1Signature( + new Uint8Array(key.publicKey), + signatureBytes + ); + return { - signed: signDoc, - signature: new Uint8Array(), + signed: _signDoc, + signature: stdSignature, }; } @@ -109,10 +262,12 @@ export class MockWallet implements Mock { signer: string, data: string | Uint8Array ): Promise { - return { - pubKey: new Uint8Array(), - signature: new Uint8Array(), - }; + data = Buffer.from(data).toString('base64'); + const signDoc = getADR36SignDoc(signer, data); + + const { signature } = await this.signAmino(chainId, signer, signDoc); + + return signature; } async getEnigmaPubKey(chainId: string): Promise { @@ -144,13 +299,75 @@ export class MockWallet implements Mock { async sendTx( chainId: string, - tx: Uint8Array, + tx: Uint8Array, // protobuf tx mode: BroadcastMode ): Promise { - return new Uint8Array(); + const chain = getChainInfoByChainId(chainId); + + const params = { + tx_bytes: Buffer.from(tx).toString('base64'), + mode: mode + ? `BROADCAST_MODE_${mode.toUpperCase()}` + : 'BROADCAST_MODE_UNSPECIFIED', + }; + + const url = `${chain.apis.rest[0].address}/cosmos/tx/v1beta1/txs`; + + const res = await fetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + const result = await res.json(); + const txResponse = result['tx_response']; + + // if (txResponse.code != null && txResponse.code !== 0) { + // throw new Error(txResponse['raw_log']); + // } + + return Buffer.from(txResponse.txhash, 'base64'); } async experimentalSuggestChain(chainInfo: ChainInfo): Promise { - // Simulate suggesting a chain + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + const newKeystoreEntries = await Promise.all( + Object.entries(KeyChain.getItem(KEYSTORE)).map( + async ([walletId, walletInfo]) => { + const wallet = await generateWallet(walletInfo.cipher, { + prefix: chainInfo.bech32Config.bech32PrefixAccAddr, + }); + + const accounts = await wallet.getAccounts(); + + const newWallet = { + ...walletInfo, + addresses: { + ...walletInfo.addresses, + [chainInfo.chainId]: accounts[0].address, + }, + pubKeys: { + ...walletInfo.pubKeys, + [chainInfo.chainId]: Buffer.from(accounts[0].pubkey), + }, + }; + + return [walletId, newWallet]; + } + ) + ); + + const newKeystore: TValueMap[typeof KEYSTORE] = newKeystoreEntries.reduce( + (res, entry: [string, TWallet]) => (res[entry[0]] = entry[1]) && res, + {} + ); + + KeyChain.setItem(KEYSTORE, newKeystore); + KeyChain.setItem(ACTIVE_WALLET, newKeystore[activeWallet.id]); + + // `chainId` should be added to `CONNECTIONS` if not present. + // since the mock env, no end user approval is required, + // `enable` function can be treated as `add connection` approval + await this.enable(chainInfo.chainId); + + // return newKeystore; } } diff --git a/packages/test/src/mocker/offline-signer.ts b/packages/test/src/mocker/offline-signer.ts new file mode 100644 index 000000000..d0149bd6f --- /dev/null +++ b/packages/test/src/mocker/offline-signer.ts @@ -0,0 +1,99 @@ +import { + AccountData, + AminoSignResponse, + OfflineAminoSigner, + StdSignDoc, +} from '@cosmjs/amino'; +import { Algo, OfflineDirectSigner } from '@cosmjs/proto-signing'; +import { DirectSignResponse } from '@cosmjs/proto-signing/build/signer'; +import { SignDoc } from 'cosmjs-types/cosmos/tx/v1beta1/tx'; +import Long from 'long'; + +import { Mock, MockSignOptions } from '../mock-extension'; + +export class CosmJSOfflineSignerOnlyAmino implements OfflineAminoSigner { + constructor( + protected readonly chainId: string, + protected readonly mock: Mock, + protected signOptions?: MockSignOptions + ) {} + + async getAccounts(): Promise { + const key = await this.mock.getKey(this.chainId); + return [ + { + address: key.bech32Address, + algo: key.algo as Algo, + pubkey: key.pubKey, + }, + ]; + } + + async signAmino( + signerAddress: string, + signDoc: StdSignDoc + ): Promise { + if (this.chainId !== signDoc.chain_id) { + throw new Error('Unmatched chain id with the offline signer'); + } + + const key = await this.mock.getKey(signDoc.chain_id); + + if (key.bech32Address !== signerAddress) { + throw new Error('Unknown signer address'); + } + return this.mock.signAmino( + this.chainId, + signerAddress, + signDoc, + this.signOptions + ); + } + + // Fallback function for the legacy cosmjs implementation before the staragte. + async sign( + signerAddress: string, + signDoc: StdSignDoc + ): Promise { + return this.signAmino(signerAddress, signDoc); + } +} + +export class CosmJSOfflineSigner + extends CosmJSOfflineSignerOnlyAmino + implements OfflineAminoSigner, OfflineDirectSigner +{ + constructor( + protected readonly chainId: string, + protected readonly mock: Mock, + protected signOptions?: MockSignOptions + ) { + super(chainId, mock, signOptions); + } + + async signDirect( + signerAddress: string, + signDoc: SignDoc + ): Promise { + if (this.chainId !== signDoc.chainId) { + throw new Error('Unmatched chain id with the offline signer'); + } + + const key = await this.mock.getKey(signDoc.chainId); + + if (key.bech32Address !== signerAddress) { + throw new Error('Unknown signer address'); + } + + const _signDoc = { + ...signDoc, + accountNumber: Long.fromString(signDoc.accountNumber.toString()), + }; + return this.mock.signDirect( + this.chainId, + signerAddress, + _signDoc, + this.signOptions + ); + } +} diff --git a/packages/test/src/utils.ts b/packages/test/src/utils.ts new file mode 100644 index 000000000..c0372d9dc --- /dev/null +++ b/packages/test/src/utils.ts @@ -0,0 +1,121 @@ +import { Chain } from '@chain-registry/types'; +import { StdSignDoc } from '@cosmjs/amino'; +import { Bip39, Random, stringToPath } from '@cosmjs/crypto'; +import { toBase64 } from '@cosmjs/encoding'; +import { + DirectSecp256k1HdWallet, + DirectSecp256k1HdWalletOptions, +} from '@cosmjs/proto-signing'; +import * as bip32 from 'bip32'; +import * as bip39 from 'bip39'; +import { chains } from 'chain-registry'; +import { v4 as uuidv4 } from 'uuid'; + +import { ACTIVE_WALLET, KeyChain, KEYSTORE, TWallet } from './key-chain'; + +// website mock +export const ORIGIN = 'https://mock.mock'; + +export function getHdPath( + coinType = '118', + addrIndex = '0', + account = '0', + chain = '0' +): string { + return `m/44'/${coinType}'/${account}'/${chain}/${addrIndex}`; +} + +export function generateMnemonic(): string { + return Bip39.encode(Random.getBytes(16)).toString(); +} + +export async function generateWallet( + mnemonic: string, + options?: Partial +) { + return DirectSecp256k1HdWallet.fromMnemonic(mnemonic, options); +} + +export async function initActiveWallet(chains: Chain[], mnemonic?: string) { + const addresses: Record = {}; + const pubKeys: Record = {}; + + const _mnemonic = mnemonic ?? generateMnemonic(); + + for (const chain of chains) { + const { chain_id, bech32_prefix, slip44 } = chain; + const options: Partial = { + prefix: bech32_prefix, + hdPaths: [stringToPath(getHdPath(`${slip44}`))], + }; + const wallet = await generateWallet(_mnemonic, options); + const accounts = await wallet.getAccounts(); + + addresses[chain_id] = accounts[0].address; + pubKeys[chain_id] = Buffer.from(accounts[0].pubkey); + } + + const walletId = uuidv4() as string; + + const wallet: TWallet = { + addressIndex: 0, + name: `Wallet 0`, + cipher: _mnemonic, // cipher: encrypt(_mnemonic, password), + addresses, + pubKeys, + walletType: 'SEED_PHRASE', + id: walletId, + }; + + KeyChain.setItem(KEYSTORE, { [walletId]: wallet }); + KeyChain.setItem(ACTIVE_WALLET, wallet); +} + +export function getChainInfoByChainId(chainId: string): Chain { + return chains.find((chain) => chain.chain_id === chainId); +} + +export function getChildKey(chainId: string, address: string) { + const activeWallet = KeyChain.getItem(ACTIVE_WALLET); + const activeAddress = activeWallet.addresses[chainId]; + + if (address !== activeAddress) { + throw new Error('Signer address does not match wallet address'); + } + + const mnemonic = activeWallet.cipher; // decrypt + const chainInfo = getChainInfoByChainId(chainId); + const hdPath = getHdPath( + chainInfo.slip44 + '', + activeWallet.addressIndex + '' + ); + + const seed = bip39.mnemonicToSeedSync(mnemonic); + const node = bip32.fromSeed(seed); + + return node.derivePath(hdPath); +} + +export function getADR36SignDoc(signer: string, data: string): StdSignDoc { + return { + chain_id: '', + account_number: '1', + sequence: '0', + fee: { gas: '1000', amount: [] }, + msgs: [{ type: 'sign/MsgSignData', value: { signer, data } }], + memo: '', + }; +} + +export async function getContractInfo( + chainId: string, + contractAddress: string +) { + const chainInfo = getChainInfoByChainId(chainId); + const queryBytesStr = toBase64(Buffer.from('{"token_info":{}}')); + const chainRest = chainInfo.apis.rest[0].address; + const url = `${chainRest}/cosmwasm/wasm/v1/contract/${contractAddress}/smart/${queryBytesStr}`; + + const res = await fetch(url); + return res.json(); +}