From 290689cf1b51243e71a7e2ccc4d865fdee9881dd Mon Sep 17 00:00:00 2001 From: HoangVD2 Date: Wed, 11 Dec 2024 17:38:21 +0700 Subject: [PATCH 1/2] feat: add ctrl wallet adapter --- README.md | 1 + packages/adapters/ctrlwallet/LICENSE | 20 + packages/adapters/ctrlwallet/README.md | 91 +++ packages/adapters/ctrlwallet/jest.config.js | 18 + packages/adapters/ctrlwallet/package.json | 54 ++ packages/adapters/ctrlwallet/src/adapter.ts | 552 ++++++++++++++++++ packages/adapters/ctrlwallet/src/error.ts | 5 + packages/adapters/ctrlwallet/src/index.ts | 3 + packages/adapters/ctrlwallet/src/types.ts | 61 ++ packages/adapters/ctrlwallet/src/utils.ts | 24 + .../ctrlwallet/tests/units/adapter.test.ts | 383 ++++++++++++ .../adapters/ctrlwallet/tests/units/mock.ts | 182 ++++++ .../adapters/ctrlwallet/tests/units/utils.ts | 9 + .../adapters/ctrlwallet/tsconfig.all.json | 14 + .../adapters/ctrlwallet/tsconfig.cjs.json | 7 + .../adapters/ctrlwallet/tsconfig.esm.json | 8 + 16 files changed, 1432 insertions(+) create mode 100644 packages/adapters/ctrlwallet/LICENSE create mode 100644 packages/adapters/ctrlwallet/README.md create mode 100644 packages/adapters/ctrlwallet/jest.config.js create mode 100644 packages/adapters/ctrlwallet/package.json create mode 100644 packages/adapters/ctrlwallet/src/adapter.ts create mode 100644 packages/adapters/ctrlwallet/src/error.ts create mode 100644 packages/adapters/ctrlwallet/src/index.ts create mode 100644 packages/adapters/ctrlwallet/src/types.ts create mode 100644 packages/adapters/ctrlwallet/src/utils.ts create mode 100644 packages/adapters/ctrlwallet/tests/units/adapter.test.ts create mode 100644 packages/adapters/ctrlwallet/tests/units/mock.ts create mode 100644 packages/adapters/ctrlwallet/tests/units/utils.ts create mode 100644 packages/adapters/ctrlwallet/tsconfig.all.json create mode 100644 packages/adapters/ctrlwallet/tsconfig.cjs.json create mode 100644 packages/adapters/ctrlwallet/tsconfig.esm.json diff --git a/README.md b/README.md index 717897e..541aa91 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ This repository contains wallet adapters and components for Tron DApps. With out | |Browser Extension | >= 3.16.3 | | [Ledger](https://www.ledger.com/) | - | All versions | | [WalletConnect](https://walletconnect.org) | - | >= v2.0 | +| [CtrlWallet](https://ctrl.xyz//) | - | >= 1.0.0 | > **Note**: In case wallet developers intend to release breaking changes, you can [open an issue here](https://github.com/web3-geek/tronwallet-adapter/issues/new) to inform us, thus enabling us to update the new protocols accordingly. diff --git a/packages/adapters/ctrlwallet/LICENSE b/packages/adapters/ctrlwallet/LICENSE new file mode 100644 index 0000000..3a787d6 --- /dev/null +++ b/packages/adapters/ctrlwallet/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2022-Present, web3-geek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/adapters/ctrlwallet/README.md b/packages/adapters/ctrlwallet/README.md new file mode 100644 index 0000000..598e48c --- /dev/null +++ b/packages/adapters/ctrlwallet/README.md @@ -0,0 +1,91 @@ +# `@tronweb3/tronwallet-adapter-ctrlwallet` + +This package provides an adapter to enable TRON DApps to connect to the [Ctrl Wallet extension](https://chromewebstore.google.com/detail/ctrl-wallet/hmeobnfnfcmdkdcmlblgagmfpfboieaf). + +## Demo + +```typescript +import { CtrlWalletAdapter } from '@tronweb3/tronwallet-adapter-ctrlwallet'; +import TronWeb from 'tronweb'; + +const tronWeb = new TronWeb({ + fullHost: 'https://api.trongrid.io', + headers: { 'TRON-PRO-API-KEY': 'your api key' }, +}); + +const adapter = new CtrlWalletAdapter(); +// connect +await adapter.connect(); + +// then you can get address +console.log(adapter.address); + +// create a send TRX transaction +const unSignedTransaction = await tronWeb.transactionBuilder.sendTrx(targetAddress, 100, adapter.address); +// using adapter to sign the transaction +const signedTransaction = await adapter.signTransaction(unSignedTransaction); +// broadcast the transaction +await tronWeb.trx.sendRawTransaction(signedTransaction); +``` + +## Documentation + +### API + +- `Constructor(config: CtrlWalletAdapterConfig)` + ```typescript + interface CtrlWalletAdapterConfig { + /** + * Set if open Wallet's website url when wallet is not installed. + * Default is true. + */ + openUrlWhenWalletNotFound?: boolean; + /** + * Timeout in millisecond for checking if TronLink wallet exists. + * Default is 30 * 1000ms + */ + checkTimeout?: number; + /** + * Set if open TronLink app using DeepLink on mobile device. + * Default is true. + */ + openTronLinkAppOnMobile?: boolean; + /** + * The icon of your dapp. Used when open TronLink app in mobile device browsers. + * Default is current website icon. + */ + dappIcon?: string; + /** + * The name of your dapp. Used when open TronLink app in mobile device browsers. + * Default is `document.title`. + */ + dappName?: string; + } + ``` +- `network()` method is supported to get current network information. The type of returned value is `Network` as follows: + + ```typescript + export enum NetworkType { + Mainnet = 'Mainnet', + Shasta = 'Shasta', + Nile = 'Nile', + /** + * When use custom node + */ + Unknown = 'Unknown', + } + + export type Network = { + networkType: NetworkType; + chainId: string; + fullNode: string; + solidityNode: string; + eventServer: string; + }; + ``` + +### Caveats + +- **Ctrl Wallet doesn't support `disconnect` by DApp**. As CtrlWalletAdapter doesn't support disconnect by DApp website, call `adapter.disconnect()` won't disconnect from Ctrl Wallet extension really. + +For more information about tronwallet adapters, please refer to [`@web3-geek/tronwallet-adapters`](https://github.com/web3-geek/tronwallet-adapter/tree/main/packages/adapters/adapters) diff --git a/packages/adapters/ctrlwallet/jest.config.js b/packages/adapters/ctrlwallet/jest.config.js new file mode 100644 index 0000000..8951188 --- /dev/null +++ b/packages/adapters/ctrlwallet/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest', + testEnvironment: 'jsdom', + transform: { + '\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, + moduleNameMapper: { + 'bignumber\\.js': '$0', + '(.+)\\.js': '$1', + }, + extensionsToTreatAsEsm: ['.ts'], +}; diff --git a/packages/adapters/ctrlwallet/package.json b/packages/adapters/ctrlwallet/package.json new file mode 100644 index 0000000..91fa3dd --- /dev/null +++ b/packages/adapters/ctrlwallet/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tronweb3/tronwallet-adapter-ctrlwallet", + "version": "1.0.0", + "description": "Wallet adapter for Ctrl Wallet extension and app.", + "keywords": [ + "TRON", + "TronWeb", + "Ctrl Wallet" + ], + "author": "web3-geek", + "repository": { + "type": "git", + "url": "https://github.com/web3-geek/tronwallet-adapter" + }, + "license": "MIT", + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=16", + "pnpm": ">=7" + }, + "main": "./lib/cjs/index.js", + "module": "./lib/esm/index.js", + "types": "./lib/types/index.d.ts", + "exports": { + "require": "./lib/cjs/index.js", + "import": "./lib/esm/index.js", + "types": "./lib/types/index.d.ts" + }, + "files": [ + "lib", + "src", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "shx mkdir -p lib && shx rm -rf lib", + "package": "shx echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json", + "test": "jest", + "test:coverage": "jest --coverage", + "build:umd": "node ../../../scripts/build-umd.js" + }, + "dependencies": { + "@tronweb3/tronwallet-abstract-adapter": "workspace:^" + }, + "devDependencies": { + "@testing-library/dom": "^8.20.0", + "jest": "29.6.4", + "jest-environment-jsdom": "^29.6.4", + "shx": "^0.3.4" + } +} diff --git a/packages/adapters/ctrlwallet/src/adapter.ts b/packages/adapters/ctrlwallet/src/adapter.ts new file mode 100644 index 0000000..b8b8a33 --- /dev/null +++ b/packages/adapters/ctrlwallet/src/adapter.ts @@ -0,0 +1,552 @@ +import { + Adapter, + AdapterState, + isInBrowser, + WalletReadyState, + WalletSignMessageError, + WalletNotFoundError, + WalletDisconnectedError, + WalletConnectionError, + WalletSignTransactionError, + WalletSwitchChainError, + WalletGetNetworkError, + isInMobileBrowser, + NetworkType, +} from '@tronweb3/tronwallet-abstract-adapter'; +import type { + Transaction, + SignedTransaction, + AdapterName, + BaseAdapterConfig, + Network, +} from '@tronweb3/tronwallet-abstract-adapter'; +import type { + AccountsChangedEventData, + NetworkChangedEventData, + ReqestAccountsResponse, + Tron, + TronAccountsChangedCallback, + TronChainChangedCallback, + TronLinkMessageEvent, + TronWeb, +} from './types.js'; +import { supportTron, supportTronLink } from './utils.js'; +export interface TronLink { + ready: boolean; + tronWeb: TronWeb; + request(config: Record): Promise; +} +export const chainIdNetworkMap: Record = { + '0x2b6653dc': NetworkType.Mainnet, + '0x94a9059e': NetworkType.Shasta, + '0xcd8690dc': NetworkType.Nile, +}; + +export async function getNetworkInfoByTronWeb(tronWeb: TronWeb) { + const { blockID = '' } = await tronWeb.trx.getBlockByNumber(0); + const chainId = `0x${blockID.slice(-8)}`; + return { + networkType: chainIdNetworkMap[chainId] || NetworkType.Unknown, + chainId, + fullNode: tronWeb.fullNode?.host || '', + solidityNode: tronWeb.solidityNode?.host || '', + eventServer: tronWeb.eventServer?.host || '', + }; +} +declare global { + interface Window { + tronLink?: TronLink; + tronWeb?: TronWeb & { ready?: boolean }; + // @ts-ignore + tron?: Tron; + } +} +export interface CtrlWalletConfig extends BaseAdapterConfig { + checkTimeout?: number; + openTronLinkAppOnMobile?: boolean; +} + +export const CtrlWalletName = 'CtrlWallet' as AdapterName<'CtrlWallet'>; + +export class CtrlWalletAdapter extends Adapter { + name = CtrlWalletName; + url = 'https://ctrl.xyz/'; + icon = + 'data:image/png;base64,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'; + config: Required; + private _readyState: WalletReadyState = isInBrowser() ? WalletReadyState.Loading : WalletReadyState.NotFound; + private _state: AdapterState = AdapterState.Loading; + private _connecting: boolean; + private _wallet: TronLink | Tron | null; + private _address: string | null; + // https://github.com/tronprotocol/tips/blob/master/tip-1193.md + private _supportNewTronProtocol = false; + // record if first connect event has emitted or not + + constructor(config: CtrlWalletConfig = {}) { + super(); + const { + checkTimeout = 30 * 1000, + openUrlWhenWalletNotFound = true, + openTronLinkAppOnMobile = true, + } = config; + if (typeof checkTimeout !== 'number') { + throw new Error('[CtrlWallet] config.checkTimeout should be a number'); + } + this.config = { + checkTimeout, + openTronLinkAppOnMobile, + openUrlWhenWalletNotFound, + }; + this._connecting = false; + this._wallet = null; + this._address = null; + + if (!isInBrowser()) { + this._readyState = WalletReadyState.NotFound; + this.setState(AdapterState.NotFound); + return; + } + if (supportTron() || (isInMobileBrowser() && (window.tronLink || window.tronWeb))) { + this._readyState = WalletReadyState.Found; + this._updateWallet(); + } else { + this._checkWallet().then(() => { + if (this.connected) { + this.emit('connect', this.address || ''); + } + }); + } + } + + get address() { + return this._address; + } + + get state() { + return this._state; + } + get readyState() { + return this._readyState; + } + + get connecting() { + return this._connecting; + } + + /** + * Get network information used by TronLink. + * @returns {Network} Current network information. + */ + async network(): Promise { + try { + await this._checkWallet(); + if (this.state !== AdapterState.Connected) throw new WalletDisconnectedError(); + const tronWeb = this._wallet?.tronWeb || window.tronWeb; + if (!tronWeb) throw new WalletDisconnectedError(); + try { + return await getNetworkInfoByTronWeb(tronWeb); + } catch (e: any) { + throw new WalletGetNetworkError(e?.message, e); + } + } catch (e: any) { + this.emit('error', e); + throw e; + } + } + + async connect(): Promise { + try { + if (this.connected || this.connecting) return; + await this._checkWallet(); + if (this.state === AdapterState.NotFound) { + if (this.config.openUrlWhenWalletNotFound !== false && isInBrowser()) { + window.open(this.url, '_blank'); + } + throw new WalletNotFoundError(); + } + // lower version only support window.tronWeb, no window.tronLink + if (!this._wallet) return; + this._connecting = true; + if (this._supportNewTronProtocol) { + const wallet = this._wallet as Tron; + try { + const res = await wallet.request({ method: 'eth_requestAccounts' }); + const address = res[0]; + this.setAddress(address); + this.setState(AdapterState.Connected); + this._listenTronEvent(); + } catch (error: any) { + let message = error?.message || error || 'Connect TronLink wallet failed.'; + if (error.code === -32002) { + message = + 'The same DApp has already initiated a request to connect to TronLink wallet, and the pop-up window has not been closed.'; + } + if (error.code === 4001) { + message = 'The user rejected connection.'; + } + throw new WalletConnectionError(message, error); + } + } else if (window.tronLink) { + const wallet = this._wallet as TronLink; + try { + const res = await wallet.request({ method: 'tron_requestAccounts' }); + if (!res) { + // 1. wallet is locked + // 2. tronlink is first installed and there is no wallet account + throw new WalletConnectionError('TronLink wallet is locked or no wallet account is avaliable.'); + } + if (res.code === 4000) { + throw new WalletConnectionError( + 'The same DApp has already initiated a request to connect to TronLink wallet, and the pop-up window has not been closed.' + ); + } + if (res.code === 4001) { + throw new WalletConnectionError('The user rejected connection.'); + } + } catch (error: any) { + throw new WalletConnectionError(error?.message, error); + } + + const address = wallet.tronWeb.defaultAddress?.base58 || ''; + this.setAddress(address); + this.setState(AdapterState.Connected); + this._listenTronLinkEvent(); + } else if (window.tronWeb) { + const wallet = this._wallet as TronLink; + const address = wallet.tronWeb.defaultAddress?.base58 || ''; + this.setAddress(address); + this.setState(AdapterState.Connected); + } else { + throw new WalletConnectionError('Cannot connect wallet.'); + } + this.connected && this.emit('connect', this.address || ''); + } catch (error: any) { + this.emit('error', error); + throw error; + } finally { + this._connecting = false; + } + } + + async disconnect(): Promise { + if (this._supportNewTronProtocol) { + this._stopListenTronEvent(); + } else { + this._stopListenTronLinkEvent(); + } + if (this.state !== AdapterState.Connected) { + return; + } + this.setAddress(null); + this.setState(AdapterState.Disconnect); + this.emit('disconnect'); + } + + async signTransaction(transaction: Transaction, privateKey?: string): Promise { + try { + const wallet = await this.checkAndGetWallet(); + + try { + return await wallet.tronWeb.trx.sign(transaction, privateKey); + } catch (error: any) { + if (error instanceof Error) { + throw new WalletSignTransactionError(error.message, error); + } else { + throw new WalletSignTransactionError(error, new Error(error)); + } + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + async multiSign( + transaction: Transaction, + privateKey?: string | false, + permissionId?: number + ): Promise { + try { + const wallet = await this.checkAndGetWallet(); + + try { + return await wallet.tronWeb.trx.multiSign(transaction, privateKey, permissionId); + } catch (error: any) { + if (error instanceof Error) { + throw new WalletSignTransactionError(error.message, error); + } else { + throw new WalletSignTransactionError(error, new Error(error)); + } + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + async signMessage(message: string, privateKey?: string): Promise { + try { + const wallet = await this.checkAndGetWallet(); + try { + return await wallet.tronWeb.trx.signMessageV2(message, privateKey); + } catch (error: any) { + if (error instanceof Error) { + throw new WalletSignMessageError(error.message, error); + } else { + throw new WalletSignMessageError(error, new Error(error)); + } + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + /** + * Switch to target chain. If current chain is the same as target chain, the call will success immediately. + * Available chainIds: + * - Mainnet: 0x2b6653dc + * - Shasta: 0x94a9059e + * - Nile: 0xcd8690dc + * @param chainId chainId + */ + async switchChain(chainId: string) { + try { + await this._checkWallet(); + if (this.state === AdapterState.NotFound) { + if (this.config.openUrlWhenWalletNotFound !== false && isInBrowser()) { + window.open(this.url, '_blank'); + } + throw new WalletNotFoundError(); + } + if (!this._supportNewTronProtocol) { + throw new WalletSwitchChainError("Current version of TronLink doesn't support switch chain operation."); + } + const wallet = this._wallet as Tron; + try { + await wallet.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId }], + }); + } catch (e: any) { + throw new WalletSwitchChainError(e?.message || e, e instanceof Error ? e : new Error(e)); + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + private async checkAndGetWallet() { + await this._checkWallet(); + if (this.state !== AdapterState.Connected) throw new WalletDisconnectedError(); + const wallet = this._wallet; + if (!wallet || !wallet.tronWeb) throw new WalletDisconnectedError(); + return wallet as Tron & { tronWeb: TronWeb }; + } + + private _listenTronLinkEvent() { + this._stopListenTronLinkEvent(); + window.addEventListener('message', this._tronLinkMessageHandler); + } + + private _stopListenTronLinkEvent() { + window.removeEventListener('message', this._tronLinkMessageHandler); + } + + private _tronLinkMessageHandler = (e: TronLinkMessageEvent) => { + const message = e.data?.message; + if (!message) { + return; + } + if (message.action === 'accountsChanged') { + setTimeout(() => { + const preAddr = this.address || ''; + if ((this._wallet as TronLink)?.ready) { + const address = (message.data as AccountsChangedEventData).address; + this.setAddress(address); + this.setState(AdapterState.Connected); + } else { + this.setAddress(null); + this.setState(AdapterState.Disconnect); + } + this.emit('accountsChanged', this.address || '', preAddr); + if (!preAddr && this.address) { + this.emit('connect', this.address); + } else if (preAddr && !this.address) { + this.emit('disconnect'); + } + }, 200); + } else if (message.action === 'setNode') { + this.emit('chainChanged', { chainId: (message.data as NetworkChangedEventData)?.node?.chainId || '' }); + } else if (message.action === 'connect') { + const address = (this._wallet as TronLink).tronWeb?.defaultAddress?.base58 || ''; + this.setAddress(address); + this.setState(AdapterState.Connected); + this.emit('connect', address); + } else if (message.action === 'disconnect') { + this.setAddress(null); + this.setState(AdapterState.Disconnect); + this.emit('disconnect'); + } + }; + + // following code is for TIP-1193 + private _listenTronEvent() { + this._stopListenTronEvent(); + this._stopListenTronLinkEvent(); + const wallet = this._wallet as Tron; + wallet.on('chainChanged', this._onChainChanged); + wallet.on('accountsChanged', this._onAccountsChanged); + } + + private _stopListenTronEvent() { + const wallet = this._wallet as Tron; + wallet.removeListener('chainChanged', this._onChainChanged); + wallet.removeListener('accountsChanged', this._onAccountsChanged); + } + + private _onChainChanged: TronChainChangedCallback = (data) => { + this.emit('chainChanged', data); + }; + + private _onAccountsChanged: TronAccountsChangedCallback = () => { + const preAddr = this.address || ''; + const curAddr = (this._wallet?.tronWeb && this._wallet?.tronWeb.defaultAddress?.base58) || ''; + if (!curAddr) { + // change to a new address and if it's disconnected, data will be empty + // tronlink will emit accountsChanged many times, only process when connected + this.setAddress(null); + this.setState(AdapterState.Disconnect); + } else { + const address = curAddr as string; + this.setAddress(address); + this.setState(AdapterState.Connected); + } + this.emit('accountsChanged', this.address || '', preAddr); + if (!preAddr && this.address) { + this.emit('connect', this.address); + } else if (preAddr && !this.address) { + this.emit('disconnect'); + } + }; + + private _checkPromise: Promise | null = null; + /** + * check if wallet exists by interval, the promise only resolve when wallet detected or timeout + * @returns if wallet exists + */ + private _checkWallet(): Promise { + if (this.readyState === WalletReadyState.Found) { + return Promise.resolve(true); + } + if (this._checkPromise) { + return this._checkPromise; + } + const interval = 100; + const checkTronTimes = Math.floor(2000 / interval); + const maxTimes = Math.floor(this.config.checkTimeout / interval); + let times = 0, + timer: ReturnType; + this._checkPromise = new Promise((resolve) => { + const check = () => { + times++; + const isSupport = times < checkTronTimes && !isInMobileBrowser() ? supportTron() : supportTronLink(); + if (isSupport || times > maxTimes) { + timer && clearInterval(timer); + this._readyState = isSupport ? WalletReadyState.Found : WalletReadyState.NotFound; + this._updateWallet(); + this.emit('readyStateChanged', this.readyState); + resolve(isSupport); + } + }; + timer = setInterval(check, interval); + check(); + }); + return this._checkPromise; + } + + private _updateWallet = () => { + let state = this.state; + let address = this.address; + if (isInMobileBrowser()) { + if (window.tronLink) { + this._wallet = window.tronLink; + } else { + this._wallet = { + ready: !!window.tronWeb?.defaultAddress, + tronWeb: window.tronWeb, + request: () => Promise.resolve(true) as any, + } as TronLink; + } + address = this._wallet.tronWeb?.defaultAddress?.base58 || null; + state = address ? AdapterState.Connected : AdapterState.Disconnect; + } else if (window.tron && window.tron.isTronLink) { + this._supportNewTronProtocol = true; + this._wallet = window.tron; + this._listenTronEvent(); + address = (this._wallet.tronWeb && this._wallet.tronWeb?.defaultAddress?.base58) || null; + state = address ? AdapterState.Connected : AdapterState.Disconnect; + } else if (window.tronLink) { + this._wallet = window.tronLink; + this._listenTronLinkEvent(); + address = this._wallet.tronWeb?.defaultAddress?.base58 || null; + state = this._wallet.ready ? AdapterState.Connected : AdapterState.Disconnect; + } else if (window.tronWeb) { + // fake tronLink + this._wallet = { + ready: window.tronWeb.ready, + tronWeb: window.tronWeb, + request: () => Promise.resolve(true) as any, + } as TronLink; + address = this._wallet.tronWeb.defaultAddress?.base58 || null; + state = this._wallet.ready ? AdapterState.Connected : AdapterState.Disconnect; + } else { + // no tronlink support + this._wallet = null; + address = null; + state = AdapterState.NotFound; + } + // In TronLink App, account should be connected + if (isInMobileBrowser() && state === AdapterState.Disconnect) { + this.checkForWalletReadyForApp(); + } + this.setAddress(address); + this.setState(state); + }; + + private checkReadyInterval: ReturnType | null = null; + private checkForWalletReadyForApp() { + if (this.checkReadyInterval) { + return; + } + let times = 0; + const maxTimes = Math.floor(this.config.checkTimeout / 200); + const check = () => { + if (window.tronLink ? window.tronLink.tronWeb?.defaultAddress : window.tronWeb?.defaultAddress) { + this.checkReadyInterval && clearInterval(this.checkReadyInterval); + this.checkReadyInterval = null; + this._updateWallet(); + this.emit('connect', this.address || ''); + } else if (times > maxTimes) { + this.checkReadyInterval && clearInterval(this.checkReadyInterval); + this.checkReadyInterval = null; + } else { + times++; + } + }; + this.checkReadyInterval = setInterval(check, 200); + } + private setAddress(address: string | null) { + this._address = address; + } + + private setState(state: AdapterState) { + const preState = this.state; + if (state !== preState) { + this._state = state; + this.emit('stateChanged', state); + } + } +} diff --git a/packages/adapters/ctrlwallet/src/error.ts b/packages/adapters/ctrlwallet/src/error.ts new file mode 100644 index 0000000..2668e8c --- /dev/null +++ b/packages/adapters/ctrlwallet/src/error.ts @@ -0,0 +1,5 @@ +import { WalletError } from '@tronweb3/tronwallet-abstract-adapter'; + +export class WalletGetNetworkError extends WalletError { + name = 'WalletGetNetworkError'; +} \ No newline at end of file diff --git a/packages/adapters/ctrlwallet/src/index.ts b/packages/adapters/ctrlwallet/src/index.ts new file mode 100644 index 0000000..2432458 --- /dev/null +++ b/packages/adapters/ctrlwallet/src/index.ts @@ -0,0 +1,3 @@ +export * from './adapter.js'; +export * from './types.js'; +export * from './utils.js'; diff --git a/packages/adapters/ctrlwallet/src/types.ts b/packages/adapters/ctrlwallet/src/types.ts new file mode 100644 index 0000000..e828bad --- /dev/null +++ b/packages/adapters/ctrlwallet/src/types.ts @@ -0,0 +1,61 @@ +import type { NetworkNodeConfig } from '@tronweb3/tronwallet-abstract-adapter'; +import { TronWeb } from '@tronweb3/tronwallet-abstract-adapter'; + +export interface TronLinkWalletEvents { + connect(...args: unknown[]): unknown; + disconnect(...args: unknown[]): unknown; +} + +export { TronWeb }; +export interface ReqestAccountsResponse { + code: 200 | 4000 | 4001; + message: string; +} + +export interface TronLinkMessageEvent { + data: { + isTronLink: boolean; + message: { + action: 'setAccount' | 'accountsChanged' | 'setNode' | 'connect' | 'disconnect'; + data?: AccountsChangedEventData | NetworkChangedEventData; + }; + }; +} +export interface AccountsChangedEventData { + // tronlink will return false when users lock accounts, treat it as string + address: string; +} + +export interface NetworkChangedEventData { + node: NetworkNodeConfig; + connectNode: NetworkNodeConfig; +} + +interface TronRequestArguments { + readonly method: string; + readonly params?: unknown[] | object; +} +interface ProviderRpcError extends Error { + code: number; + message: string; + data?: unknown; +} +type TronEvent = 'connect' | 'disconnect' | 'chainChanged' | 'accountsChanged'; + +export type TronConnectCallback = (data: { chainId: string }) => void; +export type TronChainChangedCallback = TronConnectCallback; +export type TronDisconnectCallback = (error: ProviderRpcError) => void; +export type TronAccountsChangedCallback = (data: [string?]) => void; +export interface Tron { + request(args: { method: 'eth_requestAccounts' }): Promise<[string]>; + request(args: TronRequestArguments): Promise; + + on(event: 'connect', cb: TronConnectCallback): void; + on(event: 'disconnect', cb: TronDisconnectCallback): void; + on(event: 'chainChanged', cb: TronChainChangedCallback): void; + on(event: 'accountsChanged', cb: TronAccountsChangedCallback): void; + + removeListener(event: TronEvent, cb: unknown): void; + tronWeb: TronWeb | false; + isTronLink: boolean; +} diff --git a/packages/adapters/ctrlwallet/src/utils.ts b/packages/adapters/ctrlwallet/src/utils.ts new file mode 100644 index 0000000..20e5904 --- /dev/null +++ b/packages/adapters/ctrlwallet/src/utils.ts @@ -0,0 +1,24 @@ +import type { Tron } from './types.js'; + +export function supportTron() { + return !!(window.tron && window.tron.isTronLink); +} +export function supportTronLink() { + return !!(supportTron() || window.tronLink || window.tronWeb); +} + +export async function waitTronReady(tronObj: Tron | any) { + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (tronObj.tronWeb) { + clearInterval(interval); + clearTimeout(timeout); + resolve(); + } + }, 50); + const timeout = setTimeout(() => { + clearInterval(interval); + reject('`window.xfi.tron.tronweb` is not ready.'); + }, 2000); + }); +} \ No newline at end of file diff --git a/packages/adapters/ctrlwallet/tests/units/adapter.test.ts b/packages/adapters/ctrlwallet/tests/units/adapter.test.ts new file mode 100644 index 0000000..7270c58 --- /dev/null +++ b/packages/adapters/ctrlwallet/tests/units/adapter.test.ts @@ -0,0 +1,383 @@ +import { + WalletConnectionError, + WalletDisconnectedError, + WalletNotFoundError, + WalletSwitchChainError, + AdapterState, +} from '@tronweb3/tronwallet-abstract-adapter'; +import { CtrlWalletAdapter } from '../../src/index.js'; +import { wait, ONE_MINUTE } from './utils.js'; +import { MockTron, MockTronLink } from './mock.js'; +import { waitFor } from '@testing-library/dom'; +const noop = () => { + // +}; +window.open = jest.fn(); +beforeEach(function () { + jest.useFakeTimers(); + global.document = window.document; + global.navigator = window.navigator; + window.tronLink = undefined; + window.tron = undefined; +}); +describe('CtrlWalletAdapter', function () { + describe('#adapter()', function () { + test('constructor', () => { + const adapter = new CtrlWalletAdapter(); + expect(adapter.name).toEqual('CtrlWallet'); + expect(adapter).toHaveProperty('icon'); + expect(adapter).toHaveProperty('url'); + expect(adapter).toHaveProperty('state'); + expect(adapter).toHaveProperty('address'); + expect(adapter).toHaveProperty('connecting'); + expect(adapter).toHaveProperty('connected'); + + expect(adapter).toHaveProperty('connect'); + expect(adapter).toHaveProperty('disconnect'); + expect(adapter).toHaveProperty('signMessage'); + expect(adapter).toHaveProperty('signTransaction'); + expect(adapter).toHaveProperty('switchChain'); + + expect(adapter).toHaveProperty('on'); + expect(adapter).toHaveProperty('off'); + }); + + test('should work fine when Ctrl Wallet is not installed', async function () { + (window as any).tronLink = undefined; + const adapter = new CtrlWalletAdapter(); + expect(adapter.state).toEqual(AdapterState.Loading); + expect(adapter.connected).toEqual(false); + jest.advanceTimersByTime(ONE_MINUTE); + await Promise.resolve(); + expect(adapter.state).toEqual(AdapterState.NotFound); + }); + test('should work fine when Ctrl Wallet is installed but not connected', function () { + (window as any).tronLink = { + ready: false, + tronWeb: false, + request: noop, + }; + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + expect(adapter.state).toEqual(AdapterState.Disconnect); + expect(adapter.connected).toEqual(false); + }); + test('should work fine when Ctrl Wallet is connected', function () { + (window as any).tronLink = { + ready: true, + tronWeb: { + defaultAddress: { base58: 'xxx' }, + }, + }; + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + expect(adapter.state).toEqual(AdapterState.Connected); + expect(adapter.connected).toEqual(true); + expect(adapter.address).toEqual('xxx'); + }); + }); + describe('Tron protocol for TIP1193', function () { + beforeEach(() => { + jest.useFakeTimers(); + }); + test('should work fine when tron is disconnected', async function () { + const tron = ((window as any).tron = new MockTron('')); + tron.request = jest.fn(() => { + return Promise.resolve(['xxx']); + }); + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + expect(adapter.state).toEqual(AdapterState.Disconnect); + tron._unlock(); + tron._setAddress('xxx'); + await adapter.connect(); + jest.advanceTimersByTime(1000); + expect(adapter.state).toEqual(AdapterState.Connected); + expect(adapter.address).toEqual('xxx'); + }); + test('should work fine when tron is connected', async function () { + const onMethod = jest.fn(); + const removeListenerMethod = jest.fn(); + let tron: MockTron; + (window as any).tron = tron = new MockTron('xxx'); + tron._unlock(); + tron.on = onMethod; + tron.removeListener = removeListenerMethod; + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + expect(adapter.state).toEqual(AdapterState.Connected); + expect(adapter.address).toEqual('xxx'); + // accountsChanged, chainChanged + expect(onMethod).toHaveBeenCalledTimes(2); + + await adapter.disconnect(); + expect(removeListenerMethod).toHaveBeenCalled(); + }); + }); + describe('#connect()', function () { + test('should throw error when Ctrl Wallet is not installed', async function () { + (window as any).tronLink = undefined; + const adapter = new CtrlWalletAdapter(); + expect(adapter.connect()).rejects.toThrow(WalletNotFoundError); + }); + test('should throw error when Ctrl Wallet is locked', async function () { + const address = 'xxxxx'; + (window as any).tronLink = { + ready: true, + tronWeb: { + defaultAddress: { + base58: address, + }, + }, + request: function () { + return Promise.resolve(''); + }, + }; + const connecor = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + try { + await connecor.connect(); + } catch (e) { + expect(e).toBeInstanceOf(WalletConnectionError); + } + }); + test('should throw error when user denied the connection', async function () { + const address = 'xxxxx'; + (window as any).tronLink = { + ready: true, + tronWeb: { + defaultAddress: { + base58: address, + }, + }, + request: function () { + return Promise.resolve({ code: 4001 }); + }, + }; + const connecor = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + try { + await connecor.connect(); + } catch (e) { + expect(e).toBeInstanceOf(WalletConnectionError); + } + }); + test('should throw error when last connection is not completed', async function () { + const address = 'xxxxx'; + (window as any).tronLink = { + ready: true, + tronWeb: { + defaultAddress: { + base58: address, + }, + }, + request: function () { + return Promise.resolve({ code: 4000 }); + }, + }; + const connecor = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + try { + await connecor.connect(); + } catch (e) { + expect(e).toBeInstanceOf(WalletConnectionError); + } + }); + test('should work fine when TronLink is installed', async function () { + const address = 'xxxxx'; + (window as any).tronLink = { + ready: true, + tronWeb: { + defaultAddress: { + base58: address, + }, + }, + request: noop, + }; + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + await adapter.connect(); + expect(adapter.state).toEqual(AdapterState.Connected); + expect(adapter.address).toEqual(address); + expect(adapter.connected).toEqual(true); + }); + }); + describe('#signMessage()', function () { + test('should throw Disconnected error when Ctrl Wallet is not installed', async function () { + jest.useFakeTimers(); + (window as any).tronLink = undefined; + const adapter = new CtrlWalletAdapter(); + const res = adapter.signMessage('some str'); + jest.advanceTimersByTime(ONE_MINUTE); + expect(res).rejects.toThrow(WalletDisconnectedError); + }); + test('should throw Disconnected error when Ctrl Wallet is disconnected', async function () { + (window as any).tronLink = { + ready: false, + }; + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + expect(adapter.signMessage('some str')).rejects.toThrow(WalletDisconnectedError); + }); + test('should work fine when Ctrl Wallet is connected', async function () { + const tronLink = ((window as any).tronLink = new MockTronLink('address')); + tronLink.tronWeb.trx.signMessageV2 = () => Promise.resolve('123') as any; + tronLink._unlock(); + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(ONE_MINUTE); + await adapter.connect(); + const signedMsg = await adapter.signMessage('some str'); + expect(signedMsg).toEqual('123'); + }); + }); + describe('#signTransaction()', function () { + test('should throw Disconnected error when Ctrl Wallet is not installed', async function () { + (window as any).tronLink = undefined; + const adapter = new CtrlWalletAdapter(); + expect(adapter.signTransaction({} as any)).rejects.toThrow(WalletDisconnectedError); + }); + test('should throw Disconnected error when Ctrl Wallet is disconnected', async function () { + (window as any).tronLink = { + ready: false, + }; + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + try { + await adapter.signTransaction({} as any); + } catch (e) { + expect(e).toBeInstanceOf(WalletDisconnectedError); + } + }); + test('should work fine when Ctrl Wallet is connected', async function () { + jest.useFakeTimers(); + const tronLink = ((window as any).tronLink = new MockTronLink('address')); + tronLink._unlock(); + tronLink.tronWeb.trx.sign = () => Promise.resolve('123') as any; + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(ONE_MINUTE); + await adapter.connect(); + await Promise.resolve(); + const signedTransaction = await adapter.signTransaction({} as any); + expect(signedTransaction).toEqual('123'); + }); + }); + + describe('#switchChain', function () { + test('should throw error and open link when Ctrl Wallet is not found', async function () { + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(ONE_MINUTE); + await expect(adapter.switchChain('0x39483')).rejects.toThrow('The wallet is not found.'); + expect(window.open).toBeCalled(); + }); + test('should throw error when Ctrl Wallet do not support Tron protocol', async function () { + (window as any).tronLink = { + ready: false, + }; + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + await expect(adapter.switchChain('0x39483')).rejects.toThrowError(WalletSwitchChainError); + }); + test('should work fine when Ctrl Wallet support Tron protocol', async function () { + (window as any).tron = new MockTron('address'); + window.tron!.request = jest.fn(); + const adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + await adapter.switchChain('0x39483'); + expect(window.tron!.request).toBeCalledTimes(1); + }); + }); +}); + +describe('Events should work fine', function () { + let tronLink: MockTronLink; + let adapter: CtrlWalletAdapter; + beforeEach(() => { + jest.useFakeTimers(); + tronLink = window.tronLink = new MockTronLink('address'); + tronLink._unlock(); + adapter = new CtrlWalletAdapter(); + }); + test('connect event should work fine', async () => { + const _onConnect = jest.fn(); + tronLink = window.tronLink = new MockTronLink(''); + const adapter = new CtrlWalletAdapter(); + adapter.on('connect', _onConnect); + jest.advanceTimersByTime(ONE_MINUTE); + expect(adapter.state).toEqual(AdapterState.Disconnect); + expect(adapter.address).toEqual(null); + setTimeout(() => { + tronLink._setAddress('address'); + tronLink._unlock(); + tronLink._emit('connect', 'address'); + }, 100); + jest.advanceTimersByTime(3000); + await wait(); + expect(_onConnect).toHaveBeenCalled(); + }); + describe('accountsChanged event should work fine', () => { + let tronLink: MockTronLink; + let adapter: CtrlWalletAdapter; + beforeEach(() => { + jest.useFakeTimers(); + tronLink = window.tronLink = new MockTronLink('address'); + tronLink._unlock(); + adapter = new CtrlWalletAdapter(); + jest.advanceTimersByTime(3000); + }); + test('when switch to a connected account', async () => { + const _onAccountsChanged = jest.fn(); + const _onConnect = jest.fn(); + + adapter.on('accountsChanged', _onAccountsChanged); + adapter.on('connect', _onConnect); + tronLink._setAddress('address'); + tronLink._emit('accountsChanged', { address: 'address2' }); + await wait(); + expect(_onAccountsChanged).toHaveBeenCalled(); + expect(_onConnect).not.toHaveBeenCalled(); + }); + test('when switch to a disconnected account', async () => { + const _onAccountsChanged = jest.fn(); + const _onDisconnect = jest.fn(); + adapter.on('accountsChanged', _onAccountsChanged); + adapter.on('disconnect', _onDisconnect); + tronLink._setAddress('address'); + tronLink.ready = false; + tronLink._emit('accountsChanged', { address: 'address' }); + jest.advanceTimersByTime(200); + await Promise.resolve(); + expect(_onAccountsChanged).toHaveBeenCalled(); + expect(_onDisconnect).toHaveBeenCalled(); + }); + }); + + test('chainChanged event should work fine', () => { + const _onChainChanged = jest.fn(); + adapter.on('chainChanged', _onChainChanged); + jest.advanceTimersByTime(3000); + tronLink._emit('setNode', { address: 'address' }); + expect(_onChainChanged).toHaveBeenCalled(); + }); + + test('disconnect event should work fine', async () => { + tronLink._unlock(); + const _onDisconnect = jest.fn(); + const adapter = new CtrlWalletAdapter(); + adapter.on('disconnect', _onDisconnect); + jest.advanceTimersByTime(3000); + tronLink._emit('disconnect', {}); + waitFor(() => { + expect(_onDisconnect).toHaveBeenCalled(); + }); + }); + + test('empty message should work fine', () => { + tronLink._unlock(); + const _onDisconnect = jest.fn(); + const adapter = new CtrlWalletAdapter(); + adapter.on('disconnect', _onDisconnect); + jest.advanceTimersByTime(3000); + tronLink._emit('disconnect', false); + expect(_onDisconnect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/ctrlwallet/tests/units/mock.ts b/packages/adapters/ctrlwallet/tests/units/mock.ts new file mode 100644 index 0000000..4ff20f0 --- /dev/null +++ b/packages/adapters/ctrlwallet/tests/units/mock.ts @@ -0,0 +1,182 @@ +// @ts-nocheck +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { TronLink } from '../../src/adapter.js'; +import type { TronWeb } from '../../src/types.js'; +import type { Tron } from '../../src/types.js'; + +export class MockTronWeb implements TronWeb { + fullNode = { host: 'http://api.nileex.io' }; + solidityNode = { host: 'http://api.nileex.io' }; + eventServer = { host: 'http://api.nileex.io' }; + + defaultAddress: { + base58: string; + hex: string; + name: string; + type: number; + } = { base58: '', hex: '', type: 1, name: '' }; + ready = true; + trx = { + sign(_: unknown): Promise { + return Promise.resolve(''); + }, + signMessageV2(_: unknown): Promise { + return Promise.resolve(''); + }, + multiSign() { + return Promise.resolve(); + }, + async getBlockByNumber() { + return Promise.resolve({ + blockID: '0000000a93d9e9372efcd8690dc', + }); + }, + }; + constructor(address: string) { + this.defaultAddress.base58 = address; + } + + toHex(_: string): string { + return ''; + } +} + +export class MockBaseTronLink { + protected _tronWeb: TronWeb = new MockTronWeb(''); + protected locked = true; + + constructor(address: string) { + this._setAddress(address); + } + + get tronWeb() { + return this.locked ? false : !this._tronWeb.defaultAddress!.base58 ? false : this._tronWeb; + } + + _lock() { + this.locked = true; + } + _unlock() { + this.locked = false; + } + + _setAddress(address: string) { + this._tronWeb.defaultAddress!.base58 = address; + } + + request(args: { method: any; params: unknown[] | Record }): Promise { + return Promise.reject('No provide request result.'); + } +} +export class MockTron extends MockBaseTronLink implements Tron { + private listeners: Record unknown)[]> = {}; + isTronLink = true; + constructor(address?: string) { + super(address || ''); + } + + on(event: string, cb: any) { + if (this.listeners[event]) { + this.listeners[event].push(cb); + } else { + this.listeners[event] = [cb]; + } + } + removeListener(event: string, cb: unknown) { + if (this.listeners[event]) { + const idx = this.listeners[event].findIndex((listener) => listener === cb); + this.listeners[event].splice(idx, 1); + } + } + + _unlock() { + this.locked = false; + // const address = this._tronWeb.defaultAddress?.base58; + // this._emit('accountsChanged', !address ? '' : [address]); + } + + _emit(event: string, ...params: unknown[]) { + if (this.listeners[event]) { + this.listeners[event].forEach((cb) => { + cb(...params); + }); + } + } + _destroy() { + this.listeners = null; + window.tron = undefined; + } +} +export class MockTronLink extends MockBaseTronLink implements TronLinkWallet { + ready = false; + private listeners: Record unknown)[]> = {}; + + constructor(address?: string) { + super(address || ''); + this._tronWeb = new MockTronWeb(address || ''); + window.addEventListener = this._on; + window.postMessage = this._emit; + } + + get tronWeb() { + return this._tronWeb; + } + _lock() { + this.ready = false; + this.locked = true; + this._tronWeb.ready = false; + window.postMessage( + { + message: { + action: 'accountsChanged', + data: { + address: false, + }, + }, + }, + '*' + ); + } + + _unlock() { + this.locked = false; + this.ready = !!this._tronWeb.defaultAddress?.base58; + window.postMessage( + { + message: { + action: 'accountsChanged', + data: { + address: this._tronWeb.defaultAddress?.base58, + }, + }, + }, + '*' + ); + } + + _on = (event: string, cb: any) => { + if (this.listeners[event]) { + this.listeners[event].push(cb); + } else { + this.listeners[event] = [cb]; + } + }; + + _emit = (event: string, params: unknown) => { + if (this.listeners['message']) { + this.listeners['message'].forEach((cb) => { + cb({ + data: { + message: !params + ? undefined + : { + action: event, + data: params, + }, + }, + }); + }); + } + }; +} diff --git a/packages/adapters/ctrlwallet/tests/units/utils.ts b/packages/adapters/ctrlwallet/tests/units/utils.ts new file mode 100644 index 0000000..7e0d6bf --- /dev/null +++ b/packages/adapters/ctrlwallet/tests/units/utils.ts @@ -0,0 +1,9 @@ +export async function wait() { + const p = new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + jest.advanceTimersByTime(1000); + await p; + await p; +} +export const ONE_MINUTE = 62 * 1000; diff --git a/packages/adapters/ctrlwallet/tsconfig.all.json b/packages/adapters/ctrlwallet/tsconfig.all.json new file mode 100644 index 0000000..2f57563 --- /dev/null +++ b/packages/adapters/ctrlwallet/tsconfig.all.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.root.json", + "references": [ + { + "path": "../abstract-adapter/tsconfig.all.json" + }, + { + "path": "./tsconfig.cjs.json" + }, + { + "path": "./tsconfig.esm.json" + } + ] +} diff --git a/packages/adapters/ctrlwallet/tsconfig.cjs.json b/packages/adapters/ctrlwallet/tsconfig.cjs.json new file mode 100644 index 0000000..099b9aa --- /dev/null +++ b/packages/adapters/ctrlwallet/tsconfig.cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.cjs.json", + "include": ["src"], + "compilerOptions": { + "outDir": "lib/cjs" + } +} diff --git a/packages/adapters/ctrlwallet/tsconfig.esm.json b/packages/adapters/ctrlwallet/tsconfig.esm.json new file mode 100644 index 0000000..4900d2f --- /dev/null +++ b/packages/adapters/ctrlwallet/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.esm.json", + "include": ["src"], + "compilerOptions": { + "outDir": "lib/esm", + "declarationDir": "lib/types" + } +} From f0dc82cf9775bb60c28d46686b86219c3573238a Mon Sep 17 00:00:00 2001 From: HoangVD2 Date: Wed, 11 Dec 2024 18:21:38 +0700 Subject: [PATCH 2/2] chore: update --- README.md | 1 + packages/adapters/ctrlwallet/README.md | 2 +- packages/adapters/ctrlwallet/src/types.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 541aa91..684299e 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ You can use the `@tronweb3/tronwallet-adapters` package, or add the individual w | [gatewallet](https://www.gate.io/web3) | Adapter for gate.io Wallet App(IOS and Android) | [`@tronweb3/tronwallet-adapter-gatewallet`](https://www.npmjs.com/package/@tronweb3/tronwallet-adapter-gatewallet) | | [foxwallet](https://foxwallet.com/) | Adapter for Fox Wallet App(IOS and Android) | [`@tronweb3/tronwallet-adapter-foxwallet`](https://www.npmjs.com/package/@tronweb3/tronwallet-adapter-foxwallet) | | [bybit](https://www.bybit.com/en/web3/home) | Adapter for Bybit Wallet App(IOS and Android) and Extension | [`@tronweb3/tronwallet-adapter-bybit`](https://www.npmjs.com/package/@tronweb3/tronwallet-adapter-bybit) | +| [ctrlwallet](https://ctrl.xyz/) | Adapter for Ctrl Wallet | [`@tronweb3/tronwallet-adapter-ctrlwallet`](https://www.npmjs.com/package/@tronweb3/tronwallet-adapter-ctrlwallet) | ### React Components diff --git a/packages/adapters/ctrlwallet/README.md b/packages/adapters/ctrlwallet/README.md index 598e48c..b0c3925 100644 --- a/packages/adapters/ctrlwallet/README.md +++ b/packages/adapters/ctrlwallet/README.md @@ -88,4 +88,4 @@ await tronWeb.trx.sendRawTransaction(signedTransaction); - **Ctrl Wallet doesn't support `disconnect` by DApp**. As CtrlWalletAdapter doesn't support disconnect by DApp website, call `adapter.disconnect()` won't disconnect from Ctrl Wallet extension really. -For more information about tronwallet adapters, please refer to [`@web3-geek/tronwallet-adapters`](https://github.com/web3-geek/tronwallet-adapter/tree/main/packages/adapters/adapters) +For more information about tronwallet adapters, please refer to [`@tronweb3/tronwallet-adapters`](https://github.com/web3-geek/tronwallet-adapter/tree/main/packages/adapters/adapters) diff --git a/packages/adapters/ctrlwallet/src/types.ts b/packages/adapters/ctrlwallet/src/types.ts index e828bad..12d34d0 100644 --- a/packages/adapters/ctrlwallet/src/types.ts +++ b/packages/adapters/ctrlwallet/src/types.ts @@ -1,7 +1,7 @@ import type { NetworkNodeConfig } from '@tronweb3/tronwallet-abstract-adapter'; import { TronWeb } from '@tronweb3/tronwallet-abstract-adapter'; -export interface TronLinkWalletEvents { +export interface TronLinkEvents { connect(...args: unknown[]): unknown; disconnect(...args: unknown[]): unknown; }