diff --git a/.ckb-light-version b/.ckb-light-version new file mode 100644 index 0000000000..f82e0685d9 --- /dev/null +++ b/.ckb-light-version @@ -0,0 +1 @@ +v0.2.4 diff --git a/.gitignore b/.gitignore index f49fd9ba91..e752ec8fce 100755 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ release /packages/neuron-wallet/bin/mac /packages/neuron-wallet/bin/linux /packages/neuron-wallet/bin/win +/packages/neuron-wallet/light # misc .DS_Store diff --git a/packages/neuron-ui/src/components/ClearCache/index.tsx b/packages/neuron-ui/src/components/ClearCache/index.tsx index e70e9b1726..e140af5948 100644 --- a/packages/neuron-ui/src/components/ClearCache/index.tsx +++ b/packages/neuron-ui/src/components/ClearCache/index.tsx @@ -18,7 +18,7 @@ const IDs = { rebuildCacheOption: 'rebuild-cache-option', } -const ClearCache = ({ dispatch }: { dispatch: StateDispatch }) => { +const ClearCache = ({ dispatch, hideRebuild }: { dispatch: StateDispatch; hideRebuild?: boolean }) => { const [t] = useTranslation() const [clearedDate, setClearedDate] = useState(cacheClearDate.load()) const [isClearing, setIsClearing] = useState(false) @@ -96,8 +96,12 @@ const ClearCache = ({ dispatch }: { dispatch: StateDispatch }) => {
- - + {hideRebuild ? null : ( + <> + + + + )}
- {isDefault ? null : ( + {network.chain === LIGHT_CLIENT_TESTNET ? null : ( + + )} + {isDefault || network.chain === LIGHT_CLIENT_TESTNET ? null : (
} {network ? ( -
+
{network.name}
diff --git a/packages/neuron-ui/src/components/NetworkStatus/networkStatus.module.scss b/packages/neuron-ui/src/components/NetworkStatus/networkStatus.module.scss index 562b4ec785..95fd700a2b 100644 --- a/packages/neuron-ui/src/components/NetworkStatus/networkStatus.module.scss +++ b/packages/neuron-ui/src/components/NetworkStatus/networkStatus.module.scss @@ -9,13 +9,17 @@ $hover-bg-color: #3cc68a4d; font-size: 0.8rem; font-weight: bold; - .name { - position: relative; - display: flex; - align-items: center; - line-height: 1em; - word-break: break-all; - margin-top: 3px; + .networkDisplay { + max-width: 100%; + + .name { + position: relative; + display: block; + line-height: 16px; + word-break: break-all; + overflow-wrap: break-word; + margin-top: 3px; + } } /* keep the tooltip popped to make it more obvious */ diff --git a/packages/neuron-ui/src/components/NetworkTypeLabel/index.tsx b/packages/neuron-ui/src/components/NetworkTypeLabel/index.tsx index e8ebf2434c..02653211b3 100644 --- a/packages/neuron-ui/src/components/NetworkTypeLabel/index.tsx +++ b/packages/neuron-ui/src/components/NetworkTypeLabel/index.tsx @@ -1,5 +1,6 @@ import React from 'react' import { useTranslation } from 'react-i18next' +import { LIGHT_CLIENT_TESTNET } from 'utils/const' const NetworkTypeLabel = ({ type }: { type: 'ckb' | 'ckb_testnet' | 'ckb_dev' | string }) => { const [t] = useTranslation() @@ -10,6 +11,9 @@ const NetworkTypeLabel = ({ type }: { type: 'ckb' | 'ckb_testnet' | 'ckb_dev' | case 'ckb_testnet': { return {t('settings.network.testnet')} } + case LIGHT_CLIENT_TESTNET: { + return {t('settings.network.lightTestnet')} + } default: { return {t('settings.network.devnet')} } diff --git a/packages/neuron-ui/src/components/SUDTMigrateToExistAccountDialog/index.tsx b/packages/neuron-ui/src/components/SUDTMigrateToExistAccountDialog/index.tsx index 06082136ca..2b27e7a47d 100644 --- a/packages/neuron-ui/src/components/SUDTMigrateToExistAccountDialog/index.tsx +++ b/packages/neuron-ui/src/components/SUDTMigrateToExistAccountDialog/index.tsx @@ -17,6 +17,7 @@ const SUDTMigrateToExistAccountDialog = ({ sUDTAccounts, isMainnet, walletID, + isLightClient, }: { cell: SpecialAssetCell closeDialog: () => void @@ -24,6 +25,7 @@ const SUDTMigrateToExistAccountDialog = ({ sUDTAccounts: State.SUDTAccount[] isMainnet: boolean walletID: string + isLightClient: boolean }) => { const [t] = useTranslation() const [address, setAddress] = useState('') @@ -92,6 +94,7 @@ const SUDTMigrateToExistAccountDialog = ({ onChange={onAddressChange} value={address} className={styles.addressInputSelect} + inputDisabeld={isLightClient} /> {addressError &&
{addressError}
}
diff --git a/packages/neuron-ui/src/components/SUDTSend/index.tsx b/packages/neuron-ui/src/components/SUDTSend/index.tsx index 34afa7c3ec..02a2b378b0 100644 --- a/packages/neuron-ui/src/components/SUDTSend/index.tsx +++ b/packages/neuron-ui/src/components/SUDTSend/index.tsx @@ -11,6 +11,7 @@ import Button from 'widgets/Button' import Spinner from 'widgets/Spinner' import { ReactComponent as TooltipIcon } from 'widgets/Icons/Tooltip.svg' import { ReactComponent as Attention } from 'widgets/Icons/Attention.svg' +import { ReactComponent as WarningAttention } from 'widgets/Icons/ExperimentalAttention.svg' import { getSUDTAccount, destoryAssetAccount } from 'services/remote' import { useState as useGlobalState, useDispatch, AppActions } from 'states' import { @@ -29,7 +30,15 @@ import { } from 'utils' import { AmountNotEnoughException, isErrorWithI18n } from 'exceptions' import { UANTokenName, UANTonkenSymbol } from 'components/UANDisplay' -import { AddressLockType, getGenerator, useAddressLockType, useOnSumbit, useOptions, useSendType } from './hooks' +import { + AddressLockType, + SendType, + getGenerator, + useAddressLockType, + useOnSumbit, + useOptions, + useSendType, +} from './hooks' import styles from './sUDTSend.module.scss' const { INIT_SEND_PRICE, DEFAULT_SUDT_FIELDS } = CONSTANTS @@ -405,6 +414,12 @@ const SUDTSend = () => { ) : null} + {(v.key === SendType.secp256Cheque && !isMainnet) ? ( +
+ + {t('messages.light-client-cheque-warning')} +
+ ) : null} ))} {!isOptionCorrect &&
{t('s-udt.send.select-option')}
} diff --git a/packages/neuron-ui/src/components/SUDTSend/sUDTSend.module.scss b/packages/neuron-ui/src/components/SUDTSend/sUDTSend.module.scss index 75db9cfa1d..4a09dd1132 100644 --- a/packages/neuron-ui/src/components/SUDTSend/sUDTSend.module.scss +++ b/packages/neuron-ui/src/components/SUDTSend/sUDTSend.module.scss @@ -178,6 +178,20 @@ } } } + + .chequeWarning { + padding: 0 8px; + margin-left: 8px; + background-color: rgb(255, 244, 206); + color: rgb(50, 49, 48); + + & > svg { + width: 14px; + height: 14px; + margin-right: 4px; + transform: translateY(20%); + } + } } .cheque { grid-area: option-0; diff --git a/packages/neuron-ui/src/components/Send/index.tsx b/packages/neuron-ui/src/components/Send/index.tsx index 0a026f1b34..fc06222a16 100644 --- a/packages/neuron-ui/src/components/Send/index.tsx +++ b/packages/neuron-ui/src/components/Send/index.tsx @@ -177,6 +177,7 @@ const Send = () => { onSendMaxClick={handleSendMaxClick} onLocktimeClick={handleLocktimeClick} isTimeLockable={!device} + isMainnet={isMainnet} /> ) }} diff --git a/packages/neuron-ui/src/components/SendFieldset/index.tsx b/packages/neuron-ui/src/components/SendFieldset/index.tsx index a7e85df255..6f937ee9af 100644 --- a/packages/neuron-ui/src/components/SendFieldset/index.tsx +++ b/packages/neuron-ui/src/components/SendFieldset/index.tsx @@ -36,6 +36,7 @@ interface SendSubformProps { onLocktimeClick?: React.EventHandler> onSendMaxClick?: React.EventHandler> onItemChange: React.EventHandler> + isMainnet: boolean } const SendFieldset = ({ @@ -54,6 +55,7 @@ const SendFieldset = ({ onSendMaxClick, onItemChange, isTimeLockable = true, + isMainnet, }: SendSubformProps) => { const [t] = useTranslation() @@ -162,7 +164,7 @@ const SendFieldset = ({ {item.date && (
- {t('send.locktime-warning')} + {t('send.locktime-warning', { extraNote: isMainnet ? null : t('messages.light-client-locktime-warning') })}
)} diff --git a/packages/neuron-ui/src/components/SendFromMultisigDialog/index.tsx b/packages/neuron-ui/src/components/SendFromMultisigDialog/index.tsx index c682178a75..8722c071d9 100644 --- a/packages/neuron-ui/src/components/SendFromMultisigDialog/index.tsx +++ b/packages/neuron-ui/src/components/SendFromMultisigDialog/index.tsx @@ -102,6 +102,7 @@ const SendFromMultisigDialog = ({ onOutputRemove={deleteSendInfo} onItemChange={onSendInfoChange} onSendMaxClick={onSendMaxClick} + isMainnet={isMainnet} /> ))} diff --git a/packages/neuron-ui/src/components/SpecialAssetList/index.tsx b/packages/neuron-ui/src/components/SpecialAssetList/index.tsx index 43e170ac89..0d23056d66 100644 --- a/packages/neuron-ui/src/components/SpecialAssetList/index.tsx +++ b/packages/neuron-ui/src/components/SpecialAssetList/index.tsx @@ -36,6 +36,7 @@ import { useGetAssetAccounts, } from './hooks' import styles from './specialAssetList.module.scss' +import { LIGHT_NETWORK_TYPE } from 'utils/const' const { PAGE_SIZE } = CONSTANTS @@ -113,6 +114,10 @@ const SpecialAssetList = () => { } = useGlobalState() const { suggestFeeRate } = useGetCountDownAndFeeRateStats() const isMainnet = isMainnetUtil(networks, networkID) + const isLightClient = useMemo(() => networks.find(n => n.id === networkID)?.type === LIGHT_NETWORK_TYPE, [ + networkID, + networks, + ]) const foundTokenInfo = tokenInfoList.find(token => token.tokenID === accountToClaim?.account.tokenID) const accountNames = useMemo(() => sUDTAccounts.filter(v => !!v.accountName).map(v => v.accountName!), [sUDTAccounts]) const updateAccountDialogProps: SUDTUpdateDialogProps | undefined = accountToClaim?.account @@ -384,6 +389,7 @@ const SpecialAssetList = () => { sUDTAccounts={sUDTAccounts} isMainnet={isMainnet} walletID={id} + isLightClient={isLightClient} /> )} diff --git a/packages/neuron-ui/src/components/WithdrawDialog/index.tsx b/packages/neuron-ui/src/components/WithdrawDialog/index.tsx index 9b8f180691..f887a19f54 100644 --- a/packages/neuron-ui/src/components/WithdrawDialog/index.tsx +++ b/packages/neuron-ui/src/components/WithdrawDialog/index.tsx @@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next' import Button from 'widgets/Button' import { CONSTANTS, shannonToCKBFormatter, localNumberFormatter, useCalculateEpochs, useDialog } from 'utils' -import { calculateDaoMaximumWithdraw, getHeader } from 'services/chain' +import { getTransaction, getHeader } from 'services/chain' +import { calculateMaximumWithdraw } from '@nervosnetwork/ckb-sdk-utils' import styles from './withdrawDialog.module.scss' const { WITHDRAW_EPOCHS } = CONSTANTS @@ -13,13 +14,13 @@ const WithdrawDialog = ({ onDismiss, onSubmit, record, - tipBlockHash, + tipDao, currentEpoch, }: { onDismiss: () => void onSubmit: () => void record: State.NervosDAORecord - tipBlockHash: string + tipDao?: string currentEpoch: string }) => { const [t] = useTranslation() @@ -42,24 +43,29 @@ const WithdrawDialog = ({ }, [record]) useEffect(() => { - if (!record || !tipBlockHash) { + if (!record || !tipDao) { return } - calculateDaoMaximumWithdraw( - { - txHash: record.outPoint.txHash, - index: `0x${BigInt(record.outPoint.index).toString(16)}`, - }, - tipBlockHash - ) - .then((res: string) => { - setWithdrawValue(res) + getTransaction(record.outPoint.txHash) + .then(tx => { + if (tx.txStatus.blockHash) { + getHeader(tx.txStatus.blockHash).then(header => { + setWithdrawValue( + calculateMaximumWithdraw( + tx.transaction.outputs[+record.outPoint.index], + tx.transaction.outputsData[+record.outPoint.index], + header.dao, + tipDao + ) + ) + }) + } }) .catch((err: Error) => { console.error(err) }) - }, [record, tipBlockHash]) + }, [record, tipDao]) const { currentEpochInfo, targetEpochValue } = useCalculateEpochs({ depositEpoch, currentEpoch }) diff --git a/packages/neuron-ui/src/containers/Main/hooks.ts b/packages/neuron-ui/src/containers/Main/hooks.ts index 25d96d94ee..a0714bb07f 100644 --- a/packages/neuron-ui/src/containers/Main/hooks.ts +++ b/packages/neuron-ui/src/containers/Main/hooks.ts @@ -18,7 +18,7 @@ import { SyncState as SyncStateSubject, Command as CommandSubject, } from 'services/subjects' -import { ckbCore, getBlockchainInfo, getTipHeader } from 'services/chain' +import { ckbCore, getTipHeader } from 'services/chain' import { networks as networksCache, currentNetworkID as currentNetworkIDCache } from 'services/localCache' import { WalletWizardPath } from 'components/WalletWizard' import { ErrorCode, RoutePath, getConnectionStatus } from 'utils' @@ -38,18 +38,16 @@ export const useSyncChainData = ({ chainURL, dispatch }: { chainURL: string; dis useEffect(() => { let timer: NodeJS.Timeout const syncBlockchainInfo = () => { - Promise.all([getTipHeader(), getBlockchainInfo()]) - .then(([header, chainInfo]) => { + getTipHeader() + .then(header => { if (isCurrentUrl(chainURL)) { dispatch({ type: AppActions.UpdateChainInfo, payload: { tipBlockNumber: `${BigInt(header.number)}`, - tipBlockHash: header.hash, + tipDao: header.dao, tipBlockTimestamp: +header.timestamp, - chain: chainInfo.chain, - difficulty: BigInt(chainInfo.difficulty), - epoch: chainInfo.epoch, + epoch: header.epoch, }, }) diff --git a/packages/neuron-ui/src/locales/en.json b/packages/neuron-ui/src/locales/en.json index 367d127345..eee3b132aa 100644 --- a/packages/neuron-ui/src/locales/en.json +++ b/packages/neuron-ui/src/locales/en.json @@ -217,7 +217,7 @@ "set-locktime": "Set Locktime", "locktime-notice-content": "According to the actual running block height, there may be some time variances in locktime.", "release-on": "Release on", - "locktime-warning": "Please ensure that receiver's wallet can support expiration unlocking. In general, exchanges do not support expiration unlocking, please use with caution!" + "locktime-warning": "Please ensure that receiver's wallet can support expiration unlocking. (Note: 1.Exchanges generally do not support expiration unlocking {{extraNote}})" }, "receive": { "title": "Receive", @@ -352,6 +352,7 @@ }, "mainnet": "Mainnet", "testnet": "Testnet", + "lightTestnet": "Light Testnet", "devnet": "Devnet" }, "locale": { @@ -510,6 +511,8 @@ "migrate-warning": "Warning: The migration process may fail for unknown reasons resulting in resynchronization, please back up manually and start the migration!", "migrate": "Migrate", "secp256k1/blake160-address-required": "Secp256k1/blake160 address is required", + "light-client-locktime-warning": "2.Light client mode doesn't support showing lock-time CKBytes.", + "light-client-cheque-warning": "Warning: Light client mode doesn't support showing Cheque assets.", "fields": { "wallet": "Wallet", "name": "Name", @@ -927,6 +930,7 @@ "type": "type", "balance": "balance", "copy-address": "Copy Address", + "sync-block": "Synced Block", "actions": { "info": "Info", "send": "Send", diff --git a/packages/neuron-ui/src/locales/zh-tw.json b/packages/neuron-ui/src/locales/zh-tw.json index 5980e80336..202dac1504 100644 --- a/packages/neuron-ui/src/locales/zh-tw.json +++ b/packages/neuron-ui/src/locales/zh-tw.json @@ -210,7 +210,7 @@ "set-locktime": "設置鎖定時間", "locktime-notice-content": "鎖定時間根據區塊鏈實際運行情況會有一定的誤差。", "release-on": "鎖定至", - "locktime-warning": "請確保對方錢包能夠支持到期解鎖功能。一般情況下,交易所不支持到期解鎖功能,請謹慎使用!" + "locktime-warning": "請確保對方錢包支持到期解鎖功能。(註:1.交易所壹般不支持到期解鎖功能 {{extraNote}})" }, "receive": { "title": "收款", @@ -503,6 +503,8 @@ "migrate-warning": "註意:遷移過程中可能由於未知原因失敗導致需要重新同步,請備份完成後開始遷移!", "migrate": "遷移", "secp256k1/blake160-address-required": "請輸入 secp256k1/blake160 地址", + "light-client-locktime-warning": "2.輕節點模式不支持展示到期解鎖的 CKBytes。", + "light-client-cheque-warning": "註意: 輕節點模式不支持展示 Cheque 資產。", "fields": { "wallet": "錢包", "name": "名稱", @@ -919,6 +921,7 @@ "type": "類型", "balance": "余额", "copy-address": "復製地址", + "sync-block": "同步高度", "actions": { "info": "詳情", "send": "轉賬", diff --git a/packages/neuron-ui/src/locales/zh.json b/packages/neuron-ui/src/locales/zh.json index 5225018c60..54c226e073 100644 --- a/packages/neuron-ui/src/locales/zh.json +++ b/packages/neuron-ui/src/locales/zh.json @@ -210,7 +210,7 @@ "set-locktime": "设置锁定时间", "locktime-notice-content": "锁定时间根据区块链实际运行情况会有一定的误差。", "release-on": "锁定至", - "locktime-warning": "请确保对方钱包能够支持到期解锁功能。一般情况下,交易所不支持到期解锁功能,请谨慎使用!" + "locktime-warning": "请确保对方钱包支持到期解锁功能。(注:1.交易所一般不支持到期解锁功能 {{extraNote}})" }, "receive": { "title": "收款", @@ -345,6 +345,7 @@ }, "mainnet": "主网", "testnet": "测试网", + "lightTestnet": "轻节点测试网", "devnet": "开发网" }, "locale": { @@ -503,6 +504,8 @@ "migrate-warning": "注意:迁移过程中可能由于未知原因失败导致需要重新同步,请备份完成后开始迁移!", "migrate": "迁移", "secp256k1/blake160-address-required": "请输入 secp256k1/blake160 地址", + "light-client-locktime-warning": "2.轻节点模式不支持展示到期解锁的 CKBytes。", + "light-client-cheque-warning": "注意: 轻节点模式不支持展示 Cheque 资产。", "fields": { "wallet": "钱包", "name": "名称", @@ -919,6 +922,7 @@ "type": "类型", "balance": "余额", "copy-address": "复制地址", + "sync-block": "同步高度", "actions": { "info": "详情", "send": "转账", diff --git a/packages/neuron-ui/src/services/chain.ts b/packages/neuron-ui/src/services/chain.ts index e83b569cbf..463b53868e 100644 --- a/packages/neuron-ui/src/services/chain.ts +++ b/packages/neuron-ui/src/services/chain.ts @@ -1,19 +1,17 @@ import CKBCore from '@nervosnetwork/ckb-sdk-core' export const ckbCore = new CKBCore('') -export const { getHeader, getBlock, getBlockchainInfo, getTipHeader, getHeaderByNumber, getFeeRateStats } = ckbCore.rpc +export const { getHeader, getBlockchainInfo, getTipHeader, getHeaderByNumber, getFeeRateStats, getTransaction } = ckbCore.rpc export const { calculateDaoMaximumWithdraw } = ckbCore export const { toUint64Le, parseEpoch } = ckbCore.utils + export default { ckbCore, - getBlock, - getBlockchainInfo, getHeader, getTipHeader, - getHeaderByNumber, - calculateDaoMaximumWithdraw, + getTransaction, toUint64Le, getFeeRateStats, } diff --git a/packages/neuron-ui/src/services/remote/multisig.ts b/packages/neuron-ui/src/services/remote/multisig.ts index 7c853a2994..4ac2b4aa2b 100644 --- a/packages/neuron-ui/src/services/remote/multisig.ts +++ b/packages/neuron-ui/src/services/remote/multisig.ts @@ -49,3 +49,6 @@ export const generateMultisigSendAllTx = remoteApi<{ multisigConfig: MultisigConfig }>('generate-multisig-send-all-tx') export const loadMultisigTxJson = remoteApi('load-multisig-tx-json') +export const getMultisigSyncProgress = remoteApi( + 'get-sync-progress-by-addresses' +) diff --git a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts index 54729f60a2..3d1f28e184 100644 --- a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts +++ b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts @@ -147,6 +147,7 @@ type Action = | 'load-multisig-tx-json' | 'get-hold-sudt-cell-capacity' | 'start-migrate' + | 'get-sync-progress-by-addresses' export const remoteApi =

(action: Action) => diff --git a/packages/neuron-ui/src/states/init/app.ts b/packages/neuron-ui/src/states/init/app.ts index 35a433466a..4183fd81cd 100644 --- a/packages/neuron-ui/src/states/init/app.ts +++ b/packages/neuron-ui/src/states/init/app.ts @@ -12,10 +12,7 @@ const initNotifications: Array = [ export const appState: State.App = { tipBlockNumber: '', - tipBlockHash: '', tipBlockTimestamp: 0, - chain: '', - difficulty: BigInt(0), epoch: '', send: { txID: '', diff --git a/packages/neuron-ui/src/stories/NervosDAO.stories.tsx b/packages/neuron-ui/src/stories/NervosDAO.stories.tsx index f3b25c014b..8ab78818bc 100644 --- a/packages/neuron-ui/src/stories/NervosDAO.stories.tsx +++ b/packages/neuron-ui/src/stories/NervosDAO.stories.tsx @@ -40,6 +40,7 @@ const stateTemplate = { remote: 'http://testnet.nervos.com', chain: 'ckb_testnet', type: 1 as 0 | 1, + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', }, ], }, @@ -158,6 +159,7 @@ stories.addDecorator(withKnobs).add('With knobs', () => { remote: text('Network Address', 'http://testnet.nervos.com'), chain: text('Chain', 'ckb_testnet'), type: 1 as 0 | 1, + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', }, ], }, diff --git a/packages/neuron-ui/src/stories/NetworkSetting.stories.tsx b/packages/neuron-ui/src/stories/NetworkSetting.stories.tsx index 1fa4e8a932..9dcafdbf23 100644 --- a/packages/neuron-ui/src/stories/NetworkSetting.stories.tsx +++ b/packages/neuron-ui/src/stories/NetworkSetting.stories.tsx @@ -13,6 +13,7 @@ const states: { [title: string]: State.Network[] } = { remote: 'http://127.0.0.1:8114', chain: 'ckb', type: 0, + genesisHash: '0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5', }, { id: 'Testnet', @@ -20,6 +21,7 @@ const states: { [title: string]: State.Network[] } = { remote: 'http://127.0.0.1:8114', chain: 'ckb_testnet', type: 1, + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', }, { id: 'Local', @@ -27,6 +29,7 @@ const states: { [title: string]: State.Network[] } = { remote: 'http://127.0.0.1:8114', chain: 'ckb_devnet', type: 1, + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', }, ], } diff --git a/packages/neuron-ui/src/stories/NetworkStatus.stories.tsx b/packages/neuron-ui/src/stories/NetworkStatus.stories.tsx index 4f9572404e..40c32cf5bb 100644 --- a/packages/neuron-ui/src/stories/NetworkStatus.stories.tsx +++ b/packages/neuron-ui/src/stories/NetworkStatus.stories.tsx @@ -10,6 +10,7 @@ const defaultProps: Omit {}, isLookingValidTarget: false, @@ -71,6 +72,7 @@ stories.add('With knobs', () => { type: select('Type', [0, 1], 0) as any, id: text('id', 'd'), chain: select('Chain', ['ckb', 'ckb_testnet', 'ckb_dev'], 'ckb'), + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', }, syncPercents: number('Sync Percents', 1), syncBlockNumbers: text('Sync Block Number', '1/100'), diff --git a/packages/neuron-ui/src/stories/Overview.stories.tsx b/packages/neuron-ui/src/stories/Overview.stories.tsx index c73c96b726..e35e89fbdd 100644 --- a/packages/neuron-ui/src/stories/Overview.stories.tsx +++ b/packages/neuron-ui/src/stories/Overview.stories.tsx @@ -41,6 +41,7 @@ const stateTemplate = { remote: 'http://testnet.nervos.com', chain: 'ckb_testnet', type: 1 as 0 | 1, + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', }, ], }, @@ -116,6 +117,7 @@ stories.addDecorator(withKnobs).add('With knobs', () => { remote: text('Network Address', 'http://testnet.nervos.com'), chain: text('Chain', 'ckb_testnet'), type: 1 as 0 | 1, + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', }, ], }, diff --git a/packages/neuron-ui/src/stories/SendFieldset.stories.tsx b/packages/neuron-ui/src/stories/SendFieldset.stories.tsx index 290d745393..8f19d4e767 100644 --- a/packages/neuron-ui/src/stories/SendFieldset.stories.tsx +++ b/packages/neuron-ui/src/stories/SendFieldset.stories.tsx @@ -30,6 +30,7 @@ stories.add('Common', () => { onItemChange: (e: any) => action('Item Change')(JSON.stringify(e.target.dataset), e.target.value), onScan: () => action('Scan'), onSendMaxClick: (e: any) => action('Click Send Max button')(JSON.stringify(e.target.dataset)), + isMainnet: false } return }) diff --git a/packages/neuron-ui/src/stories/WithdrawDialog.stories.tsx b/packages/neuron-ui/src/stories/WithdrawDialog.stories.tsx index c4e992bd2e..c6925ae0c6 100644 --- a/packages/neuron-ui/src/stories/WithdrawDialog.stories.tsx +++ b/packages/neuron-ui/src/stories/WithdrawDialog.stories.tsx @@ -36,7 +36,6 @@ const props = { timestamp: Date.now().toString(), depositTimestamp: Date.now().toString(), }, - tipBlockHash: '0x70abeeaa2ed08b7d7659341a122b9a2f2ede99bb6bd0df7398d7ffe488beab61', currentEpoch: '0x00000000', } diff --git a/packages/neuron-ui/src/styles/index.scss b/packages/neuron-ui/src/styles/index.scss index 8376832ef6..172c4489d3 100755 --- a/packages/neuron-ui/src/styles/index.scss +++ b/packages/neuron-ui/src/styles/index.scss @@ -39,7 +39,7 @@ body { justify-content: center; align-items: center; height: 18px; - width: 58px; + min-width: 58px; padding: 0 4px; font-size: 9px; font-weight: 600; diff --git a/packages/neuron-ui/src/tests/is/isMainnet/fixtures.ts b/packages/neuron-ui/src/tests/is/isMainnet/fixtures.ts index 9749009a15..56dc338194 100644 --- a/packages/neuron-ui/src/tests/is/isMainnet/fixtures.ts +++ b/packages/neuron-ui/src/tests/is/isMainnet/fixtures.ts @@ -16,7 +16,16 @@ const fixtures = { 'Should return false when network id cannot be found in network list': { params: { networkID: 'testnet', - networks: [{ id: 'mainnet', chain: 'ckb', type: 0 as 0 | 1, name: 'Mainnet', remote: 'http://127.0.0.1:8114' }], + networks: [ + { + id: 'mainnet', + chain: 'ckb', + type: 0 as 0 | 1, + name: 'Mainnet', + remote: 'http://127.0.0.1:8114', + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', + }, + ], }, expected: false, }, @@ -24,7 +33,14 @@ const fixtures = { params: { networkID: 'testnet', networks: [ - { id: 'testnet', chain: 'ckb_testnet', type: 0 as 0 | 1, name: 'Mainnet', remote: 'http://127.0.0.1:8114' }, + { + id: 'testnet', + chain: 'ckb_testnet', + type: 0 as 0 | 1, + name: 'Mainnet', + remote: 'http://127.0.0.1:8114', + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', + }, ], }, expected: false, @@ -32,7 +48,16 @@ const fixtures = { "Should return true when network id can be found in network list and it's Mainnet": { params: { networkID: 'mainnet', - networks: [{ id: 'mainnet', chain: 'ckb', type: 0 as 0 | 1, name: 'Mainnet', remote: 'http://127.0.0.1:8114' }], + networks: [ + { + id: 'mainnet', + chain: 'ckb', + type: 0 as 0 | 1, + name: 'Mainnet', + remote: 'http://127.0.0.1:8114', + genesisHash: '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', + }, + ], }, expected: true, }, diff --git a/packages/neuron-ui/src/types/App/index.d.ts b/packages/neuron-ui/src/types/App/index.d.ts index 458c724171..0b51453eef 100644 --- a/packages/neuron-ui/src/types/App/index.d.ts +++ b/packages/neuron-ui/src/types/App/index.d.ts @@ -125,10 +125,8 @@ declare namespace State { interface App { tipBlockNumber: string - tipBlockHash: string + tipDao?: string tipBlockTimestamp: number - chain: string - difficulty: bigint epoch: string send: Send passwordRequest: PasswordRequest @@ -149,7 +147,8 @@ declare namespace State { name: string remote: string chain: 'ckb' | 'ckb_testnet' | 'ckb_dev' | string - type: 0 | 1 + type: 0 | 1 | 2 + genesisHash: string } interface Network extends NetworkProperty { diff --git a/packages/neuron-ui/src/utils/const.ts b/packages/neuron-ui/src/utils/const.ts index 69d33c9b2f..1112f265f9 100644 --- a/packages/neuron-ui/src/utils/const.ts +++ b/packages/neuron-ui/src/utils/const.ts @@ -63,3 +63,7 @@ export const DEPRECATED_CODE_HASH: Record = { AcpOnLina: '0x0fb343953ee78c9986b091defb6252154e0bb51044fd2879fde5b27314506111', AcpOnAggron: '0x86a1c6987a4acbe1a887cca4c9dd2ac9fcb07405bbeda51b861b18bbf7492c4b', } + +export const LIGHT_CLIENT_TESTNET = 'light_client_testnet' +export const LIGHT_NETWORK_TYPE = 2 +export const METHOD_NOT_FOUND = -32601 diff --git a/packages/neuron-ui/src/utils/hooks/useGetCountDownAndFeeRateStats.ts b/packages/neuron-ui/src/utils/hooks/useGetCountDownAndFeeRateStats.ts index a1a97221fa..9caf6cf076 100644 --- a/packages/neuron-ui/src/utils/hooks/useGetCountDownAndFeeRateStats.ts +++ b/packages/neuron-ui/src/utils/hooks/useGetCountDownAndFeeRateStats.ts @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { getFeeRateStats } from 'services/chain' -import { AppActions, StateDispatch, useDispatch } from 'states' -import { MEDIUM_FEE_RATE } from 'utils/const' +import { MEDIUM_FEE_RATE, METHOD_NOT_FOUND } from 'utils/const' type CountdownOptions = { seconds?: number @@ -15,34 +14,29 @@ const useGetCountDownAndFeeRateStats = ({ seconds = 30, interval = 1000 }: Count median?: string suggestFeeRate: number }>({ suggestFeeRate: MEDIUM_FEE_RATE }) - const dispatch = useDispatch() - const handleGetFeeRateStatis = useCallback( - (stateDispatch: StateDispatch) => { - getFeeRateStats() - .then(res => { - const { mean, median } = res ?? {} - const suggested = mean && median ? Math.max(1000, Number(mean), Number(median)) : MEDIUM_FEE_RATE + const handleGetFeeRateStatis = useCallback(() => { + getFeeRateStats() + .then(res => { + const { mean, median } = res ?? {} + const suggested = mean && median ? Math.max(1000, Number(mean), Number(median)) : MEDIUM_FEE_RATE - setFeeFatestatsData(states => ({ ...states, ...res, suggestFeeRate: suggested })) - }) - .catch((err: Error & { response?: { status: number } }) => { + setFeeFatestatsData(states => ({ ...states, ...res, suggestFeeRate: suggested })) + }) + .catch((err: Error & { response?: { status: number } }) => { + try { if (err?.response?.status === 404) { - setFeeFatestatsData(states => ({ ...states, suggestFeeRate: MEDIUM_FEE_RATE })) - } else { - stateDispatch({ - type: AppActions.AddNotification, - payload: { - type: 'alert', - timestamp: +new Date(), - content: err.message, - }, - }) + throw new Error('method not found') } - }) - }, - [getFeeRateStats, setFeeFatestatsData] - ) + const errMsg = JSON.parse(err.message) + if (errMsg?.code === METHOD_NOT_FOUND) { + throw new Error('method not found') + } + } catch (error) { + setFeeFatestatsData(states => ({ ...states, suggestFeeRate: MEDIUM_FEE_RATE })) + } + }) + }, []) useEffect(() => { const countInterval = setInterval(() => { @@ -56,9 +50,9 @@ const useGetCountDownAndFeeRateStats = ({ seconds = 30, interval = 1000 }: Count useEffect(() => { if (countDown === seconds) { - handleGetFeeRateStatis(dispatch) + handleGetFeeRateStatis() } - }, [countDown, seconds, dispatch]) + }, [countDown, seconds]) return { countDown, ...feeFatestatsData } } diff --git a/packages/neuron-ui/src/widgets/InputSelect/index.tsx b/packages/neuron-ui/src/widgets/InputSelect/index.tsx index bc7728a98e..6341b30953 100644 --- a/packages/neuron-ui/src/widgets/InputSelect/index.tsx +++ b/packages/neuron-ui/src/widgets/InputSelect/index.tsx @@ -16,6 +16,7 @@ export interface InputSelectProps { onChange?: (value: string, arg?: SelectOptions) => void value?: string placeholder?: string + inputDisabeld?: boolean } function parseValue(value: string, options: SelectOptions[]) { @@ -23,7 +24,7 @@ function parseValue(value: string, options: SelectOptions[]) { return option?.value || value } -const Select = ({ value, options, placeholder, disabled, onChange, className }: InputSelectProps) => { +const Select = ({ value, options, placeholder, disabled, onChange, className, inputDisabeld }: InputSelectProps) => { const mounted = useRef(true) const root = useRef(null) const openRef = useRef(false) @@ -121,7 +122,7 @@ const Select = ({ value, options, placeholder, disabled, onChange, className }: tabIndex={0} data-open={openRef.current} > - +

{openRef.current ? ( diff --git a/packages/neuron-wallet/electron-builder.yml b/packages/neuron-wallet/electron-builder.yml index 8125c6b0da..6cc88df861 100644 --- a/packages/neuron-wallet/electron-builder.yml +++ b/packages/neuron-wallet/electron-builder.yml @@ -13,7 +13,7 @@ afterSign: scripts/notarize.js files: - from: "../.." to: "." - filter: ["!**/*", ".ckb-version", "ormconfig.json"] + filter: ["!**/*", ".ckb-version", ".ckb-light-version", "ormconfig.json"] - package.json - dist - ".env" @@ -41,6 +41,10 @@ win: extraFiles: - from: "bin/win/ckb.exe" to: "bin/ckb.exe" + - from: "bin/win/ckb-light-client.exe" + to: "bin/ckb-light-client.exe" + - from: "light/ckb_light.toml" + to: "light/ckb_light.toml" target: - target: nsis arch: @@ -53,6 +57,10 @@ mac: extraFiles: - from: "bin/mac/ckb-${arch}" to: "bin/ckb" + - from: "bin/mac/ckb-light-client" + to: "bin/ckb-light-client" + - from: "light/ckb_light.toml" + to: "light/ckb_light.toml" hardenedRuntime: true gatekeeperAssess: false entitlements: assets/entitlements.plist @@ -71,5 +79,9 @@ linux: extraFiles: - from: "bin/linux/ckb" to: "bin/ckb" + - from: "bin/linux/ckb-light-client" + to: "bin/ckb-light-client" + - from: "light/ckb_light.toml" + to: "light/ckb_light.toml" target: - AppImage diff --git a/packages/neuron-wallet/light/.gitkeep b/packages/neuron-wallet/light/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/neuron-wallet/package.json b/packages/neuron-wallet/package.json index bed50a94f3..61f1259cf8 100644 --- a/packages/neuron-wallet/package.json +++ b/packages/neuron-wallet/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@ckb-lumos/base": "0.18.0-rc2", + "@ckb-lumos/codec": "0.19.0", "@ckb-lumos/rpc": "0.18.0-rc2", "@iarna/toml": "2.2.5", "@ledgerhq/hw-transport-node-hid": "6.27.13", diff --git a/packages/neuron-wallet/src/block-sync-renderer/index.ts b/packages/neuron-wallet/src/block-sync-renderer/index.ts index 3edddca360..6c8b3cb7cc 100644 --- a/packages/neuron-wallet/src/block-sync-renderer/index.ts +++ b/packages/neuron-wallet/src/block-sync-renderer/index.ts @@ -9,12 +9,16 @@ import DataUpdateSubject from '../models/subjects/data-update' import AddressCreatedSubject from '../models/subjects/address-created-subject' import WalletDeletedSubject from '../models/subjects/wallet-deleted-subject' import TxDbChangedSubject from '../models/subjects/tx-db-changed-subject' -import { LumosCellQuery, LumosCell } from './sync/indexer-connector' +import { LumosCellQuery, LumosCell } from './sync/connector' import { WorkerMessage, StartParams, QueryIndexerParams } from './task' import logger from '../utils/logger' import CommonUtils from '../utils/common' import queueWrapper from '../utils/queue' import env from '../env' +import MultisigConfigDbChangedSubject from '../models/subjects/multisig-config-db-changed-subject' +import Multisig from '../services/multisig' +import { SyncAddressType } from '../database/chain/entities/sync-progress' +import { debounceTime } from 'rxjs/operators' let network: Network | null let child: ChildProcess | null = null @@ -106,6 +110,7 @@ export const createBlockSyncTask = async () => { child = fork(path.join(__dirname, 'task-wrapper.js'), [], { env: { fileBasePath: env.fileBasePath }, stdio: ['ipc', process.stdout, 'pipe'], + execArgv: env.app.isPackaged ? [] : ['--inspect'] }) child.on('message', ({ id, message, channel }: WorkerMessage) => { @@ -192,3 +197,17 @@ export const registerRequest = (c: ChildProcess, msg: Required) = AddressCreatedSubject.getSubject().subscribe(() => resetSyncTaskQueue.asyncPush(true)) WalletDeletedSubject.getSubject().subscribe(() => resetSyncTaskQueue.asyncPush(true)) +MultisigConfigDbChangedSubject.getSubject() + .pipe(debounceTime(1000)) + .subscribe(async () => { + if (!child) { + return + } + const appendScripts = await Multisig.getMultisigConfigForLight() + const msg: Required> = { type: 'call', channel: 'append_scripts', id: requestId++, message: appendScripts } + return registerRequest(child, msg).catch(err => { + logger.error(`Sync:\ffailed to append script to light client`, err) + }) + }) diff --git a/packages/neuron-wallet/src/block-sync-renderer/sync/connector.ts b/packages/neuron-wallet/src/block-sync-renderer/sync/connector.ts new file mode 100644 index 0000000000..95b6339c7c --- /dev/null +++ b/packages/neuron-wallet/src/block-sync-renderer/sync/connector.ts @@ -0,0 +1,55 @@ +import { SyncAddressType } from '../../database/chain/entities/sync-progress' +import { Subject } from 'rxjs' + +export interface BlockTips { + cacheTipNumber: number + indexerTipNumber: number | undefined +} + +export interface LumosCellQuery { + lock: CKBComponents.Script | null + type: CKBComponents.Script | null + data: string | null +} + +export interface LumosCell { + block_hash: string + out_point: { + tx_hash: string + index: string + } + cell_output: { + capacity: string + lock: { + code_hash: string + args: string + hash_type: string + } + type?: { + code_hash: string + args: string + hash_type: string + } + } + data?: string +} + +export interface AppendScript { + walletId: string + script: CKBComponents.Script + addressType: SyncAddressType + scriptType: CKBRPC.ScriptType +} + +export abstract class Connector { + abstract blockTipsSubject: Subject + abstract transactionsSubject: Subject<{ txHashes: CKBComponents.Hash[]; params: TransactionsSubjectParam }> + + abstract connect(): Promise + abstract notifyCurrentBlockNumberProcessed(param: TransactionsSubjectParam): void + abstract stop(): void + abstract getLiveCellsByScript(query: LumosCellQuery): Promise + async appendScript(_scripts: AppendScript[]) { + // do nothing + } +} diff --git a/packages/neuron-wallet/src/block-sync-renderer/sync/indexer-connector.ts b/packages/neuron-wallet/src/block-sync-renderer/sync/indexer-connector.ts index de363b600e..8c67e3f5e8 100644 --- a/packages/neuron-wallet/src/block-sync-renderer/sync/indexer-connector.ts +++ b/packages/neuron-wallet/src/block-sync-renderer/sync/indexer-connector.ts @@ -1,65 +1,30 @@ -import type { ScriptHashType } from '../../models/chain/script' import { Subject } from 'rxjs' -import { queue, AsyncQueue } from 'async' +import { queue, QueueObject } from 'async' import { Tip, QueryOptions } from '@ckb-lumos/base' import { CkbIndexer, CellCollector } from '@nervina-labs/ckb-indexer' import logger from '../../utils/logger' import CommonUtils from '../../utils/common' import RpcService from '../../services/rpc-service' -import TransactionWithStatus from '../../models/chain/transaction-with-status' import { Address } from '../../models/address' import AddressMeta from '../../database/address/meta' import IndexerTxHashCache from '../../database/chain/entities/indexer-tx-hash-cache' import IndexerCacheService from './indexer-cache-service' +import { BlockTips, LumosCellQuery, Connector } from './connector' -export interface LumosCellQuery { - lock: { codeHash: string; hashType: ScriptHashType; args: string } | null - type: { codeHash: string; hashType: ScriptHashType; args: string } | null - data: string | null -} - -export interface LumosCell { - block_hash: string - out_point: { - tx_hash: string - index: string - } - cell_output: { - capacity: string - lock: { - code_hash: string - args: string - hash_type: string - } - type?: { - code_hash: string - args: string - hash_type: string - } - } - data?: string -} - -export interface BlockTips { - cacheTipNumber: number - indexerTipNumber: number | undefined -} - -export default class IndexerConnector { +export default class IndexerConnector extends Connector { private indexer: CkbIndexer private rpcService: RpcService private addressesByWalletId: Map - private processNextBlockNumberQueue: AsyncQueue | undefined - private indexerQueryQueue: AsyncQueue | undefined + private processNextBlockNumberQueue: QueueObject | undefined + private indexerQueryQueue: QueueObject | undefined private processingBlockNumber: string | undefined - public pollingIndexer: boolean = false + private pollingIndexer: boolean = false public readonly blockTipsSubject: Subject = new Subject() - public readonly transactionsSubject: Subject> = new Subject< - Array - >() + public readonly transactionsSubject = new Subject<{ txHashes: CKBComponents.Hash[]; params: string | undefined }>() constructor(addresses: Address[], nodeUrl: string, indexerUrl: string) { + super() this.indexer = new CkbIndexer(nodeUrl, indexerUrl) this.rpcService = new RpcService(nodeUrl) @@ -182,7 +147,7 @@ export default class IndexerConnector { return result } - private async getTxsInNextUnprocessedBlockNumber() { + private async getTxHashesWithNextUnprocessedBlockNumber(): Promise<[string | undefined, string[]]> { const txHashCachesByNextBlockNumberAndAddress = await Promise.all( [...this.addressesByWalletId.entries()].map(async ([walletId, addressMetas]) => { const indexerCacheService = new IndexerCacheService(walletId, addressMetas, this.rpcService, this.indexer) @@ -206,15 +171,12 @@ export default class IndexerConnector { const nextUnprocessedBlockNumber = [...groupedTxHashCaches.keys()].sort((a, b) => parseInt(a) - parseInt(b)).shift() if (!nextUnprocessedBlockNumber) { - return [] + return [undefined, []] } const txHashCachesInNextUnprocessedBlockNumber = groupedTxHashCaches.get(nextUnprocessedBlockNumber) - const txsInNextUnprocessedBlockNumber = await this.fetchTxsWithStatus( - txHashCachesInNextUnprocessedBlockNumber!.map(({ txHash }) => txHash) - ) - return txsInNextUnprocessedBlockNumber + return [nextUnprocessedBlockNumber, txHashCachesInNextUnprocessedBlockNumber!.map(({ txHash }) => txHash)] } private async upsertTxHashes(): Promise { @@ -233,31 +195,13 @@ export default class IndexerConnector { } private async processTxsInNextBlockNumber() { - const txsInNextUnprocessedBlockNumber = await this.getTxsInNextUnprocessedBlockNumber() - if (txsInNextUnprocessedBlockNumber.length) { - this.processingBlockNumber = txsInNextUnprocessedBlockNumber[0].transaction.blockNumber - this.transactionsSubject.next(txsInNextUnprocessedBlockNumber) + const [nextBlockNumber, txHashesInNextBlock] = await this.getTxHashesWithNextUnprocessedBlockNumber() + if (nextBlockNumber !== undefined && txHashesInNextBlock.length) { + this.processingBlockNumber = nextBlockNumber + this.transactionsSubject.next({ txHashes: txHashesInNextBlock, params: this.processingBlockNumber }) } } - private async fetchTxsWithStatus(txHashes: string[]) { - const txsWithStatus: TransactionWithStatus[] = [] - - for (const hash of txHashes) { - const txWithStatus = await this.rpcService.getTransaction(hash) - if (!txWithStatus) { - throw new Error(`failed to fetch transaction for hash ${hash}`) - } - const blockHeader = await this.rpcService.getHeader(txWithStatus!.txStatus.blockHash!) - txWithStatus!.transaction.blockNumber = blockHeader!.number - txWithStatus!.transaction.blockHash = txWithStatus!.txStatus.blockHash! - txWithStatus!.transaction.timestamp = blockHeader!.timestamp - txsWithStatus.push(txWithStatus) - } - - return txsWithStatus - } - public notifyCurrentBlockNumberProcessed(blockNumber: string) { if (blockNumber === this.processingBlockNumber) { delete this.processingBlockNumber @@ -266,4 +210,8 @@ export default class IndexerConnector { } this.processNextBlockNumber() } + + public stop(): void { + this.pollingIndexer = false + } } diff --git a/packages/neuron-wallet/src/block-sync-renderer/sync/light-connector.ts b/packages/neuron-wallet/src/block-sync-renderer/sync/light-connector.ts new file mode 100644 index 0000000000..e50f5a99fb --- /dev/null +++ b/packages/neuron-wallet/src/block-sync-renderer/sync/light-connector.ts @@ -0,0 +1,320 @@ +import { Subject } from 'rxjs' +import { queue, QueueObject } from 'async' +import { HexString, QueryOptions } from '@ckb-lumos/base' +import { CkbIndexer, CellCollector } from '@nervina-labs/ckb-indexer' +import logger from '../../utils/logger' +import { Address } from '../../models/address' +import AddressMeta from '../../database/address/meta' +import { scheduler } from 'timers/promises' +import SyncProgressService from '../../services/sync-progress' +import { BlockTips, LumosCellQuery, Connector, AppendScript } from './connector' +import { scriptToHash } from '@nervosnetwork/ckb-sdk-utils' +import { FetchTransactionReturnType, LightRPC, LightScriptFilter } from '../../utils/ckb-rpc' +import HexUtils from '../../utils/hex' +import Multisig from '../../services/multisig' +import { SyncAddressType } from '../../database/chain/entities/sync-progress' +import WalletService from '../../services/wallets' +import AssetAccountInfo from '../../models/asset-account-info' +import { DepType } from '../../models/chain/cell-dep' +import { molecule, number } from '@ckb-lumos/codec' + +interface SyncQueueParam { + script: CKBComponents.Script + scriptType: CKBRPC.ScriptType + blockRange: [HexString, HexString] + cursor?: HexString +} + +const unpackGroup = molecule.vector( + molecule.struct( + { + tx_hash: number.Uint256BE, + index: number.Uint32LE + }, + ['tx_hash', 'index'] + ) +) + +export default class LightConnector extends Connector { + private lightRpc: LightRPC + private indexer: CkbIndexer + private addressMetas: AddressMeta[] + private syncQueue: QueueObject = queue(this.syncNextWithScript.bind(this), 1) + private indexerQueryQueue: QueueObject | undefined + private pollingIndexer: boolean = false + private syncInQueue: Map< + CKBComponents.Hash, + { blockStartNumber: number; blockEndNumber: number; cursor?: string } + > = new Map() + + public readonly blockTipsSubject: Subject = new Subject() + public readonly transactionsSubject = new Subject<{ txHashes: CKBComponents.Hash[]; params: CKBComponents.Hash }>() + + constructor(addresses: Address[], nodeUrl: string) { + super() + this.indexer = new CkbIndexer(nodeUrl, nodeUrl) + this.lightRpc = new LightRPC(nodeUrl) + this.addressMetas = addresses.map(address => AddressMeta.fromObject(address)) + this.indexerQueryQueue = queue(this.collectLiveCellsByScript.bind(this)) + + // fetch some dep cell + this.fetchDepCell() + } + + private async getDepTxs(): Promise { + const assetAccountInfo = new AssetAccountInfo() + const fetchCellDeps = [ + assetAccountInfo.anyoneCanPayCellDep, + assetAccountInfo.sudtCellDep, + assetAccountInfo.getNftClassInfo().cellDep, + assetAccountInfo.getNftInfo().cellDep, + assetAccountInfo.getNftIssuerInfo().cellDep, + assetAccountInfo.getLegacyAnyoneCanPayInfo().cellDep, + assetAccountInfo.getChequeInfo().cellDep + ] + const fetchTxHashes = fetchCellDeps + .map(v => v.outPoint.txHash) + .map<[string, string]>(v => ['fetchTransaction', v]) + const txs = await this.lightRpc + .createBatchRequest(fetchTxHashes) + .exec() + if (txs.some(v => !v.txWithStatus)) { + // wait for light client sync the dep cell + await scheduler.wait(10000) + return await this.getDepTxs() + } + return fetchCellDeps + .map((v, idx) => { + if (v.depType === DepType.DepGroup) { + const tx = txs[idx] + return tx.txWithStatus ? tx?.txWithStatus?.transaction?.outputsData?.[+v.outPoint.index] : undefined + } + }) + .filter((v): v is string => !!v) + } + + private async fetchDepCell() { + const depGroupOutputsData: string[] = await this.getDepTxs() + const depGroupTxHashes = [ + ...new Set(depGroupOutputsData.map(v => unpackGroup.unpack(v).map(v => v.tx_hash.toHexString())).flat()) + ] + if (depGroupTxHashes.length) { + await this.lightRpc + .createBatchRequest( + depGroupTxHashes.map(v => ['fetchTransaction', v]) + ) + .exec() + } + } + + private async synchronize() { + if (!this.syncQueue.idle()) { + return + } + await this.subscribeSync() + const syncScripts = await this.lightRpc.getScripts() + const syncStatusMap = await SyncProgressService.getAllSyncStatusToMap() + syncStatusMap.forEach(v => { + if (v.cursor && !this.syncInQueue.has(v.hash)) { + this.syncQueue.push({ + script: { + codeHash: v.codeHash, + hashType: v.hashType, + args: v.args + }, + blockRange: [HexUtils.toHex(v.blockStartNumber), HexUtils.toHex(v.blockEndNumber)], + scriptType: v.scriptType, + cursor: v.cursor + }) + } + }) + syncScripts.forEach(syncScript => { + const scriptHash = scriptToHash(syncScript.script) + const syncStatus = syncStatusMap.get(scriptHash) + if ( + syncStatus && + !this.syncInQueue.has(scriptHash) && + !syncStatus.cursor && + syncStatus.blockEndNumber < parseInt(syncScript.blockNumber) + ) { + this.syncQueue.push({ + script: syncScript.script, + blockRange: [HexUtils.toHex(syncStatus.blockEndNumber), syncScript.blockNumber], + scriptType: syncScript.scriptType, + cursor: undefined + }) + } + }) + } + + private async subscribeSync() { + const minSyncBlockNumber = await SyncProgressService.getCurrentWalletMinBlockNumber() + const header = await this.lightRpc.getTipHeader() + this.blockTipsSubject.next({ + cacheTipNumber: minSyncBlockNumber, + indexerTipNumber: +header.number + }) + } + + private async initSyncProgress(appendScripts: AppendScript[] = []) { + if (!this.addressMetas.length && !appendScripts.length) { + return + } + const syncScripts = await this.lightRpc.getScripts() + const existSyncscripts: Record = {} + syncScripts.forEach(v => { + existSyncscripts[scriptToHash(v.script)] = v + }) + const currentWalletId = WalletService.getInstance().getCurrent()?.id + const allScripts = this.addressMetas + .filter(v => (currentWalletId ? v.walletId === currentWalletId : true)) + .map(addressMeta => { + const lockScripts = [ + addressMeta.generateDefaultLockScript(), + addressMeta.generateACPLockScript(), + addressMeta.generateLegacyACPLockScript() + ] + return lockScripts.map(v => ({ + script: v.toSDK(), + scriptType: 'lock' as CKBRPC.ScriptType, + walletId: addressMeta.walletId + })) + }) + .flat() + const walletMinBlockNumber = await SyncProgressService.getWalletMinBlockNumber() + const wallets = await WalletService.getInstance().getAll() + const walletStartBlockMap = wallets.reduce>( + (pre, cur) => ({ ...pre, [cur.id]: cur.startBlockNumberInLight }), + {} + ) + const otherTypeSyncProgress = await SyncProgressService.getOtherTypeSyncProgress() + const setScriptsParams = [ + ...allScripts.map(v => ({ + ...v, + blockNumber: + existSyncscripts[scriptToHash(v.script)]?.blockNumber ?? + walletStartBlockMap[v.walletId] ?? + `0x${(walletMinBlockNumber?.[v.walletId] ?? 0).toString(16)}` + })), + ...appendScripts.map(v => ({ + ...v, + blockNumber: + existSyncscripts[scriptToHash(v.script)]?.blockNumber ?? + `0x${(otherTypeSyncProgress[scriptToHash(v.script)] ?? 0).toString(16)}` + })) + ] + await this.lightRpc.setScripts(setScriptsParams) + const walletIds = [...new Set(this.addressMetas.map(v => v.walletId))] + await SyncProgressService.resetSyncProgress([allScripts, appendScripts].flat()) + await SyncProgressService.updateSyncProgressFlag(walletIds) + await SyncProgressService.removeByHashesAndAddressType( + SyncAddressType.Multisig, + appendScripts.map(v => scriptToHash(v.script)) + ) + } + + private async initSync() { + const appendScripts = await Multisig.getMultisigConfigForLight() + await this.initSyncProgress(appendScripts) + while (this.pollingIndexer) { + await this.synchronize() + await scheduler.wait(5000) + } + } + + private async syncNextWithScript({ script, scriptType, blockRange, cursor }: SyncQueueParam) { + const syncProgress = await SyncProgressService.getSyncStatus(script) + if (!syncProgress) { + return + } + const result = await this.lightRpc.getTransactions({ script, blockRange, scriptType }, 'asc', '0x64', cursor!) + if (!result.txs.length) { + await SyncProgressService.updateSyncStatus(syncProgress.hash, { + blockStartNumber: parseInt(blockRange[1]), + blockEndNumber: parseInt(blockRange[1]), + cursor: undefined + }) + return + } + this.transactionsSubject.next({ txHashes: result.txs.map(v => v.txHash), params: syncProgress.hash }) + this.syncInQueue.set(syncProgress.hash, { + blockStartNumber: result.lastCursor === '0x' ? parseInt(blockRange[1]) : parseInt(blockRange[0]), + blockEndNumber: parseInt(blockRange[1]), + cursor: result.lastCursor === '0x' ? undefined : result.lastCursor + }) + } + + private async collectLiveCellsByScript(query: LumosCellQuery) { + const { lock, type, data } = query + if (!lock && !type) { + throw new Error('at least one script is required') + } + + const queries: QueryOptions = {} + if (lock) { + queries.lock = { + code_hash: lock.codeHash, + hash_type: lock.hashType, + args: lock.args + } + } + if (type) { + queries.type = { + code_hash: type.codeHash, + hash_type: type.hashType, + args: type.args + } + } + queries.data = data || 'any' + + const collector = new CellCollector(this.indexer, queries) + + const result = [] + for await (const cell of collector.collect()) { + result.push(cell) + } + return result + } + + public async connect() { + try { + logger.info('LightConnector:\tconnect ...:') + this.pollingIndexer = true + this.initSync() + } catch (error) { + logger.error(`Error connecting to Light: ${error.message}`) + throw error + } + } + + public stop(): void { + this.pollingIndexer = false + } + + public async getLiveCellsByScript(query: LumosCellQuery) { + return new Promise((resolve, reject) => { + this.indexerQueryQueue!.push(query, (err: any, result: unknown) => { + if (err) { + return reject(err) + } + resolve(result) + }) + }) + } + + public async notifyCurrentBlockNumberProcessed(hash: CKBComponents.Hash) { + const nextSyncParams = this.syncInQueue.get(hash) + if (nextSyncParams) { + try { + await SyncProgressService.updateSyncStatus(hash, nextSyncParams) + } finally { + this.syncInQueue.delete(hash) + } + } + await this.subscribeSync() + } + + async appendScript(scripts: AppendScript[]) { + this.initSyncProgress(scripts) + } +} diff --git a/packages/neuron-wallet/src/block-sync-renderer/sync/queue.ts b/packages/neuron-wallet/src/block-sync-renderer/sync/queue.ts index 4b3903e29c..aa8b7102c9 100644 --- a/packages/neuron-wallet/src/block-sync-renderer/sync/queue.ts +++ b/packages/neuron-wallet/src/block-sync-renderer/sync/queue.ts @@ -1,4 +1,4 @@ -import { queue, AsyncQueue } from 'async' +import { queue, QueueObject } from 'async' import { TransactionPersistor } from '../../services/tx' import RpcService from '../../services/rpc-service' import AssetAccountService from '../../services/asset-account-service' @@ -10,12 +10,17 @@ import AssetAccountInfo from '../../models/asset-account-info' import { Address as AddressInterface } from '../../models/address' import AddressParser from '../../models/address-parser' import Multisig from '../../models/multisig' +import BlockHeader from '../../models/chain/block-header' import TxAddressFinder from './tx-address-finder' -import IndexerConnector, { BlockTips } from './indexer-connector' +import IndexerConnector from './indexer-connector' import IndexerCacheService from './indexer-cache-service' import logger from '../../utils/logger' import CommonUtils from '../../utils/common' import { ShouldInChildProcess } from '../../exceptions' +import { AppendScript, BlockTips, Connector } from './connector' +import LightConnector from './light-connector' +import { generateRPC } from '../../utils/ckb-rpc' +import { BUNDLED_LIGHT_CKB_URL } from '../../utils/const' export default class Queue { #lockHashes: string[] @@ -23,8 +28,8 @@ export default class Queue { #indexerUrl: string #addresses: AddressInterface[] #rpcService: RpcService - #indexerConnector: IndexerConnector | undefined - #checkAndSaveQueue: AsyncQueue<{ transactions: Transaction[] }> | undefined + #indexerConnector: Connector | undefined + #checkAndSaveQueue: QueueObject<{ txHashes: CKBComponents.Hash[], params: unknown }> | undefined #multiSignBlake160s: string[] #anyoneCanPayLockHashes: string[] @@ -48,9 +53,12 @@ export default class Queue { start = async () => { logger.info('Queue:\tstart') try { - this.#indexerConnector = new IndexerConnector(this.#addresses, this.#url, this.#indexerUrl) - - await this.#indexerConnector.connect() + if (this.#url === BUNDLED_LIGHT_CKB_URL) { + this.#indexerConnector = new LightConnector(this.#addresses, this.#url) + } else { + this.#indexerConnector = new IndexerConnector(this.#addresses, this.#url, this.#indexerUrl) + } + await this.#indexerConnector!.connect() } catch (error) { logger.error('Restarting child process due to error', error.message) if (process.send) { @@ -63,12 +71,12 @@ export default class Queue { this.#indexerConnector.blockTipsSubject.subscribe(tip => this.#updateBlockNumberTips(tip)) this.#checkAndSaveQueue = queue(async (task: any) => { - const { transactions } = task + const { txHashes, params } = task //need to retry after a certain period of time if throws errors // eslint-disable-next-line no-constant-condition while (true) { try { - await this.#checkAndSave(transactions) + await this.#checkAndSave(txHashes) break } catch (error) { logger.error('retry saving transactions in 2 seconds due to error:', error) @@ -76,22 +84,22 @@ export default class Queue { } } - this.#indexerConnector!.notifyCurrentBlockNumberProcessed(transactions[0].blockNumber) + this.#indexerConnector!.notifyCurrentBlockNumberProcessed(params) }) this.#checkAndSaveQueue.error((err: any, task: any) => { logger.error(err, JSON.stringify(task, undefined, 2)) }) - this.#indexerConnector.transactionsSubject.subscribe(transactions => { - const task = { transactions: transactions.map(t => t.transaction) } - this.#checkAndSaveQueue!.push(task) - }) + this.#indexerConnector.transactionsSubject + .subscribe(task => { + this.#checkAndSaveQueue!.push(task) + }) } - getIndexerConnector = (): IndexerConnector => this.#indexerConnector! + getIndexerConnector = (): Connector => this.#indexerConnector! - stop = () => (this.#indexerConnector!.pollingIndexer = false) + stop = () => this.#indexerConnector!.stop() stopAndWait = async () => { this.stop() @@ -100,7 +108,45 @@ export default class Queue { } } - #checkAndSave = async (transactions: Transaction[]): Promise => { + async appendLightScript(scripts: AppendScript[]) { + await this.#indexerConnector?.appendScript(scripts) + } + + private async fetchTxsWithStatus(txHashes: string[]) { + const rpc = generateRPC(this.#url) + const txsWithStatus = await rpc.createBatchRequest<'getTransaction', string[], CKBComponents.TransactionWithStatus[]>( + txHashes.map(v => ['getTransaction', v]) + ).exec() + const txs: Transaction[] = [] + const blockHashes = [] + for (let index = 0; index < txsWithStatus.length; index++) { + if (txsWithStatus[index]?.transaction) { + const tx = Transaction.fromSDK(txsWithStatus[index].transaction) + tx.blockHash = txsWithStatus[index].txStatus.blockHash! + blockHashes.push(tx.blockHash) + txs.push(tx) + } else { + if ((txsWithStatus[index].txStatus as any) === 'rejected') { + logger.warn(`Transaction[${txHashes[index]}] was rejected`) + } + throw new Error(`failed to fetch transaction for hash ${txHashes[index]}`) + } + } + const headers = await rpc.createBatchRequest<'getHeader', string[], CKBComponents.BlockHeader[]>( + blockHashes.map(v => ['getHeader', v]) + ).exec() + headers.forEach((blockHeader, idx) => { + if (blockHeader) { + const header = BlockHeader.fromSDK(blockHeader) + txs[idx].timestamp = header.timestamp + txs[idx].blockNumber = header.number + } + }) + return txs + } + + #checkAndSave = async (txHashes: CKBComponents.Hash[]): Promise => { + const transactions = await this.fetchTxsWithStatus(txHashes) const cachedPreviousTxs = new Map() const fetchTxQueue = queue(async (task: any) => { diff --git a/packages/neuron-wallet/src/block-sync-renderer/task.ts b/packages/neuron-wallet/src/block-sync-renderer/task.ts index ff33424118..eeb58d67c3 100644 --- a/packages/neuron-wallet/src/block-sync-renderer/task.ts +++ b/packages/neuron-wallet/src/block-sync-renderer/task.ts @@ -1,4 +1,4 @@ -import type { LumosCellQuery } from './sync/indexer-connector' +import type { LumosCellQuery } from './sync/connector' import initConnection from '../database/chain/ormconfig' import { register as registerTxStatusListener } from './tx-status-listener' import SyncQueue from './sync/queue' @@ -9,18 +9,9 @@ import env from '../env' let syncQueue: SyncQueue | null export interface WorkerMessage { - type: 'call' | 'response' | 'kill' - id?: number - channel: - | 'start' - | 'queryIndexer' - | 'unmount' - | 'cache-tip-block-updated' - | 'tx-db-changed' - | 'wallet-deleted' - | 'address-created' - | 'indexer-error' - | 'check-and-save-wallet-address' + type: 'call' | 'response' | 'kill', + id?: number, + channel: 'start' | 'queryIndexer' | 'unmount' | 'cache-tip-block-updated' | 'tx-db-changed' | 'wallet-deleted' | 'address-created' | 'indexer-error' | 'check-and-save-wallet-address' | 'append_scripts' message: T } @@ -87,6 +78,12 @@ export const listener = async ({ type, id, channel, message }: WorkerMessage) => res = message ? await syncQueue?.getIndexerConnector()?.getLiveCellsByScript(message) : [] break } + case 'append_scripts': { + if (Array.isArray(message)) { + await syncQueue?.appendLightScript(message) + } + break + } default: { // ignore } diff --git a/packages/neuron-wallet/src/block-sync-renderer/tx-status-listener.ts b/packages/neuron-wallet/src/block-sync-renderer/tx-status-listener.ts index 7bb9d8dbc9..ae10bb37f5 100644 --- a/packages/neuron-wallet/src/block-sync-renderer/tx-status-listener.ts +++ b/packages/neuron-wallet/src/block-sync-renderer/tx-status-listener.ts @@ -1,5 +1,4 @@ import { getConnection } from 'typeorm' -import CKB from '@nervosnetwork/ckb-sdk-core' import { CONNECTION_NOT_FOUND_NAME } from '../database/chain/ormconfig' import { FailedTransaction, TransactionPersistor } from '../services/tx' import RpcService from '../services/rpc-service' @@ -62,8 +61,7 @@ const trackingStatus = async () => { if (successTxs.length > 0) { const url: string = NetworksService.getInstance().getCurrent().remote - const ckb = new CKB(url) - const rpcService = new RpcService(ckb.rpc.node.url) + const rpcService = new RpcService(url) for (const successTx of successTxs) { const transaction = successTx.tx! const { blockHash } = successTx diff --git a/packages/neuron-wallet/src/controllers/api.ts b/packages/neuron-wallet/src/controllers/api.ts index 82acd734a2..a70dd91e62 100644 --- a/packages/neuron-wallet/src/controllers/api.ts +++ b/packages/neuron-wallet/src/controllers/api.ts @@ -51,13 +51,14 @@ import { GenerateAnyoneCanPayTxParams, SendAnyoneCanPayTxParams } from './anyone import { DeviceInfo, ExtendedPublicKey } from '../services/hardware/common' import HardwareController from './hardware' import OfflineSignController from './offline-sign' -import SUDTController from './sudt' +import SUDTController from '../controllers/sudt' import SyncedBlockNumber from '../models/synced-block-number' import IndexerService from '../services/indexer' import MultisigConfigModel from '../models/multisig-config' import startMonitor, { stopMonitor } from '../services/monitor' import { migrateCkbData } from '../services/ckb-runner' import NodeService from '../services/node' +import SyncProgressService from '../services/sync-progress' export type Command = 'export-xpubkey' | 'import-xpubkey' | 'delete-wallet' | 'backup-wallet' | 'migrate-acp' // Handle channel messages from renderer process and user actions. @@ -778,6 +779,14 @@ export default class ApiController { status: ResponseCode.Success, } }) + + //light client + handle('get-sync-progress-by-addresses', async (_, hashes: string[]) => { + return { + result: (await SyncProgressService.getSyncProgressByHashes(hashes)), + status: ResponseCode.Success, + } + }) } // Register handler, warp and serialize API response diff --git a/packages/neuron-wallet/src/controllers/app/index.ts b/packages/neuron-wallet/src/controllers/app/index.ts index ac95e2847d..80b6fa4c8c 100644 --- a/packages/neuron-wallet/src/controllers/app/index.ts +++ b/packages/neuron-wallet/src/controllers/app/index.ts @@ -9,11 +9,12 @@ import logger from '../../utils/logger' import { subscribe } from './subscribe' import { register as registerListeners } from '../../listeners/main' import WalletsService from '../../services/wallets' -import ApiController, { Command } from '../api' -import { migrate as mecuryMigrate } from '../mercury' -import SyncApiController from '../sync-api' +import ApiController, { Command } from '../../controllers/api' +import { migrate as mecuryMigrate } from '../../controllers/mercury' +import SyncApiController from '../../controllers/sync-api' import { SETTINGS_WINDOW_TITLE } from '../../utils/const' import { stopCkbNode } from '../../services/ckb-runner' +import { CKBLightRunner } from '../../services/light-runner' const app = electronApp @@ -58,7 +59,10 @@ export default class AppController { if (env.isTestMode) { return } - await stopCkbNode() + await Promise.all([ + stopCkbNode(), + CKBLightRunner.getInstance().stop(), + ]) } public registerChannels(win: BrowserWindow, channels: string[]) { diff --git a/packages/neuron-wallet/src/controllers/app/menu.ts b/packages/neuron-wallet/src/controllers/app/menu.ts index 426c01e940..19c5028898 100644 --- a/packages/neuron-wallet/src/controllers/app/menu.ts +++ b/packages/neuron-wallet/src/controllers/app/menu.ts @@ -5,9 +5,9 @@ import { t } from 'i18next' import { Subject } from 'rxjs' import { throttleTime } from 'rxjs/operators' import env from '../../env' -import UpdateController from '../update' -import ExportDebugController from '../export-debug' -import { showWindow } from '../app/show-window' +import UpdateController from '../../controllers/update' +import ExportDebugController from '../../controllers/export-debug' +import { showWindow } from '../../controllers/app/show-window' import WalletsService from '../../services/wallets' import OfflineSignService from '../../services/offline-sign' import CommandSubject from '../../models/subjects/command' @@ -16,6 +16,8 @@ import { SETTINGS_WINDOW_TITLE, SETTINGS_WINDOW_WIDTH } from '../../utils/const' import { OfflineSignJSON } from '../../models/offline-sign' import NetworksService from '../../services/networks' import { clearCkbNodeCache } from '../../services/ckb-runner' +import { CKBLightRunner } from '../../services/light-runner' +import { NetworkType } from '../../models/network' enum URL { Settings = '/settings/general', @@ -38,19 +40,28 @@ const separator: MenuItemConstructorOptions = { type: 'separator', } -const showAbout = () => { - let applicationVersion = t('about.app-version', { name: app.name, version: app.getVersion() }) - - const appPath = app.isPackaged ? app.getAppPath() : path.join(__dirname, '../../../../..') - const ckbVersionPath = path.join(appPath, '.ckb-version') - if (fs.existsSync(ckbVersionPath)) { +const getVerionFromFile = (filePath: string) => { + if (fs.existsSync(filePath)) { try { - const ckbVersion = fs.readFileSync(ckbVersionPath, 'utf8') - applicationVersion += `\n${t('about.ckb-client-version', { version: ckbVersion })}` + return fs.readFileSync(filePath, 'utf8') } catch (err) { logger.error(`[Menu]: `, err) } } +} + +const showAbout = () => { + let applicationVersion = t('about.app-version', { name: app.name, version: app.getVersion() }) + + const appPath = app.isPackaged ? app.getAppPath() : path.join(__dirname, '../../../../..') + const ckbVersion = getVerionFromFile(path.join(appPath, '.ckb-version')) + if (ckbVersion) { + applicationVersion += `\n${t('about.ckb-client-version', { version: ckbVersion })}` + } + const ckbLightClientVersion = getVerionFromFile(path.join(appPath, '.ckb-light-version')) + if (ckbLightClientVersion) { + applicationVersion += `${t('about.ckb-light-client-version', { version: ckbLightClientVersion })}` + } const isWin = process.platform === 'win32' @@ -116,7 +127,7 @@ const updateApplicationMenu = (mainWindow: BrowserWindow | null) => { let isMainWindow = mainWindow === currentWindow const walletsService = WalletsService.getInstance() - const isMainnet = new NetworksService().getCurrent().chain === 'ckb' + const network = new NetworksService().getCurrent() const wallets = walletsService.getAll().map(({ id, name }) => ({ id, name })) const currentWallet = walletsService.getCurrent() const hasCurrentWallet = currentWallet !== undefined @@ -316,10 +327,10 @@ const updateApplicationMenu = (mainWindow: BrowserWindow | null) => { `#/multisig-address/${currentWallet!.id}`, t(`messageBox.multisig-address.title`), { - width: 900, - maxWidth: 900, - minWidth: 900, - resizable: true, + width: 1000, + maxWidth: 1000, + minWidth: 1000, + resizable: true }, ['multisig-output-update'] ) @@ -327,7 +338,7 @@ const updateApplicationMenu = (mainWindow: BrowserWindow | null) => { }, { label: t('application-menu.tools.clear-sync-data'), - enabled: hasCurrentWallet && isMainnet, + enabled: hasCurrentWallet && (network.chain === 'ckb' || NetworkType.Light === network.type), click: async () => { const res = await dialog.showMessageBox({ type: 'warning', @@ -338,7 +349,12 @@ const updateApplicationMenu = (mainWindow: BrowserWindow | null) => { cancelId: 1, }) if (res.response === 0) { - await clearCkbNodeCache() + const network = new NetworksService().getCurrent() + if (network.type === NetworkType.Light) { + await CKBLightRunner.getInstance().clearNodeCache() + } else { + await clearCkbNodeCache() + } } }, }, diff --git a/packages/neuron-wallet/src/controllers/export-debug.ts b/packages/neuron-wallet/src/controllers/export-debug.ts index 0d3d36a267..9847fae5f2 100644 --- a/packages/neuron-wallet/src/controllers/export-debug.ts +++ b/packages/neuron-wallet/src/controllers/export-debug.ts @@ -2,7 +2,6 @@ import os from 'os' import fs from 'fs' import path from 'path' import archiver from 'archiver' -import CKB from '@nervosnetwork/ckb-sdk-core' import { app, dialog } from 'electron' import logger from '../utils/logger' import { t } from 'i18next' @@ -11,6 +10,8 @@ import SyncedBlockNumber from '../models/synced-block-number' import AddressService from '../services/addresses' import redistCheck from '../utils/redist-check' import SettingsService from '../services/settings' +import { generateRPC } from '../utils/ckb-rpc' +import { CKBLightRunner } from '../services/light-runner' export default class ExportDebugController { #I18N_PATH = 'export-debug-info' @@ -41,6 +42,7 @@ export default class ExportDebugController { this.addBundledCKBLog(), this.addLogFiles(), this.addHdPublicKeyInfoCsv(), + this.addBundledCKBLightClientLog() ]) await this.archive.finalize() dialog.showMessageBox({ @@ -55,23 +57,25 @@ export default class ExportDebugController { private addStatusFile = async () => { const neuronVersion = app.getVersion() const url = NetworksService.getInstance().getCurrent().remote - const ckb = new CKB(url) + const rpcService = generateRPC(url) const [syncedBlockNumber, ckbVersion, tipBlockNumber, peers, vcredist] = await Promise.all([ new SyncedBlockNumber() .getNextBlock() .then(n => n.toString()) .catch(() => ''), - ckb.rpc + rpcService .localNodeInfo() - .then(res => res.version) + .then(v => v.version) .catch(() => ''), - ckb.rpc + rpcService .getTipBlockNumber() .then(n => BigInt(n).toString()) .catch(() => ''), - ckb.rpc.getPeers().catch(() => []), - redistCheck(), + rpcService + .getPeers() + .catch(() => []), + redistCheck() ]) const { platform, arch } = process const release = os.release() @@ -154,4 +158,10 @@ export default class ExportDebugController { this.archive.file(path.join(logFile.path, '..', file), { name: file }) }) } + + private addBundledCKBLightClientLog() { + const logPath = CKBLightRunner.getInstance().logPath + if (!fs.existsSync(logPath)) {return} + this.archive.file(logPath, { name: 'bundled-ckb-lignt-client.log' }) + } } diff --git a/packages/neuron-wallet/src/controllers/networks/index.ts b/packages/neuron-wallet/src/controllers/networks/index.ts index b25d31e06c..2ff247281f 100644 --- a/packages/neuron-wallet/src/controllers/networks/index.ts +++ b/packages/neuron-wallet/src/controllers/networks/index.ts @@ -26,7 +26,7 @@ export default class NetworksController { await this.connectToNetwork(true) } else { logger.debug('Network:\tconnection dropped') - resetSyncTaskQueue.push(false) + resetSyncTaskQueue.asyncPush(false) } }) diff --git a/packages/neuron-wallet/src/controllers/offline-sign.ts b/packages/neuron-wallet/src/controllers/offline-sign.ts index 5899beb4c8..9b308c1427 100644 --- a/packages/neuron-wallet/src/controllers/offline-sign.ts +++ b/packages/neuron-wallet/src/controllers/offline-sign.ts @@ -13,6 +13,7 @@ import NodeService from '../services/node' import { MultisigNotSignedNeedError, OfflineSignFailed } from '../exceptions' import MultisigConfigModel from '../models/multisig-config' import { getMultisigStatus } from '../utils/multisig' +import { generateRPC } from '../utils/ckb-rpc' export default class OfflineSignController { public async exportTransactionAsJSON({ @@ -35,12 +36,12 @@ export default class OfflineSignController { } const tx = Transaction.fromObject(transaction) - const { ckb } = NodeService.getInstance() + const rpc = generateRPC(NodeService.getInstance().nodeUrl) if (context === undefined) { - const rawTx = ckb.rpc.paramsFormatter.toRawTransaction(tx.toSDKRawTransaction()) - const txs = await Promise.all(rawTx.inputs.map(i => ckb.rpc.getTransaction(i.previous_output!.tx_hash))) - context = txs.map(i => ckb.rpc.paramsFormatter.toRawTransaction(i.transaction)) + const rawTx = rpc.paramsFormatter.toRawTransaction(tx.toSDKRawTransaction()) + const txs = await Promise.all(rawTx.inputs.map(i => rpc.getTransaction(i.previous_output!.tx_hash))) + context = txs.map(i => rpc.paramsFormatter.toRawTransaction(i.transaction)) } const signer = OfflineSign.fromJSON({ diff --git a/packages/neuron-wallet/src/controllers/sync-api.ts b/packages/neuron-wallet/src/controllers/sync-api.ts index b21b199386..7c74fc4e45 100644 --- a/packages/neuron-wallet/src/controllers/sync-api.ts +++ b/packages/neuron-wallet/src/controllers/sync-api.ts @@ -58,7 +58,7 @@ export default class SyncApiController { } #getEstimatesByCurrentNode = () => { - const nodeUrl = this.#getCurrentNodeUrl() + const nodeUrl = NodeService.getInstance().nodeUrl return this.#estimates.filter( state => state.nodeUrl === nodeUrl && Date.now() - state.timestamp <= this.#sampleTime ) @@ -103,8 +103,8 @@ export default class SyncApiController { return newSyncState } - #fetchBestKnownBlockInfo = async (): Promise<{ bestKnownBlockNumber: number; bestKnownBlockTimestamp: number }> => { - const nodeUrl = this.#getCurrentNodeUrl() + #fetchBestKnownBlockInfo = async (): Promise<{ bestKnownBlockNumber: number, bestKnownBlockTimestamp: number }> => { + const nodeUrl = NodeService.getInstance().nodeUrl const rpcService = new RpcService(nodeUrl) try { const syncState = await rpcService.getSyncState() @@ -122,17 +122,12 @@ export default class SyncApiController { } } - #getCurrentNodeUrl = () => { - const ckb = NodeService.getInstance().ckb - return ckb.node.url - } - #estimate = async (states: any): Promise => { const indexerTipNumber = parseInt(states.indexerTipNumber) const cacheTipNumber = parseInt(states.cacheTipNumber) const currentTimestamp = Date.now() - const nodeUrl = this.#getCurrentNodeUrl() + const nodeUrl = NodeService.getInstance().nodeUrl const tipHeader = await new RpcService(nodeUrl).getTipHeader() const { bestKnownBlockNumber, bestKnownBlockTimestamp } = await this.#fetchBestKnownBlockInfo() @@ -205,7 +200,7 @@ export default class SyncApiController { return this.#cachedEstimation } - const nodeUrl = this.#getCurrentNodeUrl() + const nodeUrl = NodeService.getInstance().nodeUrl if ( this.#cachedEstimation.nodeUrl !== nodeUrl || @@ -228,7 +223,7 @@ export default class SyncApiController { }) CurrentNetworkIDSubject.pipe(debounceTime(500)).subscribe(() => { - const nodeUrl = this.#getCurrentNodeUrl() + const nodeUrl = NodeService.getInstance().nodeUrl const newSyncState: SyncState = { nodeUrl, timestamp: 0, diff --git a/packages/neuron-wallet/src/controllers/wallets.ts b/packages/neuron-wallet/src/controllers/wallets.ts index 37afcf66d2..913607ca63 100644 --- a/packages/neuron-wallet/src/controllers/wallets.ts +++ b/packages/neuron-wallet/src/controllers/wallets.ts @@ -20,9 +20,10 @@ import { InvalidJSON, InvalidAddress, UsedName, + MainnetAddressRequired, + TestnetAddressRequired } from '../exceptions' import AddressService from '../services/addresses' -import { MainnetAddressRequired, TestnetAddressRequired } from '../exceptions/address' import TransactionSender from '../services/transaction-sender' import Transaction from '../models/chain/transaction' import logger from '../utils/logger' @@ -31,6 +32,8 @@ import HardwareWalletService from '../services/hardware' import { DeviceInfo, ExtendedPublicKey } from '../services/hardware/common' import AddressParser from '../models/address-parser' import MultisigConfigModel from '../models/multisig-config' +import NodeService from '../services/node' +import { generateRPC } from '../utils/ckb-rpc' export default class WalletsController { public async getAll(): Promise[]>> { @@ -116,11 +119,21 @@ export default class WalletsController { ) const walletsService = WalletsService.getInstance() + const rpc = generateRPC(NodeService.getInstance().nodeUrl) + let startBlockNumberInLight: string | undefined = undefined + if (!isImporting) { + try { + startBlockNumberInLight = await rpc.getTipBlockNumber() + } catch (error) { + startBlockNumberInLight = undefined + } + } const wallet = walletsService.create({ id: '', name, extendedKey: accountExtendedPublicKey.serialize(), keystore, + startBlockNumberInLight }) wallet.checkAndGenerateAddresses(isImporting) diff --git a/packages/neuron-wallet/src/database/chain/entities/multisig-config.ts b/packages/neuron-wallet/src/database/chain/entities/multisig-config.ts index d32b193a25..30128092ae 100644 --- a/packages/neuron-wallet/src/database/chain/entities/multisig-config.ts +++ b/packages/neuron-wallet/src/database/chain/entities/multisig-config.ts @@ -53,7 +53,7 @@ export default class MultisigConfig { this.changed('AfterRemove') } - private changed = (event: string) => { + private changed = (event: 'AfterInsert' | 'AfterRemove') => { MultisigConfigDbChangedSubject.getSubject().next(event) } } diff --git a/packages/neuron-wallet/src/database/chain/entities/sync-progress.ts b/packages/neuron-wallet/src/database/chain/entities/sync-progress.ts new file mode 100644 index 0000000000..1d6d2d27dc --- /dev/null +++ b/packages/neuron-wallet/src/database/chain/entities/sync-progress.ts @@ -0,0 +1,62 @@ +import { HexString } from '@ckb-lumos/base' +import { scriptToHash } from '@nervosnetwork/ckb-sdk-utils' +import { Entity, PrimaryColumn, Column } from 'typeorm' + +export enum SyncAddressType { + Default, + Multisig +} + +@Entity({ name: 'sync_progress' }) +export default class SyncProgress { + @PrimaryColumn({ type: 'varchar' }) + hash!: string + + @Column({ type: 'varchar' }) + args!: string + + @Column({ type: 'varchar' }) + codeHash!: string + + @Column({ type: 'varchar' }) + hashType!: CKBComponents.ScriptHashType + + @Column() + scriptType!: CKBRPC.ScriptType + + @Column({ type: 'varchar' }) + walletId!: string + + @Column() + blockStartNumber: number = 0 + + @Column() + blockEndNumber: number = 0 + + @Column({ type: 'varchar' }) + cursor?: HexString + + @Column({ type: 'boolean' }) + delete: boolean = false + + @Column() + addressType: SyncAddressType = SyncAddressType.Default + + static fromObject(obj: { + script: CKBComponents.Script + scriptType: CKBRPC.ScriptType + walletId: string + addressType?: SyncAddressType + }) { + const res = new SyncProgress() + res.hash = scriptToHash(obj.script) + res.args = obj.script.args + res.codeHash = obj.script.codeHash + res.hashType = obj.script.hashType + res.walletId = obj.walletId + res.scriptType = obj.scriptType + res.delete = false + res.addressType = obj.addressType ?? SyncAddressType.Default + return res + } +} diff --git a/packages/neuron-wallet/src/database/chain/index.ts b/packages/neuron-wallet/src/database/chain/index.ts index 3ad38d7498..94a266ed6a 100644 --- a/packages/neuron-wallet/src/database/chain/index.ts +++ b/packages/neuron-wallet/src/database/chain/index.ts @@ -1,21 +1,26 @@ import { getConnection } from 'typeorm' import MultisigOutputChangedSubject from '../../models/subjects/multisig-output-db-changed-subject' +import SyncProgressService from '../../services/sync-progress' import InputEntity from './entities/input' import OutputEntity from './entities/output' import TransactionEntity from './entities/transaction' import SyncInfoEntity from './entities/sync-info' import IndexerTxHashCache from './entities/indexer-tx-hash-cache' import MultisigOutput from './entities/multisig-output' +import SyncProgress from './entities/sync-progress' /* * Clean local sqlite storage */ -export const clean = async () => { - await Promise.all( - [InputEntity, OutputEntity, TransactionEntity, IndexerTxHashCache, MultisigOutput].map(entity => { - return getConnection().getRepository(entity).clear() - }) - ) +export const clean = async (clearAllLightClientData?: boolean) => { + await Promise.all([ + ...[InputEntity, OutputEntity, TransactionEntity, IndexerTxHashCache, MultisigOutput].map(entity => { + return getConnection() + .getRepository(entity) + .clear() + }), + clearAllLightClientData ? getConnection().getRepository(SyncProgress).clear() : SyncProgressService.clearCurrentWalletProgress() + ]) MultisigOutputChangedSubject.getSubject().next('reset') await getConnection().createQueryBuilder().delete().from(SyncInfoEntity).execute() diff --git a/packages/neuron-wallet/src/database/chain/migrations/1676441837373-AddSyncProgress.ts b/packages/neuron-wallet/src/database/chain/migrations/1676441837373-AddSyncProgress.ts new file mode 100644 index 0000000000..c3ba09b3d4 --- /dev/null +++ b/packages/neuron-wallet/src/database/chain/migrations/1676441837373-AddSyncProgress.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSyncProgress1676441837373 implements MigrationInterface { + name = 'AddSyncProgress1676441837373' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "sync_progress" + ( + "hash" varchar PRIMARY KEY NOT NULL, + "args" varchar NOT NULL, + "codeHash" varchar NOT NULL, + "hashType" varchar NOT NULL, + "scriptType" varchar NOT NULL, + "walletId" varchar NOT NULL, + "blockStartNumber" integer NOT NULL, + "blockEndNumber" integer, + "cursor" varchar, + "delete" boolean + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "sync_progress"`); + } + +} diff --git a/packages/neuron-wallet/src/database/chain/migrations/1681360188494-AddTypeSyncProgress.ts b/packages/neuron-wallet/src/database/chain/migrations/1681360188494-AddTypeSyncProgress.ts new file mode 100644 index 0000000000..70a0bd3ecc --- /dev/null +++ b/packages/neuron-wallet/src/database/chain/migrations/1681360188494-AddTypeSyncProgress.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm" +import Multisig from "../../../models/multisig" +import { scriptToHash } from "@nervosnetwork/ckb-sdk-utils" +import { SyncAddressType } from "../entities/sync-progress" +import MultisigConfig from "../entities/multisig-config" + +export class AddTypeSyncProgress1681360188494 implements MigrationInterface { + name = 'AddTypeSyncProgress1681360188494' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn('sync_progress', new TableColumn({ + name: 'addressType', + type: 'INTEGER', + isNullable: false, + default: SyncAddressType.Default, + })) + await queryRunner.createIndex("sync_progress", new TableIndex({ columnNames: ["addressType"] })) + const multisigConfigs = await queryRunner.connection + .getRepository(MultisigConfig) + .createQueryBuilder() + .getMany() + const scriptHashes = multisigConfigs.map(v => scriptToHash(Multisig.getMultisigScript(v.blake160s, v.r, v.m, v.n))) + await queryRunner.query(`UPDATE sync_progress set addressType=1 where hash in (${scriptHashes.map(v => `'${v}'`).join(',')})`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('sync_progress', 'addressType') + await queryRunner.dropIndex("sync_progress", new TableIndex({ columnNames: ["addressType"] })) + } + +} diff --git a/packages/neuron-wallet/src/database/chain/ormconfig.ts b/packages/neuron-wallet/src/database/chain/ormconfig.ts index e3910ceff3..b059bd03c1 100644 --- a/packages/neuron-wallet/src/database/chain/ormconfig.ts +++ b/packages/neuron-wallet/src/database/chain/ormconfig.ts @@ -17,6 +17,7 @@ import TxDescription from './entities/tx-description' import AddressDescription from './entities/address-description' import MultisigConfig from './entities/multisig-config' import MultisigOuput from './entities/multisig-output' +import SyncProgress from './entities/sync-progress' import { InitMigration1566959757554 } from './migrations/1566959757554-InitMigration' import { AddTypeAndHasData1567144517514 } from './migrations/1567144517514-AddTypeAndHasData' @@ -48,6 +49,8 @@ import { UpdateAddressDescription1650984779265 } from './migrations/165098477926 import { RemoveDuplicateBlake160s1656930265386 } from './migrations/1656930265386-RemoveDuplicateBlake160s' import { UpdateOutputChequeLockHash1652945662504 } from './migrations/1652945662504-UpdateOutputChequeLockHash' import { RemoveAddressesMultisigConfig1651820157100 } from './migrations/1651820157100-RemoveAddressesMultisigConfig' +import { AddSyncProgress1676441837373 } from './migrations/1676441837373-AddSyncProgress' +import { AddTypeSyncProgress1681360188494 } from './migrations/1681360188494-AddTypeSyncProgress' export const CONNECTION_NOT_FOUND_NAME = 'ConnectionNotFoundError' @@ -80,6 +83,7 @@ const connectOptions = async (genesisBlockHash: string): Promise { MultisigConfigDbChangedSubject.getSubject() .pipe(debounceTime(500)) - .subscribe(async (event: string) => { + .subscribe(async event => { try { if (event === 'AfterInsert') { await MultisigService.saveLiveMultisigOutput() diff --git a/packages/neuron-wallet/src/locales/en.ts b/packages/neuron-wallet/src/locales/en.ts index 9adfe49c03..6392f51a72 100644 --- a/packages/neuron-wallet/src/locales/en.ts +++ b/packages/neuron-wallet/src/locales/en.ts @@ -135,6 +135,7 @@ export default { 'no-match-address-for-sign': 'Not found matched address', 'target-lock-error': 'CKB asset account can only transfer to sepe256k1 or acp address', 'no-exist-ckb-node-data': '{{path}} has no CKB Node config and storage, press ok to synchronize from scratch', + 'light-client-sudt-acp-error': "Light client mode doesn't support sending assets to other's asset account" }, messageBox: { button: { @@ -247,6 +248,7 @@ export default { about: { 'app-version': '{{name}} Version: {{version}}', 'ckb-client-version': 'CKB Client Version: {{version}}', + 'ckb-light-client-version': 'CKB Light Client Version: {{version}}' }, settings: { title: { diff --git a/packages/neuron-wallet/src/locales/zh-tw.ts b/packages/neuron-wallet/src/locales/zh-tw.ts index d03db44bef..f14bf13462 100644 --- a/packages/neuron-wallet/src/locales/zh-tw.ts +++ b/packages/neuron-wallet/src/locales/zh-tw.ts @@ -125,6 +125,7 @@ export default { 'no-match-address-for-sign': '没有找到匹配的地址', 'target-lock-error': 'CKB 資產只能轉賬到 secp256k1 或者 acp 地址', 'no-exist-ckb-node-data': '{{path}} 目錄下沒有找到 CKB Node 配置和數據, 點擊繼續重新同步', + 'light-client-sudt-acp-error': '輕節點模式不支持發送資產給其他用戶的資產賬戶' }, messageBox: { button: { @@ -235,6 +236,7 @@ export default { about: { 'app-version': '{{name}} 版本: {{version}}', 'ckb-client-version': 'CKB 節點版本: {{version}}', + 'ckb-light-client-version': 'CKB 輕節點版本: {{version}}' }, settings: { title: { diff --git a/packages/neuron-wallet/src/locales/zh.ts b/packages/neuron-wallet/src/locales/zh.ts index a9b432473b..3712f7a416 100644 --- a/packages/neuron-wallet/src/locales/zh.ts +++ b/packages/neuron-wallet/src/locales/zh.ts @@ -126,6 +126,7 @@ export default { 'no-match-address-for-sign': '没有找到匹配的地址', 'target-lock-error': 'CKB 资产只能转账到 secp256k1 或者 acp 地址', 'no-exist-ckb-node-data': '{{path}} 目录下没有找到 CKB Node 配置和数据, 点击继续重新同步', + 'light-client-sudt-acp-error': '轻节点模式不支持发送资产给其他用户的资产账户' }, messageBox: { button: { @@ -236,6 +237,7 @@ export default { about: { 'app-version': '{{name}} 版本: {{version}}', 'ckb-client-version': 'CKB 节点版本: {{version}}', + 'ckb-light-client-version': 'CKB 轻节点版本: {{version}}' }, settings: { title: { diff --git a/packages/neuron-wallet/src/models/chain/live-cell.ts b/packages/neuron-wallet/src/models/chain/live-cell.ts index e72fa4b57b..8d8cd75a80 100644 --- a/packages/neuron-wallet/src/models/chain/live-cell.ts +++ b/packages/neuron-wallet/src/models/chain/live-cell.ts @@ -1,6 +1,6 @@ import Script, { ScriptHashType } from './script' import OutPoint from './out-point' -import { LumosCell } from '../../block-sync-renderer/sync/indexer-connector' +import { LumosCell } from '../../block-sync-renderer/sync/connector' const LUMOS_HASH_TYPE_MAP: Record = { type: ScriptHashType.Type, diff --git a/packages/neuron-wallet/src/models/network.ts b/packages/neuron-wallet/src/models/network.ts index 739be27e9a..dc37a4cf8d 100644 --- a/packages/neuron-wallet/src/models/network.ts +++ b/packages/neuron-wallet/src/models/network.ts @@ -1,9 +1,11 @@ export enum NetworkType { Default, // Preset mainnet node Normal, + Light } export const MAINNET_GENESIS_HASH = '0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5' +export const TESTNET_GENESIS_HASH = '0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606' export const EMPTY_GENESIS_HASH = '0x' export type ChainType = 'ckb' | 'ckb_testnet' | 'ckb_dev' diff --git a/packages/neuron-wallet/src/models/subjects/multisig-config-db-changed-subject.ts b/packages/neuron-wallet/src/models/subjects/multisig-config-db-changed-subject.ts index 70dee07b78..fc8358a5e8 100644 --- a/packages/neuron-wallet/src/models/subjects/multisig-config-db-changed-subject.ts +++ b/packages/neuron-wallet/src/models/subjects/multisig-config-db-changed-subject.ts @@ -2,7 +2,7 @@ import { ReplaySubject } from 'rxjs' // subscribe this Subject to monitor any transaction table changes export class MultisigConfigDbChangedSubject { - private static subject = new ReplaySubject(100) + private static subject = new ReplaySubject<'AfterInsert' | 'AfterRemove'>(100) public static getSubject() { return MultisigConfigDbChangedSubject.subject diff --git a/packages/neuron-wallet/src/models/system-script-info.ts b/packages/neuron-wallet/src/models/system-script-info.ts index d7241abb8f..b1a282f02a 100644 --- a/packages/neuron-wallet/src/models/system-script-info.ts +++ b/packages/neuron-wallet/src/models/system-script-info.ts @@ -101,7 +101,7 @@ export default class SystemScriptInfo { private async loadInfos(url: string): Promise { const rpcService = new RpcService(url) - const genesisBlock = (await rpcService.getBlockByNumber('0'))! + const genesisBlock = (await rpcService.getGenesisBlock())! const genesisBlockHash = genesisBlock.header.hash // set secp info diff --git a/packages/neuron-wallet/src/services/anyone-can-pay.ts b/packages/neuron-wallet/src/services/anyone-can-pay.ts index 5d68a50097..4046c3f92e 100644 --- a/packages/neuron-wallet/src/services/anyone-can-pay.ts +++ b/packages/neuron-wallet/src/services/anyone-can-pay.ts @@ -6,8 +6,12 @@ import Output from '../models/chain/output' import LiveCell from '../models/chain/live-cell' import Transaction from '../models/chain/transaction' import AssetAccountEntity from '../database/chain/entities/asset-account' -import { TargetLockError, TargetOutputNotFoundError } from '../exceptions' -import { AcpSendSameAccountError } from '../exceptions' +import { + LightClientNotSupportSendToACPError, + TargetLockError, + TargetOutputNotFoundError, + AcpSendSameAccountError +} from '../exceptions' import Script from '../models/chain/script' import OutPoint from '../models/chain/out-point' import LiveCellService from './live-cell-service' @@ -15,6 +19,8 @@ import WalletService from './wallets' import SystemScriptInfo from '../models/system-script-info' import CellsService from './cells' import { MIN_SUDT_CAPACITY } from '../utils/const' +import NetworksService from './networks' +import { NetworkType } from '../models/network' export default class AnyoneCanPayService { public static async generateAnyoneCanPayTx( @@ -90,6 +96,9 @@ export default class AnyoneCanPayService { ) if (new AssetAccountInfo().isAnyoneCanPayScript(lockScript)) { if (!targetOutputLiveCell) { + if (NetworksService.getInstance().getCurrent().type === NetworkType.Light) { + throw new LightClientNotSupportSendToACPError() + } throw new TargetOutputNotFoundError() } return Output.fromObject({ diff --git a/packages/neuron-wallet/src/services/ckb-runner.ts b/packages/neuron-wallet/src/services/ckb-runner.ts index f413129df3..cb1c40581c 100644 --- a/packages/neuron-wallet/src/services/ckb-runner.ts +++ b/packages/neuron-wallet/src/services/ckb-runner.ts @@ -1,12 +1,13 @@ import env from '../env' import path from 'path' import fs from 'fs' -import { ChildProcess, spawn } from 'child_process' +import { ChildProcess, StdioNull, StdioPipe, spawn } from 'child_process' import process from 'process' import logger from '../utils/logger' import SettingsService from './settings' import MigrateSubject from '../models/subjects/migrate-subject' import IndexerService from './indexer' +import { resetSyncTaskQueue } from '../block-sync-renderer' const platform = (): string => { switch (process.platform) { @@ -90,36 +91,33 @@ export const startCkbNode = async () => { logger.info('CKB:\tstarting node...') const options = ['run', '-C', SettingsService.getInstance().ckbDataPath, '--indexer'] + const stdio: (StdioNull | StdioPipe)[] = ['ignore', 'ignore', 'pipe'] if (app.isPackaged && process.env.CKB_NODE_ASSUME_VALID_TARGET) { options.push('--assume-valid-target', process.env.CKB_NODE_ASSUME_VALID_TARGET) + stdio[1] = 'pipe' } - ckb = spawn(ckbBinary(), options, { stdio: ['ignore', 'pipe', 'pipe'] }) + ckb = spawn(ckbBinary(), options, { stdio }) - ckb.stderr && - ckb.stderr.on('data', data => { - const dataString: string = data.toString() - logger.error('CKB:\trun fail:', dataString) - ckb = null - if (dataString.includes('CKB wants to migrate the data into new format')) { - MigrateSubject.next({ type: 'need-migrate' }) - } - }) - if (app.isPackaged && process.env.CKB_NODE_ASSUME_VALID_TARGET) { - ckb.stdout && - ckb.stdout.on('data', data => { - const dataString: string = data.toString() - if ( - dataString.includes( - `can't find assume valid target temporarily, hash: Byte32(${process.env.CKB_NODE_ASSUME_VALID_TARGET})` - ) - ) { - isLookingValidTarget = true - lastLogTime = Date.now() - } else if (lastLogTime && Date.now() - lastLogTime > 10000) { - isLookingValidTarget = false - } - }) - } + ckb.stderr?.on('data', data => { + const dataString: string = data.toString() + logger.error('CKB:\trun fail:', dataString) + if (dataString.includes('CKB wants to migrate the data into new format')) { + MigrateSubject.next({ type: 'need-migrate' }) + } + }) + ckb.stdout?.on('data', data => { + const dataString: string = data.toString() + if ( + dataString.includes( + `can't find assume valid target temporarily, hash: Byte32(${process.env.CKB_NODE_ASSUME_VALID_TARGET})` + ) + ) { + isLookingValidTarget = true + lastLogTime = Date.now() + } else if (lastLogTime && Date.now() - lastLogTime > 10000) { + isLookingValidTarget = false + } + }) ckb.on('error', error => { logger.error('CKB:\trun fail:', error) @@ -141,7 +139,7 @@ export const stopCkbNode = () => { if (ckb) { logger.info('CKB:\tkilling node') ckb.once('close', () => resolve()) - ckb.kill('SIGKILL') + ckb.kill() ckb = null } else { resolve() @@ -156,6 +154,7 @@ export const clearCkbNodeCache = async () => { await stopCkbNode() fs.rmSync(SettingsService.getInstance().ckbDataPath, { recursive: true, force: true }) await startCkbNode() + resetSyncTaskQueue.asyncPush(true) } export function migrateCkbData() { diff --git a/packages/neuron-wallet/src/services/hardware/ledger.ts b/packages/neuron-wallet/src/services/hardware/ledger.ts index b12b554a6d..a1b8de2793 100644 --- a/packages/neuron-wallet/src/services/hardware/ledger.ts +++ b/packages/neuron-wallet/src/services/hardware/ledger.ts @@ -12,6 +12,7 @@ import Address, { AddressType } from '../../models/keys/address' import HexUtils from '../../utils/hex' import logger from '../../utils/logger' import NetworksService from '../../services/networks' +import { generateRPC } from '../../utils/ckb-rpc' export default class Ledger extends Hardware { private ledgerCKB: LedgerCKB | null = null @@ -48,19 +49,13 @@ export default class Ledger extends Hardware { } } - public async signTransaction( - _: string, - tx: Transaction, - witnesses: string[], - path: string, - context?: RPC.RawTransaction[] - ) { - const { ckb } = NodeService.getInstance() - const rawTx = ckb.rpc.paramsFormatter.toRawTransaction(tx.toSDKRawTransaction()) + public async signTransaction (_: string, tx: Transaction, witnesses: string[], path: string, context?: RPC.RawTransaction[]) { + const rpc = generateRPC(NodeService.getInstance().nodeUrl) + const rawTx = rpc.paramsFormatter.toRawTransaction(tx.toSDKRawTransaction()) if (!context) { - const txs = await Promise.all(rawTx.inputs.map(i => ckb.rpc.getTransaction(i.previous_output!.tx_hash))) - context = txs.map(i => ckb.rpc.paramsFormatter.toRawTransaction(i.transaction)) + const txs = await Promise.all(rawTx.inputs.map(i => rpc.getTransaction(i.previous_output!.tx_hash))) + context = txs.map(i => rpc.paramsFormatter.toRawTransaction(i.transaction)) } const signature = await this.ledgerCKB!.signTransaction( diff --git a/packages/neuron-wallet/src/services/indexer.ts b/packages/neuron-wallet/src/services/indexer.ts index 0c50710634..f6e536bcc0 100644 --- a/packages/neuron-wallet/src/services/indexer.ts +++ b/packages/neuron-wallet/src/services/indexer.ts @@ -6,6 +6,7 @@ import { clean as cleanChain } from '../database/chain' import SettingsService from './settings' import startMonitor, { stopMonitor } from './monitor' import NodeService from './node' +import { resetSyncTaskQueue } from '../block-sync-renderer' export default class IndexerService { private constructor() {} @@ -19,19 +20,15 @@ export default class IndexerService { } static clearCache = async (clearIndexerFolder = false) => { - if (!NodeService.getInstance().isCkbNodeExternal) { - await stopMonitor('ckb') - } await cleanChain() - if (clearIndexerFolder) { + if (!NodeService.getInstance().isCkbNodeExternal && clearIndexerFolder) { + await stopMonitor('ckb') IndexerService.getInstance().clearData() await new SyncedBlockNumber().setNextBlock(BigInt(0)) + await startMonitor('ckb', true) } - - if (!NodeService.getInstance().isCkbNodeExternal) { - await startMonitor('ckb') - } + resetSyncTaskQueue.asyncPush(true) } static cleanOldIndexerData() { diff --git a/packages/neuron-wallet/src/services/light-runner.ts b/packages/neuron-wallet/src/services/light-runner.ts new file mode 100644 index 0000000000..2db887e093 --- /dev/null +++ b/packages/neuron-wallet/src/services/light-runner.ts @@ -0,0 +1,173 @@ +import path from 'path' +import fs from 'fs' +import { ChildProcess, spawn } from 'child_process' +import env from '../env' +import logger from '../utils/logger' +import SettingsService from '../services/settings' +import { clean } from '../database/chain' +import { resetSyncTaskQueue } from '../block-sync-renderer' + +const { app } = env + +export enum NetworkType { + Light, + Full +} + +abstract class NodeRunner { + protected constructor() {} + protected abstract networkType: NetworkType + protected runnerProcess: ChildProcess | undefined + protected static instance: NodeRunner | undefined + protected abstract binaryName: string + static getInstance(): NodeRunner { + throw new Error('should be called by concrete class') + } + + abstract start(): Promise + + protected get binary() { + const appPath = app.isPackaged + ? path.join(path.dirname(app.getAppPath()), '..', './bin') + : path.join(__dirname, '../../bin') + const binary = app.isPackaged + ? path.resolve(appPath, `./${this.binaryName}`) + : path.resolve(appPath, `./${this.platform()}`, `./${this.binaryName}`) + return this.platform() === 'win' ? binary + '.exe' : binary + } + + platform(): string { + switch (process.platform) { + case 'win32': + return 'win' + case 'linux': + return 'linux' + case 'darwin': + return 'mac' + default: + return '' + } + } + + async stop() { + return new Promise(resolve => { + if (this.runnerProcess) { + logger.info('Runner:\tkilling node') + this.runnerProcess.once('close', () => resolve()) + this.runnerProcess.kill() + this.runnerProcess = undefined + } else { + resolve() + } + }) + } +} + +export class CKBLightRunner extends NodeRunner { + protected networkType: NetworkType = NetworkType.Light + protected binaryName: string = 'ckb-light-client' + protected logStream?: fs.WriteStream + + static getInstance(): CKBLightRunner { + if (!CKBLightRunner.instance) { + CKBLightRunner.instance = new CKBLightRunner() + } + return CKBLightRunner.instance as CKBLightRunner + } + + private get templateConfigFile() { + const appPath = app.isPackaged + ? path.join(path.dirname(app.getAppPath()), '..', './light') + : path.join(__dirname, '../../light') + return path.resolve(appPath, './ckb_light.toml') + } + + private get configFile() { + return path.resolve(SettingsService.getInstance().testnetLightDataPath, './ckb_light.toml') + } + + initConfig() { + if (fs.existsSync(this.configFile)) { + logger.info(`CKB Light Runner:\tconfig has init, skip init...`) + return + } + const values = fs + .readFileSync(this.templateConfigFile) + .toString() + .split('\n') + let isStorePath = false + let isNetworkPath = false + const newValues = values.map(v => { + if (isStorePath) { + isStorePath = false + return `path = "${path.join(SettingsService.getInstance().testnetLightDataPath, './store')}"` + } + if (isNetworkPath) { + isNetworkPath = false + return `path = "${path.join(SettingsService.getInstance().testnetLightDataPath, './network')}"` + } + if (v === '[store]') { + isStorePath = true + } else if (v === '[network]') { + isNetworkPath = true + } + return v + }) + if (!fs.existsSync(SettingsService.getInstance().testnetLightDataPath)) { + fs.mkdirSync(SettingsService.getInstance().testnetLightDataPath, { recursive: true }) + } + fs.writeFileSync(this.configFile, newValues.join('\n')) + } + + async start() { + if (this.runnerProcess) { + logger.info(`CKB Light Runner:\tckb light is not closed, close it before start...`) + await this.stop() + } + this.initConfig() + + const options = ['run', '--config-file', this.configFile] + const runnerProcess = spawn(this.binary, options, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { RUST_LOG: 'info', ckb_light_client: 'info' } + }) + this.runnerProcess = runnerProcess + + if (!this.logStream) { + this.logStream = fs.createWriteStream(this.logPath) + } + + runnerProcess.stderr && + runnerProcess.stderr.on('data', data => { + const dataString: string = data.toString() + logger.error('CKB Light Runner:\trun fail:', dataString) + this.logStream?.write(data) + }) + + runnerProcess.stdout && + runnerProcess.stdout.on('data', data => { + this.logStream?.write(data) + }) + runnerProcess.on('error', error => { + logger.error('CKB Light Runner:\trun fail:', error) + this.runnerProcess = undefined + }) + + runnerProcess.on('close', () => { + logger.info('CKB Light Runner:\tprocess closed') + this.runnerProcess = undefined + }) + } + + get logPath() { + return path.join(logger.transports.file.getFile().path, '..', 'light_client_run.log') + } + + async clearNodeCache(): Promise { + await this.stop() + fs.rmSync(SettingsService.getInstance().testnetLightDataPath, { recursive: true, force: true }) + await clean(true) + await this.start() + resetSyncTaskQueue.asyncPush(true) + } +} diff --git a/packages/neuron-wallet/src/services/live-cell-service.ts b/packages/neuron-wallet/src/services/live-cell-service.ts index 146eb256c0..8c6694e935 100644 --- a/packages/neuron-wallet/src/services/live-cell-service.ts +++ b/packages/neuron-wallet/src/services/live-cell-service.ts @@ -1,7 +1,7 @@ import Script from '../models/chain/script' import LiveCell from '../models/chain/live-cell' import { queryIndexer } from '../block-sync-renderer/index' -import { LumosCellQuery, LumosCell } from '../block-sync-renderer/sync/indexer-connector' +import { LumosCell, LumosCellQuery } from '../block-sync-renderer/sync/connector' export default class LiveCellService { private static instance: LiveCellService diff --git a/packages/neuron-wallet/src/services/monitor/ckb-monitor.ts b/packages/neuron-wallet/src/services/monitor/ckb-monitor.ts index 395f0e246d..eba85c88e9 100644 --- a/packages/neuron-wallet/src/services/monitor/ckb-monitor.ts +++ b/packages/neuron-wallet/src/services/monitor/ckb-monitor.ts @@ -1,4 +1,5 @@ import { stopCkbNode } from '../../services/ckb-runner' +import { CKBLightRunner } from '../../services/light-runner' import NodeService from '../node' import BaseMonitor from './base' @@ -12,7 +13,10 @@ export default class CkbMonitor extends BaseMonitor { } async stop(): Promise { - await stopCkbNode() + await Promise.all([ + stopCkbNode(), + CKBLightRunner.getInstance().stop() + ]) } name: string = 'ckb' diff --git a/packages/neuron-wallet/src/services/multisig.ts b/packages/neuron-wallet/src/services/multisig.ts index 5aa129fadb..e218ac172d 100644 --- a/packages/neuron-wallet/src/services/multisig.ts +++ b/packages/neuron-wallet/src/services/multisig.ts @@ -9,6 +9,9 @@ import Transaction from '../models/chain/transaction' import { OutputStatus } from '../models/chain/output' import NetworksService from './networks' import Multisig from '../models/multisig' +import SyncProgress, { SyncAddressType } from '../database/chain/entities/sync-progress' +import { NetworkType } from '../models/network' +import WalletService from './wallets' const max64Int = '0x' + 'f'.repeat(16) export default class MultisigService { @@ -96,7 +99,7 @@ export default class MultisigService { while (currentMultisigConfigs.length) { const res = await rpcBatchRequest( network.remote, - currentMultisigConfigs.map(v => { + currentMultisigConfigs.map((v) => { const script = Multisig.getMultisigScript(v.blake160s, v.r, v.m, v.n) return { method: 'get_cells', @@ -155,7 +158,7 @@ export default class MultisigService { while (currentMultisigConfigs.length) { const res = await rpcBatchRequest( network.remote, - currentMultisigConfigs.map(v => { + currentMultisigConfigs.map((v) => { const script = Multisig.getMultisigScript(v.blake160s, v.r, v.m, v.n) return { method: 'get_transactions', @@ -201,13 +204,13 @@ export default class MultisigService { const network = await NetworksService.getInstance().getCurrent() const txList = await rpcBatchRequest( network.remote, - [...multisigOutputTxHashList].map(v => ({ + [...multisigOutputTxHashList].map((v) => ({ method: 'get_transaction', params: [v], })) ) const removeOutputTxHashList: string[] = [] - txList.forEach(v => { + txList.forEach((v) => { if (!v.error && v?.result?.transaction?.inputs?.length) { v?.result?.transaction?.inputs?.forEach((input: any) => { removeOutputTxHashList.push(input.previous_output.tx_hash + input.previous_output.index) @@ -228,7 +231,7 @@ export default class MultisigService { static async deleteRemovedMultisigOutput() { const multisigConfigs = await getConnection().getRepository(MultisigConfig).createQueryBuilder().getMany() - const multisigLockHashList = multisigConfigs.map(v => + const multisigLockHashList = multisigConfigs.map((v) => scriptToHash(Multisig.getMultisigScript(v.blake160s, v.r, v.m, v.n)) ) await getConnection() @@ -242,19 +245,50 @@ export default class MultisigService { MultisigOutputChangedSubject.getSubject().next('delete') } - static async syncMultisigOutput(lastestBlockNumber: string) { - try { - const multisigConfigs = await getConnection().getRepository(MultisigConfig).createQueryBuilder().getMany() - await MultisigService.saveLiveMultisigOutput() - await MultisigService.deleteDeadMultisigOutput(multisigConfigs) + static async saveMultisigSyncBlockNumber(multisigConfigs: MultisigConfig[], lastestBlockNumber: string) { + const network = await NetworksService.getInstance().getCurrent() + if (network.type === NetworkType.Light) { + const multisigScriptHashList = multisigConfigs.map((v) => + scriptToHash(Multisig.getMultisigScript(v.blake160s, v.r, v.m, v.n)) + ) + const syncBlockNumbers = await getConnection() + .getRepository(SyncProgress) + .createQueryBuilder() + .where({ hash: In(multisigScriptHashList) }) + .getMany() + const syncBlockNumbersMap: Record = syncBlockNumbers.reduce( + (pre, cur) => ({ ...pre, [cur.hash]: cur.blockStartNumber }), + {} + ) await getConnection() .getRepository(MultisigConfig) .save( - multisigConfigs.map(v => ({ + multisigConfigs.map((v) => { + const blockNumber = + syncBlockNumbersMap[scriptToHash(Multisig.getMultisigScript(v.blake160s, v.r, v.m, v.n))] + v.lastestBlockNumber = `0x${BigInt(blockNumber).toString(16)}` + return v + }) + ) + } else { + await getConnection() + .getRepository(MultisigConfig) + .save( + multisigConfigs.map((v) => ({ ...v, lastestBlockNumber, })) ) + } + } + + static async syncMultisigOutput(lastestBlockNumber: string) { + try { + const multisigConfigs = await getConnection().getRepository(MultisigConfig).createQueryBuilder().getMany() + await MultisigService.saveLiveMultisigOutput() + await MultisigService.deleteDeadMultisigOutput(multisigConfigs) + await MultisigService.saveMultisigSyncBlockNumber(multisigConfigs, lastestBlockNumber) + MultisigOutputChangedSubject.getSubject().next('update') } catch (error) { // ignore error, if lastestBlockNumber not update, it will try next time } @@ -262,7 +296,7 @@ export default class MultisigService { static async saveSentMultisigOutput(transaction: Transaction) { const inputsOutpointList = transaction.inputs.map( - input => `${input.previousOutput?.txHash}0x${(+input.previousOutput!.index)?.toString(16)}` + (input) => `${input.previousOutput?.txHash}0x${(+input.previousOutput!.index)?.toString(16)}` ) const multisigOutputs = transaction.outputs.map((output, idx) => { const entity = new MultisigOutput() @@ -290,4 +324,21 @@ export default class MultisigService { .execute() MultisigOutputChangedSubject.getSubject().next('update') } + + static async getMultisigConfigForLight() { + const currentWallet = WalletService.getInstance().getCurrent() + const multisigConfigs = await getConnection() + .getRepository(MultisigConfig) + .createQueryBuilder() + .where({ + walletId: currentWallet?.id + }) + .getMany() + return multisigConfigs.map((v) => ({ + walletId: v.walletId, + script: Multisig.getMultisigScript(v.blake160s, v.r, v.m, v.n), + addressType: SyncAddressType.Multisig, + scriptType: 'lock' as CKBRPC.ScriptType + })) + } } diff --git a/packages/neuron-wallet/src/services/networks.ts b/packages/neuron-wallet/src/services/networks.ts index 888b6d67cf..32717d7fe4 100644 --- a/packages/neuron-wallet/src/services/networks.ts +++ b/packages/neuron-wallet/src/services/networks.ts @@ -1,4 +1,3 @@ -import CKB from '@nervosnetwork/ckb-sdk-core' import { v4 as uuid } from 'uuid' import { DefaultNetworkUnremovable } from '../exceptions/network' @@ -6,9 +5,10 @@ import Store from '../models/store' import { Validate, Required } from '../utils/validators' import { UsedName, NetworkNotFound, InvalidFormat } from '../exceptions' -import { MAINNET_GENESIS_HASH, EMPTY_GENESIS_HASH, NetworkType, Network } from '../models/network' +import { MAINNET_GENESIS_HASH, EMPTY_GENESIS_HASH, NetworkType, Network, TESTNET_GENESIS_HASH } from '../models/network' import CommonUtils from '../utils/common' -import { BUNDLED_CKB_URL } from '../utils/const' +import { BUNDLED_CKB_URL, BUNDLED_LIGHT_CKB_URL, LIGHT_CLIENT_TESTNET } from '../utils/const' +import { generateRPC } from '../utils/ckb-rpc' const presetNetworks: { selected: string; networks: Network[] } = { selected: 'mainnet', @@ -24,9 +24,21 @@ const presetNetworks: { selected: string; networks: Network[] } = { ], } +const lightClientNetwork: Network[] = [ + { + id: 'light_client_testnet', + name: 'Light Client Testnet', + remote: BUNDLED_LIGHT_CKB_URL, + genesisHash: TESTNET_GENESIS_HASH, + type: NetworkType.Light, + chain: LIGHT_CLIENT_TESTNET + } +] + enum NetworksKey { List = 'networks', Current = 'selected', + AddedLightNetwork = 'AddedLightNetwork' } export default class NetworksService extends Store { @@ -44,6 +56,12 @@ export default class NetworksService extends Store { const currentNetwork = this.getCurrent() this.update(currentNetwork.id, {}) // Update to trigger chain/genesis hash refresh + const addLight = this.readSync(NetworksKey.AddedLightNetwork) + if (!addLight) { + const networks = this.readSync(NetworksKey.List) || presetNetworks.networks + this.updateAll([...networks, ...lightClientNetwork]) + this.writeSync(NetworksKey.AddedLightNetwork, true) + } } public getAll = () => { @@ -167,10 +185,10 @@ export default class NetworksService extends Store { // Refresh a network's genesis and chain info private async refreshChainInfo(network: Network): Promise { - const ckb = new CKB(network.remote) + const rpc = generateRPC(network.remote) - const genesisHash = await ckb.rpc.getBlockHash('0x0').catch(() => EMPTY_GENESIS_HASH) - const chain = await ckb.rpc + const genesisHash = await rpc.getGenesisBlockHash().catch(() => EMPTY_GENESIS_HASH) + const chain = await rpc .getBlockchainInfo() .then(info => info.chain) .catch(() => '') diff --git a/packages/neuron-wallet/src/services/node.ts b/packages/neuron-wallet/src/services/node.ts index 4c6dc3b3b3..b8e38df6d3 100644 --- a/packages/neuron-wallet/src/services/node.ts +++ b/packages/neuron-wallet/src/services/node.ts @@ -1,25 +1,25 @@ import fs from 'fs' import path from 'path' -import https from 'https' -import http from 'http' import { app as electronApp, dialog, shell } from 'electron' import { t } from 'i18next' -import CKB from '@nervosnetwork/ckb-sdk-core' import { interval, BehaviorSubject, merge } from 'rxjs' import { distinctUntilChanged, sampleTime, flatMap, delay, retry, debounceTime } from 'rxjs/operators' import env from '../env' import { ShouldBeTypeOf } from '../exceptions' import { ConnectionStatusSubject } from '../models/subjects/node' import { CurrentNetworkIDSubject } from '../models/subjects/networks' +import { NetworkType } from '../models/network' import NetworksService from '../services/networks' import RpcService from '../services/rpc-service' -import { startCkbNode } from '../services/ckb-runner' -import HexUtils from '../utils/hex' -import { BUNDLED_CKB_URL, START_WITHOUT_INDEXER } from '../utils/const' +import { startCkbNode, stopCkbNode } from '../services/ckb-runner' +import { BUNDLED_CKB_URL, START_WITHOUT_INDEXER, BUNDLED_LIGHT_CKB_URL } from '../utils/const' import logger from '../utils/logger' import redistCheck from '../utils/redist-check' import { rpcRequest } from '../utils/rpc-request' +import { generateRPC } from '../utils/ckb-rpc' +import HexUtils from '..//utils/hex' import startMonitor from './monitor' +import { CKBLightRunner } from './light-runner' class NodeService { private static instance: NodeService @@ -39,10 +39,9 @@ class NodeService { private _tipBlockNumber: string = '0' private startedBundledNode: boolean = false private _isCkbNodeExternal: boolean = false + #nodeUrl: string = '' - public ckb: CKB = new CKB('') - - constructor() { + private constructor() { this.start() this.syncConnectionStatus() CurrentNetworkIDSubject.subscribe(async ({ currentNetworkID }) => { @@ -53,6 +52,10 @@ class NodeService { }) } + get nodeUrl() { + return this.#nodeUrl + } + public get tipBlockNumber(): string { return this._tipBlockNumber } @@ -63,9 +66,9 @@ class NodeService { merge(periodSync, realtimeSync) .pipe(debounceTime(500)) .subscribe(connected => { - const isBundledNode = this.ckb.node.url === BUNDLED_CKB_URL + const isBundledNode = this.#nodeUrl === BUNDLED_CKB_URL ConnectionStatusSubject.next({ - url: this.ckb.node.url, + url: this.#nodeUrl, connected, isBundledNode, startedBundledNode: isBundledNode ? this.startedBundledNode : false, @@ -73,23 +76,16 @@ class NodeService { }) } - public setNetwork = (url: string) => { + private setNetwork = (url: string) => { if (typeof url !== 'string') { throw new ShouldBeTypeOf('URL', 'string') } if (!url.startsWith('http')) { throw new Error('Protocol of url should be specified') } - if (url.startsWith('https')) { - const httpsAgent = new https.Agent({ keepAlive: true }) - this.ckb.setNode({ url, httpsAgent }) - } else { - const httpAgent = new http.Agent({ keepAlive: true }) - this.ckb.setNode({ url, httpAgent }) - } + this.#nodeUrl = url this.tipNumberSubject.next('0') this.connectionStatusSubject.next(false) - return this.ckb } public start = () => { @@ -104,7 +100,7 @@ class NodeService { .pipe( delay(this.delayTime), flatMap(() => { - return this.ckb.rpc + return generateRPC(this.#nodeUrl) .getTipBlockNumber() .then(tipNumber => { this.connectionStatusSubject.next(true) @@ -147,18 +143,21 @@ class NodeService { } else { logger.info('CKB:\texternal RPC on default uri detected, skip starting bundled CKB node.') this._isCkbNodeExternal = true - await this.verifyNodeVersion() - await this.verifyStartWithIndexer() + const network = NetworksService.getInstance().getCurrent() + if (network.type !== NetworkType.Light) { + await this.verifyNodeVersion() + await this.verifyStartWithIndexer() + } } } public async isDefaultCKBNeedRestart() { - let network = NetworksService.getInstance().getCurrent() - if (network.remote !== BUNDLED_CKB_URL) { + const network = NetworksService.getInstance().getCurrent() + if (network.remote !== BUNDLED_CKB_URL && network.remote !== BUNDLED_LIGHT_CKB_URL) { return false } try { - await new RpcService(network.remote).getChain() + await new RpcService(network.remote).localNodeInfo() return false } catch (err) { return true @@ -167,7 +166,14 @@ class NodeService { public async startNode() { try { - await startCkbNode() + const network = NetworksService.getInstance().getCurrent() + if (network.type === NetworkType.Light) { + await stopCkbNode() + await CKBLightRunner.getInstance().start() + } else { + await CKBLightRunner.getInstance().stop() + await startCkbNode() + } this.startedBundledNode = true } catch (error) { this.startedBundledNode = false @@ -215,7 +221,7 @@ class NodeService { private async verifyNodeVersion() { const network = NetworksService.getInstance().getCurrent() - const localNodeInfo = await new RpcService(network.remote).getLocalNodeInfo() + const localNodeInfo = await new RpcService(network.remote).localNodeInfo() const internalNodeVersion = this.getInternalNodeVersion() const [internalMajor, internalMinor] = internalNodeVersion?.split('.') ?? [] const [externalMajor, externalMinor] = localNodeInfo.version?.split('.') ?? [] diff --git a/packages/neuron-wallet/src/services/rpc-service.ts b/packages/neuron-wallet/src/services/rpc-service.ts index 09d2e2b39d..b4769e59c6 100644 --- a/packages/neuron-wallet/src/services/rpc-service.ts +++ b/packages/neuron-wallet/src/services/rpc-service.ts @@ -1,67 +1,29 @@ -import CKB from '@nervosnetwork/ckb-sdk-core' -import { generateCKB } from '../services/sdk-core' - -import HexUtils from '../utils/hex' import CommonUtils from '../utils/common' import Block from '../models/chain/block' import BlockHeader from '../models/chain/block-header' import TransactionWithStatus from '../models/chain/transaction-with-status' -import OutPoint from '../models/chain/out-point' -import CellWithStatus from '../models/chain/cell-with-status' import logger from '../utils/logger' - +import { generateRPC } from '../utils/ckb-rpc' export default class RpcService { private retryTime: number private retryInterval: number - private ckb: CKB + private rpc: ReturnType constructor(url: string, retryTime: number = 3, retryInterval: number = 100) { this.retryTime = retryTime this.retryInterval = retryInterval - this.ckb = generateCKB(url) - } - - public async getRangeBlocks(blockNumbers: string[]): Promise { - const blocks: Block[] = await Promise.all( - blockNumbers.map(async num => { - return (await this.retryGetBlock(num))! - }) - ) - - return blocks - } - - public async getRangeBlockHeaders(blockNumbers: string[]): Promise { - const headers: BlockHeader[] = await Promise.all( - blockNumbers.map(async num => { - return (await this.retryGetBlockHeader(num))! - }) - ) - - return headers + this.rpc = generateRPC(url) } public async getTipBlockNumber(): Promise { - return this.ckb.rpc.getTipBlockNumber() + return this.rpc.getTipBlockNumber() } public async getTipHeader(): Promise { - const result = await this.ckb.rpc.getTipHeader() + const result = await this.rpc.getTipHeader() return BlockHeader.fromSDK(result) } - public async retryGetBlock(num: string): Promise { - return this.retry(async () => { - return await this.getBlockByNumber(num) - }) - } - - public async retryGetBlockHeader(num: string): Promise { - return this.retry(async () => { - return this.getBlockHeaderByNumber(num) - }) - } - /** * TODO: rejected tx should be handled * { @@ -70,7 +32,7 @@ export default class RpcService { * } */ public async getTransaction(hash: string): Promise { - const result = await this.ckb.rpc.getTransaction(hash) + const result = await this.rpc.getTransaction(hash) if (result?.transaction) { return TransactionWithStatus.fromSDK(result) } @@ -80,66 +42,39 @@ export default class RpcService { return undefined } - public async getLiveCell(outPoint: OutPoint, withData: boolean = false): Promise { - const result = await this.ckb.rpc.getLiveCell(outPoint.toSDK(), withData) - return CellWithStatus.fromSDK(result) - } - public async getHeader(hash: string): Promise { - const result = await this.ckb.rpc.getHeader(hash) - if (result) { - return BlockHeader.fromSDK(result) - } - return undefined - } - - public async getHeaderByNumber(num: string): Promise { - const result = await this.ckb.rpc.getHeaderByNumber(HexUtils.toHex(num)) + const result = await this.rpc.getHeader(hash) if (result) { return BlockHeader.fromSDK(result) } return undefined } - public async getBlockByNumber(num: string): Promise { - const block = await this.ckb.rpc.getBlockByNumber(HexUtils.toHex(num)) + public async getGenesisBlock(): Promise { + const block = await this.rpc.getGenesisBlock() if (block) { return Block.fromSDK(block) } return undefined } - public async getBlockHeaderByNumber(num: string): Promise { - const header = await this.ckb.rpc.getHeaderByNumber(HexUtils.toHex(num)) - if (header) { - return BlockHeader.fromSDK(header) - } - return undefined - } - public async genesisBlockHash(): Promise { return this.retry(async () => { - return this.ckb.rpc.getBlockHash('0x0') - }) - } - - public async getChain(): Promise { - const chain: string = await this.retry(async () => { - const i = await this.ckb.rpc.getBlockchainInfo() - return i.chain + return this.rpc.getGenesisBlockHash() }) - return chain } public async getSyncState(): Promise { const syncState = await this.retry(async () => { - return await this.ckb.rpc.syncState() + return await this.rpc.syncState() }) return syncState } - public async getLocalNodeInfo() { - return this.ckb.rpc.localNodeInfo() + public async localNodeInfo() { + return this.retry(async () => { + return this.rpc.localNodeInfo() + }) } private async retry(func: () => T): Promise { diff --git a/packages/neuron-wallet/src/services/settings.ts b/packages/neuron-wallet/src/services/settings.ts index 52c068db7b..b8f2d0fdc0 100644 --- a/packages/neuron-wallet/src/services/settings.ts +++ b/packages/neuron-wallet/src/services/settings.ts @@ -8,7 +8,11 @@ import path from 'path' const { app } = env export const locales = ['zh', 'zh-TW', 'en', 'en-US'] as const -export type Locale = (typeof locales)[number] +export type Locale = typeof locales[number] +const settingKeys = { + testnetLightDataPath: 'testnetLightDataPath', + ckbDataPath: 'ckbDataPath' +} export default class SettingsService extends Store { private static instance: SettingsService | null = null @@ -43,11 +47,19 @@ export default class SettingsService extends Store { } get ckbDataPath() { - return this.readSync('ckbDataPath') + return this.readSync(settingKeys.ckbDataPath) } set ckbDataPath(dataPath: string) { - this.writeSync('ckbDataPath', dataPath) + this.writeSync(settingKeys.ckbDataPath, dataPath) + } + + get testnetLightDataPath() { + return this.readSync(settingKeys.testnetLightDataPath) + } + + set testnetLightDataPath(dataPath: string) { + this.writeSync(settingKeys.testnetLightDataPath, dataPath) } constructor() { @@ -62,6 +74,9 @@ export default class SettingsService extends Store { if (!this.ckbDataPath) { this.ckbDataPath = path.resolve(app.getPath('userData'), 'chains/mainnet') } + if (!this.testnetLightDataPath) { + this.testnetLightDataPath = path.resolve(app.getPath('userData'), 'chains/light/testnet') + } } private onLocaleChanged = (lng: Locale) => { diff --git a/packages/neuron-wallet/src/services/sync-progress.ts b/packages/neuron-wallet/src/services/sync-progress.ts new file mode 100644 index 0000000000..0a856973a4 --- /dev/null +++ b/packages/neuron-wallet/src/services/sync-progress.ts @@ -0,0 +1,139 @@ +import { Equal, getConnection, In, Not } from 'typeorm' +import { scriptToHash } from '@nervosnetwork/ckb-sdk-utils' +import { HexString } from '@ckb-lumos/base' +import SyncProgress, { SyncAddressType } from '../database/chain/entities/sync-progress' +import WalletService from './wallets' + +export default class SyncProgressService { + static async resetSyncProgress( + params: { + script: CKBComponents.Script + scriptType: CKBRPC.ScriptType + walletId: string + addressType?: SyncAddressType + }[] + ) { + await getConnection() + .getRepository(SyncProgress) + .createQueryBuilder() + .insert() + .orIgnore() + .values(params.map(v => SyncProgress.fromObject(v))) + .execute() + } + + static async updateSyncProgressFlag(existWalletIds: string[]) { + await getConnection() + .createQueryBuilder() + .update(SyncProgress) + .set({ delete: true }) + .where({ walletId: Not(In(existWalletIds)) }) + .execute() + await getConnection() + .createQueryBuilder() + .update(SyncProgress) + .set({ delete: false }) + .where({ walletId: In(existWalletIds) }) + .execute() + } + + static async removeByHashesAndAddressType(addressType: SyncAddressType, existHashes?: string[]) { + await getConnection() + .createQueryBuilder() + .update(SyncProgress) + .set({ delete: true }) + .where({ ...(existHashes?.length ? { hash: Not(In(existHashes)) } : {}), addressType }) + .execute() + } + + static async updateBlockNumber(blake160s: string[], blockNumber: number) { + await getConnection() + .createQueryBuilder() + .update(SyncProgress) + .set({ blockStartNumber: blockNumber }) + .where({ blake160s: In(blake160s) }) + .execute() + } + + static async getSyncStatus(script: CKBComponents.Script) { + const scriptHash = scriptToHash(script) + const res = await getConnection() + .getRepository(SyncProgress) + .createQueryBuilder() + .where({ delete: false, hash: scriptHash }) + .getOne() + return res + } + + static async getAllSyncStatusToMap() { + const result: Map = new Map() + const syncProgresses = await getConnection() + .getRepository(SyncProgress) + .createQueryBuilder() + .where({ delete: false }) + .getMany() + syncProgresses.forEach(v => { + result.set(v.hash, v) + }) + return result + } + + static async updateSyncStatus( + hash: HexString, + { blockStartNumber, blockEndNumber, cursor }: { blockStartNumber: number; blockEndNumber: number; cursor?: string } + ) { + await getConnection().manager.update(SyncProgress, { hash }, { blockStartNumber, blockEndNumber, cursor }) + } + + static async getCurrentWalletMinBlockNumber() { + const currentWallet = WalletService.getInstance().getCurrent() + const item = await getConnection() + .getRepository(SyncProgress) + .createQueryBuilder() + .where({ delete: false, addressType: SyncAddressType.Default, ...(currentWallet ? { walletId: currentWallet.id } : {}) }) + .orderBy('blockEndNumber', 'ASC') + .getOne() + return item?.blockEndNumber || 0 + } + + static async getWalletMinBlockNumber() { + const items = await getConnection() + .getRepository(SyncProgress) + .createQueryBuilder() + .select('MIN(blockStartNumber) as blockStartNumber, walletId') + .where({ addressType: SyncAddressType.Default }) + .groupBy('walletId') + .getRawMany<{ blockStartNumber: number; walletId: string }>() + return items.reduce>((pre, cur) => ({ ...pre, [cur.walletId]: cur.blockStartNumber }), {}) + } + + static async getOtherTypeSyncProgress() { + const items = await getConnection() + .getRepository(SyncProgress) + .find({ + addressType: SyncAddressType.Multisig, + }) + return items.reduce>((pre, cur) => ({ ...pre, [cur.hash]: cur.blockStartNumber }), {}) + } + + static async getSyncProgressByHashes(hashes: string[]) { + return await getConnection() + .getRepository(SyncProgress) + .createQueryBuilder() + .where({ hash: In(hashes) }) + .getMany() + } + + static async clearCurrentWalletProgress() { + const currentWallet = WalletService.getInstance().getCurrent() + await getConnection() + .getRepository(SyncProgress) + .delete({ walletId: currentWallet?.id }) + await getConnection() + .createQueryBuilder() + .update(SyncProgress) + .set({ blockEndNumber: 0, cursor: undefined }) + .where({ walletId: Not(Equal(currentWallet?.id)) }) + .execute() + } +} diff --git a/packages/neuron-wallet/src/services/transaction-sender.ts b/packages/neuron-wallet/src/services/transaction-sender.ts index c328efe724..916d24fd07 100644 --- a/packages/neuron-wallet/src/services/transaction-sender.ts +++ b/packages/neuron-wallet/src/services/transaction-sender.ts @@ -1,28 +1,27 @@ -import WalletService, { Wallet } from '../services/wallets' +import ECPair from '@nervosnetwork/ckb-sdk-utils/lib/ecpair' +import signWitnesses from '@nervosnetwork/ckb-sdk-core/lib/signWitnesses' import NodeService from './node' import { scriptToAddress, serializeWitnessArgs, toUint64Le } from '@nervosnetwork/ckb-sdk-utils' import { TransactionPersistor, TransactionGenerator, TargetOutput } from './tx' import AddressService from './addresses' -import { Address } from '../models/address' +import WalletService, { Wallet } from '../services/wallets' +import RpcService from '../services/rpc-service' import { PathAndPrivateKey } from '../models/keys/key' -import { CellIsNotYetLive, TransactionIsNotCommittedYet } from '../exceptions/dao' +import { Address } from '../models/address' import FeeMode from '../models/fee-mode' import TransactionSize from '../models/transaction-size' import TransactionFee from '../models/transaction-fee' -import logger from '../utils/logger' import Keychain from '../models/keys/keychain' import Input from '../models/chain/input' import OutPoint from '../models/chain/out-point' import Output from '../models/chain/output' -import RpcService from '../services/rpc-service' import WitnessArgs from '../models/chain/witness-args' import Transaction from '../models/chain/transaction' -import BlockHeader from '../models/chain/block-header' import Script from '../models/chain/script' import Multisig from '../models/multisig' import Blake2b from '../models/blake2b' +import logger from '../utils/logger' import HexUtils from '../utils/hex' -import ECPair from '@nervosnetwork/ckb-sdk-utils/lib/ecpair' import SystemScriptInfo from '../models/system-script-info' import AddressParser from '../models/address-parser' import HardwareWalletService from './hardware' @@ -32,6 +31,8 @@ import { MultisigConfigNeedError, NoMatchAddressForSign, SignTransactionFailed, + CellIsNotYetLive, + TransactionIsNotCommittedYet } from '../exceptions' import AssetAccountInfo from '../models/asset-account-info' import MultisigConfigModel from '../models/multisig-config' @@ -40,6 +41,9 @@ import MultisigService from './multisig' import { getMultisigStatus } from '../utils/multisig' import { SignStatus } from '../models/offline-sign' import NetworksService from './networks' +import { generateRPC } from '../utils/ckb-rpc' +import CKB from '@nervosnetwork/ckb-sdk-core' +import CellsService from './cells' interface SignInfo { witnessArgs: WitnessArgs @@ -86,8 +90,8 @@ export default class TransactionSender { } public async broadcastTx(walletID: string = '', tx: Transaction) { - const { ckb } = NodeService.getInstance() - await ckb.rpc.sendTransaction(tx.toSDKRawTransaction(), 'passthrough') + const rpc = generateRPC(NodeService.getInstance().nodeUrl) + await rpc.sendTransaction(tx.toSDKRawTransaction(), 'passthrough') const txHash = tx.hash! await TransactionPersistor.saveSentTx(tx, txHash) @@ -107,7 +111,6 @@ export default class TransactionSender { ) { const wallet = this.walletService.get(walletID) const tx = Transaction.fromObject(transaction) - const { ckb } = NodeService.getInstance() const txHash: string = tx.computeHash() if (wallet.isHardware()) { let device = HardwareWalletService.getInstance().getCurrent() @@ -212,7 +215,7 @@ export default class TransactionSender { wit.lock = serializedMultisig + wit.lock!.slice(2) signed[0] = serializeWitnessArgs(wit.toSDK()) } else { - signed = ckb.signWitnesses(privateKey)({ + signed = signWitnesses(privateKey)({ transactionHash: txHash, witnesses: serializedWitnesses.map(wit => { if (typeof wit === 'string') { @@ -534,19 +537,15 @@ export default class TransactionSender { feeRate: string = '0' ): Promise => { const changeAddress: string = await this.getChangeAddress() - const url: string = NodeService.getInstance().ckb.node.url - const rpcService = new RpcService(url) - // for some reason with data won't work - const cellWithStatus = await rpcService.getLiveCell(new OutPoint(outPoint.txHash, outPoint.index), true) - const prevOutput = cellWithStatus.cell!.output - if (!cellWithStatus.isLive()) { + const nftCellOutput = await CellsService.getLiveCell(new OutPoint(outPoint.txHash, outPoint.index)) + if (!nftCellOutput) { throw new CellIsNotYetLive() } const tx = await TransactionGenerator.generateTransferNftTx( walletId, outPoint, - prevOutput, + nftCellOutput, receiveAddress, changeAddress, fee, @@ -589,10 +588,10 @@ export default class TransactionSender { // only for check wallet exists this.walletService.get(walletID) - const url: string = NodeService.getInstance().ckb.node.url + const url: string = NodeService.getInstance().nodeUrl const rpcService = new RpcService(url) - const cellWithStatus = await rpcService.getLiveCell(outPoint, false) - if (!cellWithStatus.isLive()) { + const depositeOutput = await CellsService.getLiveCell(outPoint) + if (!depositeOutput) { throw new CellIsNotYetLive() } const prevTx = await rpcService.getTransaction(outPoint.txHash) @@ -604,11 +603,10 @@ export default class TransactionSender { const wallet = WalletService.getInstance().get(walletID) const changeAddress = await wallet.getNextChangeAddress() - const prevOutput = cellWithStatus.cell!.output const tx: Transaction = await TransactionGenerator.startWithdrawFromDao( walletID, outPoint, - prevOutput, + depositeOutput, depositBlockHeader!.number, depositBlockHeader!.hash, changeAddress!.address, @@ -632,11 +630,11 @@ export default class TransactionSender { const feeRateInt = BigInt(feeRate) const mode = new FeeMode(feeRateInt) - const url: string = NodeService.getInstance().ckb.node.url + const url: string = NodeService.getInstance().nodeUrl const rpcService = new RpcService(url) - const cellStatus = await rpcService.getLiveCell(withdrawingOutPoint, true) - if (!cellStatus.isLive()) { + const withdrawOutput = await CellsService.getLiveCell(withdrawingOutPoint) + if (!withdrawOutput) { throw new CellIsNotYetLive() } const prevTx = (await rpcService.getTransaction(withdrawingOutPoint.txHash))! @@ -647,12 +645,23 @@ export default class TransactionSender { const secpCellDep = await SystemScriptInfo.getInstance().getSecpCellDep() const daoCellDep = await SystemScriptInfo.getInstance().getDaoCellDep() - const content = cellStatus.cell!.data!.content - const buf = Buffer.from(content.slice(2), 'hex') - const depositBlockNumber: bigint = buf.readBigUInt64LE() - const depositBlockHeader: BlockHeader = (await rpcService.getHeaderByNumber(depositBlockNumber.toString()))! + const content = withdrawOutput.daoData + if (!content) { + throw new Error(`Withdraw output cell is not a dao cell, ${withdrawOutput.outPoint?.txHash}`) + } + if (!withdrawOutput.depositOutPoint) { + throw new Error('DAO has not finish step first withdraw') + } + const depositeTx = await rpcService.getTransaction(withdrawOutput.depositOutPoint.txHash) + if (!depositeTx?.txStatus.blockHash) { + throw new Error(`Get deposite block hash failed with tx hash ${withdrawOutput.depositOutPoint.txHash}`) + } + const depositBlockHeader = await rpcService.getHeader(depositeTx.txStatus.blockHash) + if (!depositBlockHeader) { + throw new Error(`Get Header failed with blockHash ${depositeTx.txStatus.blockHash}`) + } const depositEpoch = this.parseEpoch(BigInt(depositBlockHeader.epoch)) - const depositCapacity: bigint = BigInt(cellStatus.cell!.output.capacity) + const depositCapacity: bigint = BigInt(withdrawOutput.capacity) const withdrawBlockHeader = (await rpcService.getHeader(prevTx.txStatus.blockHash!))! const withdrawEpoch = this.parseEpoch(BigInt(withdrawBlockHeader.epoch)) @@ -686,12 +695,11 @@ export default class TransactionSender { const outputs: Output[] = [output] - const previousOutput = cellStatus.cell!.output const input: Input = new Input( withdrawingOutPoint, minimalSince.toString(), - previousOutput.capacity, - previousOutput.lock + withdrawOutput.capacity, + withdrawOutput.lock ) const withdrawWitnessArgs: WitnessArgs = new WitnessArgs(WitnessArgs.EMPTY_LOCK, '0x0000000000000000') @@ -751,10 +759,10 @@ export default class TransactionSender { // only for check wallet exists this.walletService.get(walletID) - const url: string = NodeService.getInstance().ckb.node.url + const url: string = NodeService.getInstance().nodeUrl const rpcService = new RpcService(url) - const cellWithStatus = await rpcService.getLiveCell(outPoint, false) - if (!cellWithStatus.isLive()) { + const locktimeOutput = await CellsService.getLiveCell(outPoint) + if (!locktimeOutput) { throw new CellIsNotYetLive() } const prevTx = await rpcService.getTransaction(outPoint.txHash) @@ -766,10 +774,9 @@ export default class TransactionSender { const receivingAddressInfo = await wallet.getNextAddress() const receivingAddress = receivingAddressInfo!.address - const prevOutput = cellWithStatus.cell!.output const tx: Transaction = await TransactionGenerator.generateWithdrawMultiSignTx( outPoint, - prevOutput, + locktimeOutput, receivingAddress, fee, feeRate @@ -782,7 +789,7 @@ export default class TransactionSender { depositOutPoint: OutPoint, withdrawBlockHash: string ): Promise => { - const { ckb } = NodeService.getInstance() + const ckb = new CKB(NodeService.getInstance().nodeUrl) const result = await ckb.calculateDaoMaximumWithdraw(depositOutPoint.toSDK(), withdrawBlockHash) return BigInt(result) diff --git a/packages/neuron-wallet/src/services/tx/transaction-generator.ts b/packages/neuron-wallet/src/services/tx/transaction-generator.ts index 9d0f9cdabb..94fc6c4cc8 100644 --- a/packages/neuron-wallet/src/services/tx/transaction-generator.ts +++ b/packages/neuron-wallet/src/services/tx/transaction-generator.ts @@ -340,7 +340,7 @@ export class TransactionGenerator { } private static async getTipHeader(): Promise { - const rpcService = new RpcService(NodeService.getInstance().ckb.node.url) + const rpcService = new RpcService(NodeService.getInstance().nodeUrl) const tipHeader = await rpcService.getTipHeader() return tipHeader } diff --git a/packages/neuron-wallet/src/services/wallets.ts b/packages/neuron-wallet/src/services/wallets.ts index 09bb0fc47a..f822b61847 100644 --- a/packages/neuron-wallet/src/services/wallets.ts +++ b/packages/neuron-wallet/src/services/wallets.ts @@ -12,6 +12,9 @@ import AddressService from './addresses' import { DeviceInfo } from './hardware/common' import HdPublicKeyInfo from '../database/chain/entities/hd-public-key-info' import { getConnection, In, Not } from 'typeorm' +import NetworksService from './networks' +import { NetworkType } from '../models/network' +import { resetSyncTaskQueue } from '../block-sync-renderer' const fileService = FileService.getInstance() @@ -24,6 +27,7 @@ export interface WalletProperties { isHDWallet?: boolean device?: DeviceInfo keystore?: Keystore + startBlockNumberInLight?: string } export abstract class Wallet { @@ -32,9 +36,10 @@ export abstract class Wallet { public device?: DeviceInfo protected extendedKey: string = '' protected isHD: boolean + protected startBlockNumberInLight?: string constructor(props: WalletProperties) { - const { id, name, extendedKey, device, isHDWallet } = props + const { id, name, extendedKey, device, isHDWallet, startBlockNumberInLight } = props if (id === undefined) { throw new IsRequired('ID') @@ -52,6 +57,7 @@ export abstract class Wallet { this.extendedKey = extendedKey this.device = device this.isHD = isHDWallet ?? true + this.startBlockNumberInLight = startBlockNumberInLight } public toJSON = () => ({ @@ -139,6 +145,7 @@ export class FileKeystoreWallet extends Wallet { extendedKey: this.extendedKey, device: this.device, isHD: this.isHD, + startBlockNumberInLight: this.startBlockNumberInLight } } @@ -309,7 +316,7 @@ export default class WalletService { await getConnection() .getRepository(HdPublicKeyInfo) .delete({ - walletId: Not(In(allWallets.map(w => w.id))), + walletId: Not(In(allWallets.map((w) => w.id))) }) } @@ -322,7 +329,7 @@ export default class WalletService { throw new IsRequired('ID') } - const wallet = this.getAll().find(w => w.id === id) + const wallet = this.getAll().find((w) => w.id === id) if (!wallet) { throw new WalletNotFound(id) } @@ -353,7 +360,7 @@ export default class WalletService { throw new IsRequired('wallet property') } - const index = this.getAll().findIndex(wallet => wallet.name === props.name) + const index = this.getAll().findIndex((wallet) => wallet.name === props.name) if (index !== -1) { throw new UsedName('Wallet') @@ -380,7 +387,7 @@ export default class WalletService { const wallet = this.fromJSON(wallets[index]) - if (wallet.name !== props.name && wallets.findIndex(storeWallet => storeWallet.name === props.name) !== -1) { + if (wallet.name !== props.name && wallets.findIndex((storeWallet) => storeWallet.name === props.name) !== -1) { throw new UsedName('Wallet') } @@ -395,14 +402,14 @@ export default class WalletService { public delete = async (id: string) => { const wallets = this.getAll() - const walletJSON = wallets.find(w => w.id === id) + const walletJSON = wallets.find((w) => w.id === id) if (!walletJSON) { throw new WalletNotFound(id) } const wallet = this.fromJSON(walletJSON) - const newWallets = wallets.filter(w => w.id !== id) + const newWallets = wallets.filter((w) => w.id !== id) const current = this.getCurrent() const currentID = current ? current.id : '' @@ -442,6 +449,11 @@ export default class WalletService { } } + const network = NetworksService.getInstance().getCurrent() + if (network.type === NetworkType.Light) { + resetSyncTaskQueue.asyncPush(true) + } + this.listStore.writeSync(this.currentWalletKey, id) } @@ -463,7 +475,7 @@ export default class WalletService { } public clearAll = () => { - this.getAll().forEach(w => { + this.getAll().forEach((w) => { const wallet = this.fromJSON(w) if (!wallet.isHardware()) { wallet.deleteKeystore() diff --git a/packages/neuron-wallet/src/types/controller.d.ts b/packages/neuron-wallet/src/types/controller.d.ts index 20cdba3b2e..ea54bc7f0f 100644 --- a/packages/neuron-wallet/src/types/controller.d.ts +++ b/packages/neuron-wallet/src/types/controller.d.ts @@ -91,7 +91,7 @@ declare namespace Controller { name: string remote: string - type: 0 | 1 // 0 for the default type, 1 for the normal type + type: 0 | 1 | 2 // 0 for the default type, 1 for the normal type, 2 for the light client } interface Address { diff --git a/packages/neuron-wallet/src/types/rpc.d.ts b/packages/neuron-wallet/src/types/rpc.d.ts new file mode 100644 index 0000000000..0fe9dcad1e --- /dev/null +++ b/packages/neuron-wallet/src/types/rpc.d.ts @@ -0,0 +1,3 @@ +declare namespace CKBRPC { + type ScriptType = 'lock' | 'type' +} diff --git a/packages/neuron-wallet/src/utils/ckb-rpc.ts b/packages/neuron-wallet/src/utils/ckb-rpc.ts new file mode 100644 index 0000000000..57481b27ce --- /dev/null +++ b/packages/neuron-wallet/src/utils/ckb-rpc.ts @@ -0,0 +1,334 @@ +import { HexString, Script } from '@ckb-lumos/base' +import CKBRPC from '@nervosnetwork/ckb-sdk-rpc' +import Method from '@nervosnetwork/ckb-sdk-rpc/lib/method' +import resultFormatter from '@nervosnetwork/ckb-sdk-rpc/lib/resultFormatter' +import paramsFormatter from '@nervosnetwork/ckb-sdk-rpc/lib/paramsFormatter' +import Base from '@nervosnetwork/ckb-sdk-rpc/lib/Base' +import { MethodInBatchNotFoundException, PayloadInBatchException, IdNotMatchedInBatchException } from '@nervosnetwork/ckb-sdk-rpc/lib/exceptions' +import https from 'https' +import http from 'http' +import { request } from 'undici' +import { BUNDLED_LIGHT_CKB_URL, LIGHT_CLIENT_TESTNET } from './const' +import CommonUtils from './common' + +export interface LightScriptFilter { + script: CKBComponents.Script + blockNumber: CKBComponents.BlockNumber + scriptType: CKBRPC.ScriptType +} + +export type LightScriptSyncStatus = LightScriptFilter + +const lightRPCProperties: Record[0], 'name'>> = { + setScripts: { + method: 'set_scripts', + paramsFormatters: [ + (params: LightScriptFilter[]) => params.map(v => ({ + script: { + args: v.script.args, + code_hash: v.script.codeHash, + hash_type: v.script.hashType + }, + block_number: v.blockNumber, + script_type: v.scriptType + })) + ] + }, + getScripts: { + method: 'get_scripts', + paramsFormatters: [], + resultFormatters: (result: { script: Script, block_number: CKBComponents.BlockNumber, script_type: CKBRPC.ScriptType }[]) => result.map(v => ({ + script: { + args: v.script.args, + codeHash: v.script.code_hash, + hashType: v.script.hash_type + }, + blockNumber: v.block_number, + scriptType: v.script_type + })) + }, + getTransactions: { + method: 'get_transactions', + paramsFormatters: [ + (searchKey: { script: CKBComponents.Script, scriptType: CKBRPC.ScriptType, blockRange: [HexString, HexString] }) => ({ + script: { + args: searchKey.script.args, + code_hash: searchKey.script.codeHash, + hash_type: searchKey.script.hashType + }, + script_type: searchKey.scriptType, + filter: { block_range: searchKey.blockRange }, + group_by_transaction: true + }) + ], + resultFormatters: (result: { + last_cursor: HexString, + objects: { + block_number: HexString, + tx_index: HexString, + transaction: { hash: HexString } + }[] }) => ({ + lastCursor: result.last_cursor, + txs: result.objects.map(v => ({ + txHash: v.transaction.hash, + txIndex: v.tx_index, + blockNumber: v.block_number, + })) + }) + }, + getGenesisBlock: { + method: 'get_genesis_block', + paramsFormatters: [], + resultFormatters: resultFormatter.toBlock + }, + sendTransaction: { + method: 'send_transaction', + paramsFormatters: [paramsFormatter.toRawTransaction], + resultFormatters: resultFormatter.toHash, + }, + fetchTransaction: { + method: 'fetch_transaction', + paramsFormatters: [paramsFormatter.toHash], + resultFormatters: (result: { status: 'fetched' | 'fetching' | 'added' | 'not_found', data?: RPC.TransactionWithStatus }) => { + return { + status: result.status, + txWithStatus: result.status === 'fetched' && result.data ? resultFormatter.toTransactionWithStatus(result.data) : undefined + } + } + } +} + +export class FullCKBRPC extends CKBRPC { + getGenesisBlockHash = async () => { + return this.getBlockHash('0x0') + } + + getGenesisBlock = async () => { + return this.getBlockByNumber('0x0') + } +} + +export type FetchTransactionReturnType = { status: 'fetched' | 'fetching' | 'added' | 'not_found', txWithStatus?: CKBComponents.TransactionWithStatus } + +export class LightRPC extends Base { + setScripts: (params: LightScriptFilter[]) => Promise + getScripts: () => Promise + getTransactions: ( + searchKey: { script: CKBComponents.Script, scriptType: CKBRPC.ScriptType, blockRange: [HexString, HexString] }, + order: 'asc' | 'desc', + limit: HexString, + afterCursor: HexString + ) => Promise<{ lastCursor: HexString, txs: { txHash: HexString, txIndex: HexString, blockNumber: CKBComponents.BlockNumber }[]}> + + getTransactionInLight: Base['getTransaction'] + fetchTransaction: (hash: string) => Promise + + getGenesisBlock: () => Promise + exceptionMethods = ['getCurrentEpoch', 'getEpochByNumber', 'getBlockHash', 'getLiveCell'] + coverMethods = ['getTipBlockNumber', 'syncState', 'getBlockchainInfo', 'sendTransaction', 'getTransaction'] + + constructor(url: string) { + super() + this.setNode({ url }) + + Object.defineProperties(this, { + addMethod: { value: this.addMethod, enumerable: false, writable: false, configurable: false }, + setNode: { value: this.setNode, enumerable: false, writable: false, configurable: false }, + }) + + Object.keys(this.rpcProperties).forEach(name => { + this.addMethod({ name, ...this.rpcProperties[name] }) + }) + + this.setScripts = new Method(this.node, { name: 'setScripts', ...lightRPCProperties['setScripts'] }).call + this.getScripts = new Method(this.node, { name: 'getScripts', ...lightRPCProperties['getScripts'] }).call + this.getTransactions = new Method(this.node, { name: 'getTransactions', ...lightRPCProperties['getTransactions'] }).call + this.getGenesisBlock = new Method(this.node, { name: 'getGenesisBlock', ...lightRPCProperties['getGenesisBlock'] }).call + const sendTransactionMethod = new Method(this.node, { name: 'sendTransaction', ...lightRPCProperties['sendTransaction'] }) + this.sendTransaction = (tx: CKBComponents.RawTransaction) => sendTransactionMethod.call(tx) + this.getTransactionInLight = new Method(this.node, { name: 'getTransaction', ...this.rpcProperties['getTransaction'] }).call + this.fetchTransaction = new Method(this.node, { name: 'fetchTransaction', ...lightRPCProperties['fetchTransaction'] }).call + } + + getTransaction = async (hash: string): Promise => { + let tx = await this.getTransactionInLight(hash) + if (!tx?.transaction) { + tx = await CommonUtils.retry(3, 100, async () => { + const tmp = await this.fetchTransaction(hash) + if (!tmp.txWithStatus) { + throw new Error(`transaction ${hash} status: ${tmp.status}`) + } + return tmp.txWithStatus + }) + if (!tx) {throw new Error(`Fetch transaction tx failed, please try it later: ${hash}`)} + } + return tx + } + + getTipBlockNumber = async () => { + const headerTip = await this.getTipHeader() + return headerTip.number + } + + getGenesisBlockHash = async () => { + const genesisBlock = await this.getGenesisBlock() + return genesisBlock.header.hash + } + + syncState = async () => { + const headerTip = await this.getTipHeader() + return { + bestKnownBlockNumber: headerTip.number, + bestKnownBlockTimestamp: headerTip.timestamp, + } as CKBComponents.SyncState + } + + getBlockchainInfo = async () => { + await this.localNodeInfo() + return { + chain: LIGHT_CLIENT_TESTNET + } as CKBComponents.BlockchainInfo + } + + #node: CKBComponents.Node = { + url: '', + } + + get node() { + return this.#node + } + + #paramsFormatter = paramsFormatter + + get paramsFormatter() { + return this.#paramsFormatter + } + + #resultFormatter = resultFormatter + + get resultFormatter() { + return this.#resultFormatter + } + + public setNode(node: CKBComponents.Node): CKBComponents.Node { + Object.assign(this.node, node) + return this.node + } + + public addMethod = (options: CKBComponents.Method) => { + if (this.exceptionMethods.includes(options.name)) { + Object.defineProperty(this, options.name, { + value: () => { + throw new Error(`Unrealized ${options.name} in light node`) + }, + enumerable: true, + }) + } else if (!this.coverMethods.includes(options.name)) { + const method = new Method(this.node, options) + + Object.defineProperty(this, options.name, { + value: method.call, + enumerable: true, + }) + } + } + + public createBatchRequest = ( + params: [method: N, ...rest: P][] = [], + ) => { + const methods = Object.keys(this) + const { node, rpcProperties } = this + + const proxied: [method: N, ...rest: P][] = new Proxy([], { + set(...p) { + if (p[1] !== 'length') { + const name = p?.[2]?.[0] + if (methods.indexOf(name) === -1) { + throw new MethodInBatchNotFoundException(name) + } + } + return Reflect.set(...p) + }, + }) + + Object.defineProperties(proxied, { + add: { + value(...args: P) { + this.push(args) + return this + }, + }, + remove: { + value(i: number) { + this.splice(i, 1) + return this + }, + }, + exec: { + async value() { + const payload = proxied.map(([f, ...p], i) => { + try { + const method = new Method(node, { ...({ ...rpcProperties, ...lightRPCProperties }[f]), name: f }) + return method.getPayload(...p) + } catch (err) { + throw new PayloadInBatchException(i, err.message) + } + }) + + const res = await request(node.url, { + method: 'POST', + body: JSON.stringify(payload), + headers: { 'content-type': 'application/json' }, + }) + const batchRes = await res.body.json() + + return batchRes.map((res: any, i: number) => { + if (res.id !== payload[i].id) { + return new IdNotMatchedInBatchException(i, payload[i].id, res.id) + } + return ({ ...rpcProperties, ...lightRPCProperties })[proxied[i][0]].resultFormatters?.(res.result) ?? res.result + }) + }, + }, + }) + params.forEach(p => proxied.push(p)) + + return proxied as typeof proxied & { + add: (n: N, ...p: P) => typeof proxied + remove: (index: number) => typeof proxied + exec: () => Promise + } + } +} + +let httpsAgent: https.Agent +let httpAgent: http.Agent + +const getHttpsAgent = () => { + if (!httpsAgent) { + httpsAgent = new https.Agent({ keepAlive: true }) + } + return httpsAgent +} + +const getHttpAgent = () => { + if (!httpAgent) { + httpAgent = new http.Agent({ keepAlive: true }) + } + return httpAgent +} + +export const generateRPC = (url: string) => { + let rpc: LightRPC | FullCKBRPC + if (url === BUNDLED_LIGHT_CKB_URL) { + rpc = new LightRPC(url) + } else { + rpc = new FullCKBRPC(url) + } + if (url?.startsWith('https')) { + rpc.setNode({ url, httpsAgent: getHttpsAgent() }) + } else { + rpc.setNode({ url, httpAgent: getHttpAgent() }) + } + return rpc +} diff --git a/packages/neuron-wallet/src/utils/common.ts b/packages/neuron-wallet/src/utils/common.ts index cdf789e01f..e67be1b0a4 100644 --- a/packages/neuron-wallet/src/utils/common.ts +++ b/packages/neuron-wallet/src/utils/common.ts @@ -5,18 +5,18 @@ export default class CommonUtils { return new Promise(resolve => setTimeout(resolve, ms)) } - public static async retry(times: number, interval: number, callback: () => T): Promise { + public static async retry(times: number, interval: number, callback: () => T): Promise> { let retryTime = 0 while (++retryTime < times) { try { - return await callback() + return await (callback() as Awaited) } catch (err) { logger.warn(`function call error: ${err}, retry ${retryTime + 1} ...`) await CommonUtils.sleep(interval) } } - return await callback() + return await (callback() as Awaited) } public static timeout(time: number, promise: Promise, value: T): Promise { diff --git a/packages/neuron-wallet/src/utils/const.ts b/packages/neuron-wallet/src/utils/const.ts index 1b84c7ef41..562394e4b1 100644 --- a/packages/neuron-wallet/src/utils/const.ts +++ b/packages/neuron-wallet/src/utils/const.ts @@ -1,6 +1,8 @@ export const MIN_PASSWORD_LENGTH = 8 export const MAX_PASSWORD_LENGTH = 50 export const BUNDLED_CKB_URL = 'http://127.0.0.1:8114' +export const BUNDLED_LIGHT_CKB_URL = 'http://127.0.0.1:9000' +export const LIGHT_CLIENT_TESTNET = 'light_client_testnet' export const SETTINGS_WINDOW_TITLE = process.platform === 'darwin' ? 'settings.title.mac' : 'settings.title.normal' export const SETTINGS_WINDOW_WIDTH = 900 export const DEFAULT_UDT_SYMBOL = 'Unknown' diff --git a/packages/neuron-wallet/src/utils/rpc-request.ts b/packages/neuron-wallet/src/utils/rpc-request.ts index 2793c7377e..2ed650a0e5 100644 --- a/packages/neuron-wallet/src/utils/rpc-request.ts +++ b/packages/neuron-wallet/src/utils/rpc-request.ts @@ -1,6 +1,6 @@ import { request } from 'undici' -export const rpcRequest = async ( +export const rpcRequest = async ( url: string, options: { method: string @@ -22,7 +22,8 @@ export const rpcRequest = async ( if (res.statusCode !== 200) { throw new Error(`indexer request failed with HTTP code ${res.statusCode}`) } - return res.body.json() + const body = await res.body.json() + return body?.result as T } export const rpcBatchRequest = async ( diff --git a/packages/neuron-wallet/tests/block-sync-renderer/indexer-connector.test.ts b/packages/neuron-wallet/tests/block-sync-renderer/indexer-connector.test.ts index a73f7c392f..c868b8ae34 100644 --- a/packages/neuron-wallet/tests/block-sync-renderer/indexer-connector.test.ts +++ b/packages/neuron-wallet/tests/block-sync-renderer/indexer-connector.test.ts @@ -3,7 +3,8 @@ import { when } from 'jest-when' import { AddressType } from '../../src/models/keys/address' import { Address, AddressVersion } from '../../src/models/address' import SystemScriptInfo from '../../src/models/system-script-info' -import IndexerConnector, { LumosCellQuery } from '../../src/block-sync-renderer/sync/indexer-connector' +import IndexerConnector from '../../src/block-sync-renderer/sync/indexer-connector' +import type { LumosCellQuery } from '../../src/block-sync-renderer/sync/connector' import { flushPromises } from '../test-utils' import { ScriptHashType } from '../../src/models/chain/script' @@ -236,7 +237,7 @@ describe('unit tests for IndexerConnector', () => { }) it('emits new transactions in batch by the next unprocessed block number', () => { expect(txObserver).toHaveBeenCalledTimes(1) - expect(txObserver).toHaveBeenCalledWith([fakeTx1]) + expect(txObserver).toHaveBeenCalledWith({ txHashes: [fakeTx1.transaction.hash], params: fakeTx1.transaction.blockNumber }) }) }) describe('when loaded block number is not in order', () => { @@ -252,7 +253,7 @@ describe('unit tests for IndexerConnector', () => { }) it('emits new transactions in batch by the next unprocessed block number', () => { expect(txObserver).toHaveBeenCalledTimes(1) - expect(txObserver).toHaveBeenCalledWith([fakeTx1]) + expect(txObserver).toHaveBeenCalledWith({ txHashes: [fakeTx1.transaction.hash], params: fakeTx1.transaction.blockNumber }) }) }) describe('#notifyCurrentBlockNumberProcessed', () => { @@ -326,8 +327,7 @@ describe('unit tests for IndexerConnector', () => { let txObserver: any beforeEach(async () => { stubbedUpsertTxHashesFn.mockReturnValueOnce([fakeTx3.transaction.hash]) - stubbedNextUnprocessedTxsGroupedByBlockNumberFn.mockResolvedValue([fakeTxHashCache3]) - when(stubbedGetTransactionFn).calledWith(fakeTxHashCache3.txHash).mockResolvedValueOnce(undefined) + stubbedNextUnprocessedTxsGroupedByBlockNumberFn.mockRejectedValue('exception') txObserver = jest.fn() transactionsSubject.subscribe((transactions: any) => txObserver(transactions)) @@ -336,7 +336,7 @@ describe('unit tests for IndexerConnector', () => { }) it('throws error', async () => { expect(stubbedLoggerErrorFn).toHaveBeenCalledWith( - 'Error in processing next block number queue: Error: failed to fetch transaction for hash hash3' + 'Error in processing next block number queue: Error: exception' ) }) }) diff --git a/packages/neuron-wallet/tests/block-sync-renderer/light-connector.test.ts b/packages/neuron-wallet/tests/block-sync-renderer/light-connector.test.ts new file mode 100644 index 0000000000..779a7f366e --- /dev/null +++ b/packages/neuron-wallet/tests/block-sync-renderer/light-connector.test.ts @@ -0,0 +1,462 @@ +import { scriptToHash } from '@nervosnetwork/ckb-sdk-utils' +import LightConnector from '../../src/block-sync-renderer/sync/light-connector' +import SyncProgress from '../../src/database/chain/entities/sync-progress' +import HexUtils from '../../src/utils/hex' +import AddressMeta from '../../src/database/address/meta' + +const getSyncStatusMock = jest.fn() +const getCurrentWalletMinBlockNumberMock = jest.fn() +const getAllSyncStatusToMapMock = jest.fn() +const resetSyncProgressMock = jest.fn() +const updateSyncStatusMock = jest.fn() +const updateSyncProgressFlagMock = jest.fn() +const getWalletMinBlockNumberMock = jest.fn() +const removeByHashesAndAddressType = jest.fn() +const getOtherTypeSyncProgressMock = jest.fn() + +const setScriptsMock = jest.fn() +const getScriptsMock = jest.fn() +const getTipHeaderMock = jest.fn() +const getTransactionsMock = jest.fn() +const createBatchRequestMock = jest.fn() + +const schedulerWaitMock = jest.fn() +const getMultisigConfigForLightMock = jest.fn() +const walletGetCurrentMock = jest.fn() +const walletGetAllMock = jest.fn() + +function mockReset() { + getSyncStatusMock.mockReset() + getCurrentWalletMinBlockNumberMock.mockReset() + getAllSyncStatusToMapMock.mockReset() + resetSyncProgressMock.mockReset() + updateSyncStatusMock.mockReset() + getWalletMinBlockNumberMock.mockReset() + getOtherTypeSyncProgressMock.mockReset() + + setScriptsMock.mockReset() + getScriptsMock.mockReset() + getTipHeaderMock.mockReset() + getTransactionsMock.mockReset() + createBatchRequestMock.mockReset() + + schedulerWaitMock.mockReset() + getMultisigConfigForLightMock.mockReset() + removeByHashesAndAddressType.mockReset() + walletGetCurrentMock.mockReset() + walletGetAllMock.mockReset() +} + +jest.mock('../../src/services/sync-progress', () => { + return class { + static getSyncStatus: any = () => getSyncStatusMock() + static getCurrentWalletMinBlockNumber: any = () => getCurrentWalletMinBlockNumberMock() + static getAllSyncStatusToMap: any = () => getAllSyncStatusToMapMock() + static resetSyncProgress: any = (arg: any) => resetSyncProgressMock(arg) + static updateSyncStatus: any = (hash: string, update: any) => updateSyncStatusMock(hash, update) + static updateSyncProgressFlag: any = (walletIds: string[]) => updateSyncProgressFlagMock(walletIds) + static getWalletMinBlockNumber: any = () => getWalletMinBlockNumberMock() + static removeByHashesAndAddressType: any = (type: number, scripts: CKBComponents.Script[]) => removeByHashesAndAddressType(type, scripts) + static getOtherTypeSyncProgress: any = () => getOtherTypeSyncProgressMock() + } +}) + +jest.mock('../../src/utils/ckb-rpc', () => ({ + LightRPC: function() { + return { + setScripts: setScriptsMock, + getScripts: getScriptsMock, + getTipHeader: getTipHeaderMock, + getTransactions: getTransactionsMock, + createBatchRequest: () => ({ exec: createBatchRequestMock }), + } + } +})) + +jest.mock('../../src/services/multisig', () => ({ + getMultisigConfigForLight: () => getMultisigConfigForLightMock() +})) + +jest.mock('../../src/services/wallets', () => ({ + getInstance() { + return { + getCurrent: walletGetCurrentMock, + getAll: walletGetAllMock, + } + } +})) + +jest.mock('timers/promises', () => ({ + scheduler: { + wait: (delay: number) => schedulerWaitMock(delay), + } +})) + +const script: CKBComponents.Script = { + args: '0x403f0d4e833b2a8d372772a63facaa310dfeef92', + codeHash: '0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8', + hashType: 'type' +} +const scriptHash = scriptToHash(script) +const address = 'ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsq2q8ux5aqem92xnwfmj5cl6e233phlwlysqhjx5w' + +describe('test light connector', () => { + beforeEach(() => { + walletGetAllMock.mockReturnValue([]) + createBatchRequestMock.mockResolvedValue([]) + getMultisigConfigForLightMock.mockResolvedValue([]) + getOtherTypeSyncProgressMock.mockResolvedValue({}) + }) + afterEach(() => { + mockReset() + }) + describe('test synchronize', () => { + it('syncQueue is not idle', async () => { + const connector = new LightConnector([], '') + // @ts-ignore: private-method + connector.syncQueue.push({}) + // @ts-ignore: private-method + await connector.synchronize() + expect(getScriptsMock).not.toBeCalled() + }) + it('syncQueue is idle', async () => { + getScriptsMock.mockResolvedValue([]) + getAllSyncStatusToMapMock.mockResolvedValue(new Map()) + const connector = new LightConnector([], '') + // @ts-ignore: private-method + connector.subscribeSync = jest.fn() + // @ts-ignore: private-method + await connector.synchronize() + expect(getScriptsMock).toBeCalled() + expect(getAllSyncStatusToMapMock).toBeCalled() + }) + it('some script sync cursor is not empty', async () => { + getScriptsMock.mockResolvedValue([]) + const syncProgress = SyncProgress.fromObject({ + script, + scriptType: 'lock', + walletId: 'walletId1' + }) + syncProgress.blockStartNumber = 0 + syncProgress.blockEndNumber = 1 + syncProgress.cursor = '0x1' + getAllSyncStatusToMapMock.mockResolvedValue(new Map([[scriptHash, syncProgress]])) + const connector = new LightConnector([], '') + // @ts-ignore: private-method + connector.subscribeSync = jest.fn() + // @ts-ignore: private-method + await connector.synchronize() + // @ts-ignore: private-method + const queue = connector.syncQueue.workersList() + expect(queue[0].data).toStrictEqual({ + script: { + codeHash: syncProgress.codeHash, + hashType: syncProgress.hashType, + args: syncProgress.args + }, + blockRange: [HexUtils.toHex(syncProgress.blockStartNumber), HexUtils.toHex(syncProgress.blockEndNumber)], + scriptType: syncProgress.scriptType, + cursor: syncProgress.cursor + }) + }) + it('some script sync cursor is not empty but is in sync queue', async () => { + getScriptsMock.mockResolvedValue([]) + const syncProgress = SyncProgress.fromObject({ + script, + scriptType: 'lock', + walletId: 'walletId1' + }) + syncProgress.blockStartNumber = 0 + syncProgress.blockEndNumber = 1 + syncProgress.cursor = '0x1' + getAllSyncStatusToMapMock.mockResolvedValue(new Map([[scriptHash, syncProgress]])) + const connector = new LightConnector([], '') + // @ts-ignore: private-method + connector.syncInQueue.set(scriptHash, {}) + // @ts-ignore: private-method + connector.subscribeSync = jest.fn() + // @ts-ignore: private-method + connector.syncQueue.pause() + // @ts-ignore: private-method + await connector.synchronize() + // @ts-ignore: private-method + expect(connector.syncQueue.length()).toBe(0) + }) + it('some script sync to new block', async () => { + getScriptsMock.mockResolvedValue([{ + script, + scriptType: 'lock', + blockNumber: '0xaa' + }]) + const syncProgress = SyncProgress.fromObject({ + script, + scriptType: 'lock', + walletId: 'walletId1' + }) + syncProgress.blockStartNumber = 0 + syncProgress.blockEndNumber = 1 + getAllSyncStatusToMapMock.mockResolvedValue(new Map([[scriptHash, syncProgress]])) + const connector = new LightConnector([], '') + // @ts-ignore: private-method + connector.subscribeSync = jest.fn() + // @ts-ignore: private-method + await connector.synchronize() + // @ts-ignore: private-method + const queue = connector.syncQueue.workersList() + expect(queue[0].data).toStrictEqual({ + script: { + codeHash: syncProgress.codeHash, + hashType: syncProgress.hashType, + args: syncProgress.args + }, + blockRange: [HexUtils.toHex(syncProgress.blockEndNumber), HexUtils.toHex('0xaa')], + scriptType: syncProgress.scriptType, + cursor: syncProgress.cursor + }) + }), + it('some script sync to new block but is in sync queue', async () => { + getScriptsMock.mockResolvedValue([{ + script, + scriptType: 'lock', + blockNumber: '0xaa' + }]) + const syncProgress = SyncProgress.fromObject({ + script, + scriptType: 'lock', + walletId: 'walletId1' + }) + syncProgress.blockStartNumber = 0 + syncProgress.blockEndNumber = 1 + getAllSyncStatusToMapMock.mockResolvedValue(new Map([[scriptHash, syncProgress]])) + const connector = new LightConnector([], '') + // @ts-ignore: private-method + connector.syncInQueue.set(scriptHash, {}) + // @ts-ignore: private-method + connector.subscribeSync = jest.fn() + // @ts-ignore: private-method + connector.syncQueue.pause() + // @ts-ignore: private-method + await connector.synchronize() + // @ts-ignore: private-method + expect(connector.syncQueue.length()).toBe(0) + }) + }) + + describe('test subscribeSync', () => { + it('run success', async () => { + getCurrentWalletMinBlockNumberMock.mockResolvedValue(100) + getTipHeaderMock.mockResolvedValue({ number: '0xaa' }) + const connector = new LightConnector([], '') + // @ts-ignore: private-method + connector.blockTipsSubject = { next: jest.fn() } + // @ts-ignore: private-method + await connector.subscribeSync() + // @ts-ignore: private-method + expect(connector.blockTipsSubject.next).toBeCalledWith({ + cacheTipNumber: 100, + indexerTipNumber: 170 + }) + }) + }) + + describe('test initSyncProgress', () => { + it('there is not exist addressmata', async () => { + const connect = new LightConnector([], '') + //@ts-ignore + await connect.initSyncProgress() + expect(getScriptsMock).toBeCalledTimes(0) + }) + it('append multisig script', async () => { + getScriptsMock.mockResolvedValue([]) + const connect = new LightConnector([], '') + //@ts-ignore + await connect.initSyncProgress([{ walletId: 'walletId', script, addressType: 1, scriptType: 'lock' }]) + expect(getScriptsMock).toBeCalledTimes(1) + expect(setScriptsMock).toBeCalledWith([ + { script, scriptType: 'lock', walletId: 'walletId', blockNumber: '0x0', addressType: 1, }, + ]) + }) + it('there is not exist sync scripts with light client', async () => { + getScriptsMock.mockResolvedValue([{ script, blockNumber: '0xaa' }]) + const addressMeta = AddressMeta.fromObject({ walletId: 'walletId', address, path: '', addressIndex: 10, addressType: 0, blake160: script.args }) + const connect = new LightConnector([addressMeta], '') + //@ts-ignore + await connect.initSyncProgress() + expect(setScriptsMock).toBeCalledWith([ + { script: addressMeta.generateDefaultLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId', blockNumber: '0xaa' }, + { script: addressMeta.generateACPLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId', blockNumber: '0x0' }, + { script: addressMeta.generateLegacyACPLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId', blockNumber: '0x0' }, + ]) + expect(resetSyncProgressMock).toBeCalledWith([ + { script: addressMeta.generateDefaultLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId' }, + { script: addressMeta.generateACPLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId' }, + { script: addressMeta.generateLegacyACPLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId' }, + ]) + expect(updateSyncProgressFlagMock).toBeCalledWith(['walletId']) + }) + it('set new script with the synced min block number', async () => { + getScriptsMock.mockResolvedValue([]) + const addressMeta = AddressMeta.fromObject({ walletId: 'walletId', address, path: '', addressIndex: 10, addressType: 0, blake160: script.args }) + getWalletMinBlockNumberMock.mockResolvedValue({ 'walletId': 170}) + const connect = new LightConnector([addressMeta], '') + //@ts-ignore + await connect.initSyncProgress() + expect(setScriptsMock).toBeCalledWith([ + { script: addressMeta.generateDefaultLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId', blockNumber: '0xaa' }, + { script: addressMeta.generateACPLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId', blockNumber: '0xaa' }, + { script: addressMeta.generateLegacyACPLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId', blockNumber: '0xaa' }, + ]) + }) + it('set new script with start block number in wallet', async () => { + getScriptsMock.mockResolvedValue([]) + const addressMeta = AddressMeta.fromObject({ walletId: 'walletId', address, path: '', addressIndex: 10, addressType: 0, blake160: script.args }) + getWalletMinBlockNumberMock.mockResolvedValue({}) + walletGetAllMock.mockReturnValue([{ id: 'walletId', startBlockNumberInLight: '0xaa' }]) + const connect = new LightConnector([addressMeta], '') + //@ts-ignore + await connect.initSyncProgress() + expect(setScriptsMock).toBeCalledWith([ + { script: addressMeta.generateDefaultLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId', blockNumber: '0xaa' }, + { script: addressMeta.generateACPLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId', blockNumber: '0xaa' }, + { script: addressMeta.generateLegacyACPLockScript().toSDK(), scriptType: 'lock', walletId: 'walletId', blockNumber: '0xaa' }, + ]) + }) + }) + + describe('test initSync', () => { + it('pollingIndexer is false', async () => { + const connect = new LightConnector([], '') + //@ts-ignore + connect.synchronize = jest.fn() + //@ts-ignore + await connect.initSync() + expect(schedulerWaitMock).toBeCalledTimes(0) + }) + it('pollingIndexer is true', async () => { + const connect = new LightConnector([], '') + schedulerWaitMock.mockImplementation(() => { + connect.stop() + }) + //@ts-ignore + connect.pollingIndexer = true + //@ts-ignore + connect.synchronize = jest.fn() + //@ts-ignore + await connect.initSync() + expect(schedulerWaitMock).toBeCalledWith(5000) + }) + }) + + describe('test syncNextWithScript', () => { + it('no syncprogress in db', async () => { + getSyncStatusMock.mockResolvedValue(undefined) + const connect = new LightConnector([], '') + //@ts-ignore + await connect.syncNextWithScript({ script, scriptType: 'lock' }) + expect(getTransactionsMock).toBeCalledTimes(0) + }) + it('there is no tx in blockRange ', async () => { + const syncProgress = SyncProgress.fromObject({ script, scriptType: 'lock', walletId: 'walletId' }) + getSyncStatusMock.mockResolvedValue(syncProgress) + getTransactionsMock.mockResolvedValue({ txs: [], lastCursor: '0x' }) + const connect = new LightConnector([], '') + //@ts-ignore + await connect.syncNextWithScript({ script, scriptType: 'lock', blockRange: ['0xaa', '0xbb'] }) + expect(getTransactionsMock).toBeCalledWith({ script, blockRange: ['0xaa', '0xbb'], scriptType: 'lock' }, 'asc', '0x64', undefined) + expect(updateSyncStatusMock).toBeCalledWith(scriptHash, { blockStartNumber: 187, blockEndNumber: 187, cursor: undefined }) + }) + it('there are some txs in blockRange but no more', async () => { + const syncProgress = SyncProgress.fromObject({ script, scriptType: 'lock', walletId: 'walletId' }) + getSyncStatusMock.mockResolvedValue(syncProgress) + getTransactionsMock.mockResolvedValue({ txs: [{ txHash: '0xhash1' }], lastCursor: '0x' }) + const connect = new LightConnector([], '') + //@ts-ignore + connect.transactionsSubject = { next: jest.fn() } + //@ts-ignore + await connect.syncNextWithScript({ script, scriptType: 'lock', blockRange: ['0xaa', '0xbb'] }) + expect(connect.transactionsSubject.next).toBeCalledWith({ txHashes: ['0xhash1'], params: scriptHash }) + //@ts-ignore + expect(connect.syncInQueue.has(scriptHash)).toBeTruthy() + //@ts-ignore + expect(connect.syncInQueue.get(scriptHash)).toStrictEqual({ + blockStartNumber: 187, + blockEndNumber: 187, + cursor: undefined, + }) + }) + it('there are some txs in blockRange and more', async () => { + const syncProgress = SyncProgress.fromObject({ script, scriptType: 'lock', walletId: 'walletId' }) + getSyncStatusMock.mockResolvedValue(syncProgress) + getTransactionsMock.mockResolvedValue({ txs: [{ txHash: '0xhash1' }], lastCursor: '0xaa' }) + const connect = new LightConnector([], '') + //@ts-ignore + connect.transactionsSubject = { next: jest.fn() } + //@ts-ignore + await connect.syncNextWithScript({ script, scriptType: 'lock', blockRange: ['0xaa', '0xbb'] }) + expect(connect.transactionsSubject.next).toBeCalledWith({ txHashes: ['0xhash1'], params: scriptHash }) + //@ts-ignore + expect(connect.syncInQueue.has(scriptHash)).toBeTruthy() + //@ts-ignore + expect(connect.syncInQueue.get(scriptHash)).toStrictEqual({ + blockStartNumber: 170, + blockEndNumber: 187, + cursor: '0xaa', + }) + }) + }) + + describe('test connect', () => { + const mockFn = jest.fn() + beforeEach(() => { + mockFn.mockReset() + }) + it('connect success', async () => { + const connect = new LightConnector([], '') + //@ts-ignore + connect.initSync = mockFn + await connect.connect() + expect(mockFn).toBeCalledTimes(1) + }) + it('connect failed', async () => { + const connect = new LightConnector([], '') + //@ts-ignore + connect.initSync = mockFn + mockFn.mockImplementation(() => { throw new Error('error') }) + expect(connect.connect()).rejects.toThrowError(new Error('error')) + }) + }) + + describe('test stop', () => { + it('test stop', () => { + const connect = new LightConnector([], '') + //@ts-ignore + connect.pollingIndexer = true + connect.stop() + //@ts-ignore + expect(connect.pollingIndexer).toBeFalsy() + }) + }) + + describe('test notifyCurrentBlockNumberProcessed', () => { + it ('hash is not in syncInQueue', async () => { + const connect = new LightConnector([], '') + const mockFn = jest.fn() + //@ts-ignore + connect.subscribeSync = mockFn + await connect.notifyCurrentBlockNumberProcessed('0xhash1') + expect(updateSyncStatusMock).toBeCalledTimes(0) + expect(mockFn).toBeCalledTimes(1) + }) + it ('hash is in syncInQueue', async () => { + const connect = new LightConnector([], '') + //@ts-ignore + connect.subscribeSync = jest.fn() + //@ts-ignore + connect.syncInQueue.set('0xhash1', { blockStartNumber: 1, blockEndNumber: 1 }) + await connect.notifyCurrentBlockNumberProcessed('0xhash1') + //@ts-ignore + expect(connect.syncInQueue.has('0xhash1')).toBeFalsy() + expect(updateSyncStatusMock).toBeCalledWith('0xhash1', { blockStartNumber: 1, blockEndNumber: 1 }) + }) + }) +}) diff --git a/packages/neuron-wallet/tests/block-sync-renderer/queue.test.ts b/packages/neuron-wallet/tests/block-sync-renderer/queue.test.ts index f441c0cf27..a0d08a6c3a 100644 --- a/packages/neuron-wallet/tests/block-sync-renderer/queue.test.ts +++ b/packages/neuron-wallet/tests/block-sync-renderer/queue.test.ts @@ -3,7 +3,6 @@ import { Tip } from '@ckb-lumos/base' import { scriptToAddress } from '@nervosnetwork/ckb-sdk-utils' import { AddressType } from '../../src/models/keys/address' import SystemScriptInfo from '../../src/models/system-script-info' -import TransactionWithStatus from '../../src/models/chain/transaction-with-status' import { Address, AddressVersion } from '../../src/models/address' import Queue from '../../src/block-sync-renderer/sync/queue' import Transaction from '../../src/models/chain/transaction' @@ -30,6 +29,7 @@ const stubbedCheckAndGenerateAddressesFn = jest.fn() const stubbedNotifyCurrentBlockNumberProcessedFn = jest.fn() const stubbedUpdateCacheProcessedFn = jest.fn() const stubbedLoggerErrorFn = jest.fn() +const stubbedRPCCreateBatchRequestExecFn = jest.fn() const stubbedTxAddressFinder = jest.fn().mockImplementation((...args) => { stubbedTxAddressFinderConstructor(...args) @@ -60,19 +60,21 @@ const resetMocks = () => { stubbedUpdateCacheProcessedFn.mockReset() stubbedLoggerErrorFn.mockReset() stubbedProcessSend.mockReset() + stubbedRPCCreateBatchRequestExecFn.mockReset() } const generateFakeTx = (id: string, publicKeyHash: string = '0x') => { const fakeTx = new Transaction('') - fakeTx.hash = 'hash1' - fakeTx.inputs = [new Input(new OutPoint('0x' + id.repeat(64), '0'))] + fakeTx.hash = '0xhash1' + '0'.repeat(59) + fakeTx.inputs = [new Input(new OutPoint('0x' + id.repeat(64), '0'), '0x0')] fakeTx.outputs = [ Output.fromObject({ capacity: '1', lock: Script.fromObject({ hashType: ScriptHashType.Type, codeHash: '0x' + id.repeat(64), args: publicKeyHash }), }), ] - fakeTx.blockNumber = '1' + fakeTx.blockNumber = '0x1' + fakeTx.timestamp = '0x1880a3fa5bc' const fakeTxWithStatus = { transaction: fakeTx, txStatus: new TxStatus('0x' + id.repeat(64), TxStatusType.Committed), @@ -80,6 +82,14 @@ const generateFakeTx = (id: string, publicKeyHash: string = '0x') => { return fakeTxWithStatus } +const fakeBlockHeader = { + version: '0x0', + epoch: '0x0', + hash: `0x${'0'.repeat(64)}`, + parentHash: `0x${'0'.repeat(64)}`, + timestamp: '0x0', + number: '0x0', +} describe('queue', () => { let queue: Queue const fakeNodeUrl = 'http://fakenode:8114' @@ -113,7 +123,7 @@ describe('queue', () => { jest.useFakeTimers('legacy') stubbedBlockTipsSubject = new Subject() - stubbedTransactionsSubject = new Subject>() + stubbedTransactionsSubject = new Subject<{ txHashes: CKBComponents.Hash[], params: unknown }>() const stubbedIndexerConnector = jest.fn().mockImplementation((...args) => { stubbedIndexerConnectorConstructor(...args) return { @@ -159,6 +169,19 @@ describe('queue', () => { jest.doMock('../../src/block-sync-renderer/sync/indexer-cache-service', () => { return { updateCacheProcessed: stubbedUpdateCacheProcessedFn } }) + jest.doMock('../../src/utils/ckb-rpc', () => { + return { + generateRPC() { + return { + createBatchRequest() { + return { + exec: stubbedRPCCreateBatchRequestExecFn + } + } + } + } + } + }) const Queue = require('../../src/block-sync-renderer/sync/queue').default queue = new Queue(fakeNodeUrl, addresses) }) @@ -193,30 +216,41 @@ describe('queue', () => { }) describe('subscribes to IndexerConnector#transactionsSubject', () => { const fakeTxWithStatus1 = generateFakeTx('1', addresses[0].blake160) - const fakeTxWithStatus2 = generateFakeTx('2', addresses[0].blake160) + const fakeTxWithStatus2 = generateFakeTx('0', addresses[0].blake160) const fakeTxs = [fakeTxWithStatus2] describe('processes transactions from an event', () => { beforeEach(() => { stubbedAddressesFn.mockResolvedValue([true, addresses.map(addressMeta => addressMeta.address), []]) stubbedGetTransactionFn.mockResolvedValue(fakeTxWithStatus1) - stubbedTransactionsSubject.next(fakeTxs) + stubbedRPCCreateBatchRequestExecFn + .mockResolvedValueOnce(fakeTxs) + .mockResolvedValueOnce(fakeTxs.map(v => ({ ...fakeBlockHeader, timestamp: v.transaction.timestamp, number: v.transaction.blockNumber }))) + stubbedTransactionsSubject.next({ txHashes: fakeTxs.map(v => v.transaction.hash), params: fakeTxs[0].transaction.blockNumber }) }) describe('when saving transactions is succeeded', () => { beforeEach(flushPromises) it('check infos by hashes derived from addresses', () => { const lockHashes = ['0x1f2615a8dde4e28ca736ff763c2078aff990043f4cbf09eb4b3a58a140a0862d'] + const tx = Transaction.fromSDK(fakeTxWithStatus2.transaction.toSDK()) + tx.blockHash = fakeTxWithStatus2.txStatus.blockHash! + tx.blockNumber = BigInt(fakeTxWithStatus2.transaction.blockNumber!).toString() + tx.timestamp = BigInt(fakeTxWithStatus2.transaction.timestamp!).toString() expect(stubbedTxAddressFinderConstructor).toHaveBeenCalledWith( lockHashes, [new AssetAccountInfo().generateAnyoneCanPayScript(addressInfo.blake160).computeHash()], - fakeTxs[0].transaction, + tx, [Multisig.hash([addressInfo.blake160])] ) }) it('saves transactions', () => { for (const { transaction } of fakeTxs) { - expect(stubbedSaveFetchFn).toHaveBeenCalledWith(transaction) + const tx = Transaction.fromSDK(transaction.toSDK()) + tx.blockHash = fakeTxWithStatus2.txStatus.blockHash! + tx.blockNumber = BigInt(fakeTxWithStatus2.transaction.blockNumber!).toString() + tx.timestamp = BigInt(fakeTxWithStatus2.transaction.timestamp!).toString() + expect(stubbedSaveFetchFn).toHaveBeenCalledWith(tx) } }) it('checks and generate new addresses', () => { @@ -238,7 +272,10 @@ describe('queue', () => { const err = new Error() beforeEach(async () => { stubbedSaveFetchFn.mockRejectedValueOnce(err) - stubbedTransactionsSubject.next(fakeTxs) + stubbedRPCCreateBatchRequestExecFn + .mockResolvedValueOnce(fakeTxs) + .mockResolvedValueOnce(fakeTxs.map(v => ({ ...fakeBlockHeader, timestamp: v.transaction.timestamp, number: v.transaction.blockNumber }))) + stubbedTransactionsSubject.next({ txHashes: fakeTxs.map(v => v.transaction.hash), params: fakeTxs[0].transaction.blockNumber }) await flushPromises() }) it('handles the exception', async () => { diff --git a/packages/neuron-wallet/tests/controllers/export-debug.test.ts b/packages/neuron-wallet/tests/controllers/export-debug.test.ts index a6839c03f8..5fdb6503e6 100644 --- a/packages/neuron-wallet/tests/controllers/export-debug.test.ts +++ b/packages/neuron-wallet/tests/controllers/export-debug.test.ts @@ -73,6 +73,18 @@ jest.mock('../../src/services/settings', () => { } }) +jest.mock('../../src/services/light-runner', () => { + return { + CKBLightRunner: { + getInstance() { + return { + logPath: '' + } + } + } + } +}) + import { dialog } from 'electron' import logger from '../../src/utils/logger' import ExportDebugController from '../../src/controllers/export-debug' @@ -86,6 +98,7 @@ describe('Test ExportDebugController', () => { let showErrorBoxMock: any // controller methods let addBundledCKBLogMock: any + let addBundledCKBLightClientLogMock: any let addLogFilesMock: any let addStatusFileMock: any let archiveAppendMock: any @@ -95,10 +108,11 @@ describe('Test ExportDebugController', () => { showMessageBoxMock = jest.spyOn(dialog, 'showMessageBox') showErrorBoxMock = jest.spyOn(dialog, 'showErrorBox') addBundledCKBLogMock = jest.spyOn(exportDebugController, 'addBundledCKBLog') + addBundledCKBLightClientLogMock = jest.spyOn(exportDebugController, 'addBundledCKBLightClientLog') addLogFilesMock = jest.spyOn(exportDebugController, 'addLogFiles') addStatusFileMock = jest.spyOn(exportDebugController, 'addStatusFile') archiveAppendMock = jest.spyOn(exportDebugController.archive, 'append') - jest.spyOn(exportDebugController.archive, 'file') + jest.spyOn(exportDebugController.archive, 'file').mockReturnValue(undefined) jest.spyOn(exportDebugController.archive, 'pipe').mockImplementation(() => {}) jest.spyOn(logger, 'error') }) @@ -118,10 +132,11 @@ describe('Test ExportDebugController', () => { }) it('should call required methods', () => { - expect.assertions(8) + expect.assertions(9) expect(showSaveDialogMock).toHaveBeenCalled() expect(addBundledCKBLogMock).toHaveBeenCalled() + expect(addBundledCKBLightClientLogMock).toHaveBeenCalled() expect(addLogFilesMock).toHaveBeenCalled() expect(addStatusFileMock).toHaveBeenCalled() @@ -141,11 +156,12 @@ describe('Test ExportDebugController', () => { }) it('should not call required methods', () => { - expect.assertions(8) + expect.assertions(9) expect(showSaveDialogMock).toHaveBeenCalled() expect(addBundledCKBLogMock).not.toHaveBeenCalled() + expect(addBundledCKBLightClientLogMock).not.toHaveBeenCalled() expect(addLogFilesMock).not.toHaveBeenCalled() expect(addStatusFileMock).not.toHaveBeenCalled() expect(archiveAppendMock).not.toHaveBeenCalled() diff --git a/packages/neuron-wallet/tests/controllers/multisig.test.ts b/packages/neuron-wallet/tests/controllers/multisig.test.ts index fbc830c0dc..6b793af56e 100644 --- a/packages/neuron-wallet/tests/controllers/multisig.test.ts +++ b/packages/neuron-wallet/tests/controllers/multisig.test.ts @@ -18,6 +18,15 @@ jest.mock('electron', () => ({ getFocusedWindow: jest.fn(), }, })) +jest.mock('services/wallets', () => ({ + getInstance() { + return { + getCurrent() { + return jest.fn() + } + } + } +})) jest.mock('../../src/services/multisig') const MultiSigServiceMock = MultisigService as jest.MockedClass diff --git a/packages/neuron-wallet/tests/controllers/offline-sign.test.ts b/packages/neuron-wallet/tests/controllers/offline-sign.test.ts index 6cee3aabdc..b3cdd1d622 100644 --- a/packages/neuron-wallet/tests/controllers/offline-sign.test.ts +++ b/packages/neuron-wallet/tests/controllers/offline-sign.test.ts @@ -151,15 +151,7 @@ describe('OfflineSignController', () => { jest.doMock('services/node', () => { return class { static getInstance() { - return { - ckb: { - rpc: { - paramsFormatter: { - toRawTransaction: (tx: any) => tx, - }, - }, - }, - } + return {} } } }) @@ -193,6 +185,18 @@ describe('OfflineSignController', () => { getMultisigStatus: getMultisigStatusMock, })) + jest.doMock('../../src/utils/ckb-rpc', () => { + return { + generateRPC() { + return { + paramsFormatter: { + toRawTransaction: (tx: any) => tx + } + } + } + } + }) + const OfflineSignController = require('../../src/controllers/offline-sign').default offlineSignController = new OfflineSignController() }) diff --git a/packages/neuron-wallet/tests/controllers/sync-api.test.ts b/packages/neuron-wallet/tests/controllers/sync-api.test.ts index 4d34ced8d9..ec06ed61d0 100644 --- a/packages/neuron-wallet/tests/controllers/sync-api.test.ts +++ b/packages/neuron-wallet/tests/controllers/sync-api.test.ts @@ -50,6 +50,9 @@ jest.doMock('services/ckb-runner', () => ({ jest.mock('undici', () => ({ request: () => jest.fn()(), })) +jest.mock('services/multisig', () => ({ + syncMultisigOutput: () => jest.fn() +})) describe('SyncApiController', () => { const emitter = new Emitter() @@ -134,11 +137,9 @@ describe('SyncApiController', () => { bestKnownBlockTimestamp: `0x${bestKnownBlockTimestamp.toString(16)}`, }) stubbedNodeGetInstance.mockReturnValue({ - ckb: { - node: { - url: fakeNodeUrl, - }, - }, + get nodeUrl() { + return fakeNodeUrl + } }) stubbedGetTipHeader.mockResolvedValue({ timestamp: '180000' }) }) @@ -360,11 +361,9 @@ describe('SyncApiController', () => { describe('with another node url', () => { beforeEach(async () => { stubbedNodeGetInstance.mockReturnValue({ - ckb: { - node: { - url: 'anotherfakeurl', - }, - }, + get nodeUrl() { + return 'anotherfakeurl' + } }) await sendFakeCacheBlockTipEvent(newFakeState) @@ -472,11 +471,9 @@ describe('SyncApiController', () => { describe('when node url changed', () => { beforeEach(async () => { stubbedNodeGetInstance.mockImplementation(() => ({ - ckb: { - node: { - url: 'http://diffurl', - }, - }, + get nodeUrl() { + return 'http://diffurl' + } })) await sendFakeCacheBlockTipEvent(fakeState3) }) diff --git a/packages/neuron-wallet/tests/models/chain/live-cell.test.ts b/packages/neuron-wallet/tests/models/chain/live-cell.test.ts index f60398acc5..7e8c199ada 100644 --- a/packages/neuron-wallet/tests/models/chain/live-cell.test.ts +++ b/packages/neuron-wallet/tests/models/chain/live-cell.test.ts @@ -1,6 +1,6 @@ import Script, { ScriptHashType } from '../../../src/models/chain/script' -import { LumosCell } from '../../../src/block-sync-renderer/sync/indexer-connector' -import LiveCell from '../../../src/models/chain/live-cell' +import { LumosCell } from '../../../src/block-sync-renderer/sync/connector' +import LiveCell from "../../../src/models/chain/live-cell" describe('LiveCell Test', () => { const INITIAL_DATA = { diff --git a/packages/neuron-wallet/tests/services/ckb-runner.test.ts b/packages/neuron-wallet/tests/services/ckb-runner.test.ts index e4fe206a45..fd2a07f6f8 100644 --- a/packages/neuron-wallet/tests/services/ckb-runner.test.ts +++ b/packages/neuron-wallet/tests/services/ckb-runner.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'typeorm/platform/PlatformTools' import path from 'path' +import { scheduler } from 'timers/promises' const stubbedChildProcess = jest.fn() const stubbedSpawn = jest.fn() @@ -8,6 +9,7 @@ const stubbedExistsSync = jest.fn() const stubbedLoggerInfo = jest.fn() const stubbedLoggerError = jest.fn() const stubbedLoggerLog = jest.fn() +const resetSyncTaskQueueAsyncPushMock = jest.fn() const stubbedProcess: any = {} @@ -18,6 +20,7 @@ const resetMocks = () => { stubbedLoggerInfo.mockReset() stubbedLoggerError.mockReset() stubbedLoggerLog.mockReset() + resetSyncTaskQueueAsyncPushMock.mockReset() } jest.doMock('child_process', () => { @@ -72,6 +75,11 @@ jest.mock('../../src/block-sync-renderer', () => ({ jest.mock('../../src/services/indexer', () => ({ cleanOldIndexerData: jest.fn(), })) +jest.doMock('../../src/block-sync-renderer', () => ({ + resetSyncTaskQueue: { + asyncPush: resetSyncTaskQueueAsyncPushMock + } +})) const { startCkbNode, stopCkbNode, @@ -88,6 +96,7 @@ describe('ckb runner', () => { stubbedCkb.stderr = new EventEmitter() stubbedCkb.stdout = new EventEmitter() stubbedSpawn.mockReturnValue(stubbedCkb) + resetSyncTaskQueueAsyncPushMock.mockReturnValue('') }) ;[ { platform: 'win32', platformPath: 'win' }, @@ -122,7 +131,7 @@ describe('ckb runner', () => { expect(stubbedSpawn).toHaveBeenCalledWith( expect.stringContaining(path.join(platformPath, 'ckb')), ['run', '-C', ckbDataPath, '--indexer'], - { stdio: ['ignore', 'pipe', 'pipe'] } + { stdio: ['ignore', 'ignore', 'pipe'] } ) }) }) @@ -156,7 +165,7 @@ describe('ckb runner', () => { expect(stubbedSpawn).toHaveBeenCalledWith( expect.stringContaining(path.join(platformPath, 'ckb')), ['run', '-C', ckbDataPath, '--indexer'], - { stdio: ['ignore', 'pipe', 'pipe'] } + { stdio: ['ignore', 'ignore', 'pipe'] } ) }) }) @@ -178,14 +187,25 @@ describe('ckb runner', () => { describe('with assume valid target', () => { beforeEach(async () => { + stubbedProcess.platform = platform app.isPackaged = true stubbedProcess.env = { CKB_NODE_ASSUME_VALID_TARGET: '0x' + '0'.repeat(64) } stubbedExistsSync.mockReturnValue(true) await startCkbNode() }) - afterEach(() => { + afterEach(async () => { app.isPackaged = false stubbedProcess.env = {} + const promise = stopCkbNode() + stubbedCkb.emit('close') + await promise + }) + it('runs ckb binary', () => { + expect(stubbedSpawn).toHaveBeenCalledWith( + expect.stringContaining(path.join('bin', 'ckb')), + ['run', '-C', ckbDataPath, '--indexer', "--assume-valid-target", '0x' + '0'.repeat(64)], + { stdio: ['ignore', 'pipe', 'pipe'] } + ) }) it('is Looking valid target', () => { stubbedCkb.stdout.emit( @@ -193,21 +213,15 @@ describe('ckb runner', () => { `can't find assume valid target temporarily, hash: Byte32(0x${'0'.repeat(64)})` ) expect(getLookingValidTargetStatus()).toBeTruthy() - stubbedCkb.emit('close') }) it('is Looking valid target', async () => { stubbedCkb.stdout.emit( 'data', `can't find assume valid target temporarily, hash: Byte32(0x${'0'.repeat(64)})` ) - await new Promise(resolve => - setTimeout(() => { - resolve(undefined) - }, 11000) - ) + await scheduler.wait(11000) stubbedCkb.stdout.emit('data', `had find valid target`) expect(getLookingValidTargetStatus()).toBeFalsy() - stubbedCkb.emit('close') }, 15000) it('ckb has closed', async () => { stubbedCkb.stdout.emit( diff --git a/packages/neuron-wallet/tests/services/indexer.test.ts b/packages/neuron-wallet/tests/services/indexer.test.ts index ad6aeefe68..0d8872c9fb 100644 --- a/packages/neuron-wallet/tests/services/indexer.test.ts +++ b/packages/neuron-wallet/tests/services/indexer.test.ts @@ -4,6 +4,7 @@ const existsSyncMock = jest.fn() const rmSyncMock = jest.fn() const isCkbNodeExternalMock = jest.fn() const stopMonitorMock = jest.fn() +const checkNodeMock = jest.fn() jest.mock('fs', () => { return { @@ -26,6 +27,9 @@ jest.mock('../../src/services/settings', () => { set indexerDataPath(value: string) { setIndexerDataPathMock(value) }, + get ckbDataPath() { + return jest.fn().mockReturnValue('')() + } } } } @@ -35,9 +39,17 @@ jest.mock('../../src/utils/logger', () => ({ debug: () => jest.fn(), })) -jest.mock('../../src/models/synced-block-number', () => ({})) +jest.mock('../../src/models/synced-block-number', () => { + return function() { + return { + setNextBlock: jest.fn() + } + } +}) -jest.mock('../../src/database/chain', () => ({})) +jest.mock('../../src/database/chain', () => ({ + clean: () => jest.fn() +})) jest.mock('../../src/services/monitor', () => { function mockMonitor() {} @@ -51,10 +63,18 @@ jest.mock('../../src/services/node', () => ({ get isCkbNodeExternal() { return isCkbNodeExternalMock() }, + checkNode: checkNodeMock, } }, })) +const resetSyncTaskQueueAsyncPushMock = jest.fn() +jest.mock('../../src/block-sync-renderer', () => ({ + resetSyncTaskQueue: { + asyncPush: () => resetSyncTaskQueueAsyncPushMock() + } +})) + describe('test IndexerService', () => { beforeEach(() => { existsSyncMock.mockReset() @@ -86,15 +106,32 @@ describe('test IndexerService', () => { }) describe('test clear cache', () => { - it('is external ckb node', () => { + beforeEach(() => { + resetSyncTaskQueueAsyncPushMock.mockReset() + }) + it('is external ckb node', async () => { isCkbNodeExternalMock.mockReturnValue(true) - IndexerService.clearCache() + await IndexerService.clearCache() + expect(stopMonitorMock).toBeCalledTimes(0) + expect(resetSyncTaskQueueAsyncPushMock).toBeCalledTimes(1) + }) + it('is internal ckb node', async () => { + isCkbNodeExternalMock.mockReturnValue(false) + await IndexerService.clearCache() expect(stopMonitorMock).toBeCalledTimes(0) + expect(resetSyncTaskQueueAsyncPushMock).toBeCalledTimes(1) }) - it('is internal ckb node', () => { + it('clear indexer data with internal ckb node', async () => { isCkbNodeExternalMock.mockReturnValue(false) - IndexerService.clearCache() + await IndexerService.clearCache(true) expect(stopMonitorMock).toBeCalledTimes(1) + expect(resetSyncTaskQueueAsyncPushMock).toBeCalledTimes(1) + }) + it('clear indexer data with external ckb node', async () => { + isCkbNodeExternalMock.mockReturnValue(true) + await IndexerService.clearCache(true) + expect(stopMonitorMock).toBeCalledTimes(0) + expect(resetSyncTaskQueueAsyncPushMock).toBeCalledTimes(1) }) }) }) diff --git a/packages/neuron-wallet/tests/services/light-runner.test.ts b/packages/neuron-wallet/tests/services/light-runner.test.ts new file mode 100644 index 0000000000..f2b1fdf74c --- /dev/null +++ b/packages/neuron-wallet/tests/services/light-runner.test.ts @@ -0,0 +1,297 @@ +import EventEmitter from 'events' +import path from 'path' + +const lightRunnerDirpath = path.join(__dirname, '../../src/services') +const mockFn = jest.fn() +const getAppPathMock = jest.fn() +const isPackagedMock = jest.fn() +const platformMock = jest.fn() +const joinMock = jest.fn() +const dirnameMock = jest.fn() +const resolveMock = jest.fn() +const lightDataPathMock = jest.fn() +const existsSyncMock = jest.fn() +const readFileSyncMock = jest.fn() +const mkdirSyncMock = jest.fn() +const writeFileSyncMock = jest.fn() +const createWriteStreamMock = jest.fn() +const spawnMock = jest.fn() +const loggerErrorMock = jest.fn() +const loggerInfoMock = jest.fn() +const transportsGetFileMock = jest.fn() +const cleanMock = jest.fn() +const resetSyncTaskQueueAsyncPushMock = jest.fn() + +function resetMock() { + mockFn.mockReset() + getAppPathMock.mockReset() + isPackagedMock.mockReset() + platformMock.mockReset() + joinMock.mockReset() + dirnameMock.mockReset() + resolveMock.mockReset() + lightDataPathMock.mockReset() + existsSyncMock.mockReset() + readFileSyncMock.mockReset() + mkdirSyncMock.mockReset() + writeFileSyncMock.mockReset() + createWriteStreamMock.mockReset() + spawnMock.mockReset() + loggerErrorMock.mockReset() + loggerInfoMock.mockReset() + transportsGetFileMock.mockReset() + cleanMock.mockReset() + resetSyncTaskQueueAsyncPushMock.mockReset() +} + +jest.doMock('../../src/env', () => ({ + app: { + getAppPath: getAppPathMock, + get isPackaged() { + return isPackagedMock() + }, + } +})) + +jest.doMock('../../src/utils/logger', () => ({ + info: loggerInfoMock, + error: loggerErrorMock, + transports: { + file: { + getFile: transportsGetFileMock + } + } +})) + +jest.doMock('../../src/services/settings', () => ({ + getInstance() { + return { + get testnetLightDataPath() { return lightDataPathMock() } + } + } +})) + +jest.doMock('../../src/database/chain', () => ({ + clean: cleanMock +})) + +jest.doMock('../../src/block-sync-renderer', () => ({ + resetSyncTaskQueue: { + asyncPush: resetSyncTaskQueueAsyncPushMock + } +})) + +jest.doMock('process', () => ({ + get platform() { + return platformMock() + }, +})) + +jest.doMock('path', () => ({ + join: joinMock, + dirname: dirnameMock, + resolve: resolveMock, +})) + +jest.doMock('fs', () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + mkdirSync: mkdirSyncMock, + writeFileSync: writeFileSyncMock, + createWriteStream: createWriteStreamMock +})) + +jest.doMock('child_process', () => ({ + spawn: spawnMock, +})) + +const { CKBLightRunner } = require('../../src/services/light-runner') + +describe('test light runner', () => { + beforeEach(() => { + transportsGetFileMock.mockReturnValue({ path: ''}) + createWriteStreamMock.mockReturnValue({ write() {} }) + }) + afterEach(() => { + resetMock() + }) + + describe('test getInstance', () => { + it('get from getInstance is same', () => { + expect(CKBLightRunner.getInstance()).toBe(CKBLightRunner.getInstance()) + }) + }) + + describe('test binary', () => { + it('is packaged and is win', () => { + const tmp = CKBLightRunner.getInstance().platform + CKBLightRunner.getInstance().platform = mockFn + isPackagedMock.mockReturnValue(true) + getAppPathMock.mockReturnValue('apppath') + resolveMock.mockReturnValue('prefixpath') + joinMock.mockReturnValue('joinpath') + mockFn.mockReturnValue('win') + dirnameMock.mockReturnValue('dir') + expect(CKBLightRunner.getInstance().binary).toBe('prefixpath.exe') + expect(dirnameMock).toBeCalledWith('apppath') + expect(joinMock).toBeCalledWith('dir', '..', './bin') + expect(resolveMock).toBeCalledWith('joinpath', './ckb-light-client') + CKBLightRunner.getInstance().platform = tmp + }) + it('is packaged and is mac', () => { + const tmp = CKBLightRunner.getInstance().platform + CKBLightRunner.getInstance().platform = mockFn + isPackagedMock.mockReturnValue(true) + resolveMock.mockReturnValue('prefixpath') + mockFn.mockReturnValue('mac') + expect(CKBLightRunner.getInstance().binary).toBe('prefixpath') + CKBLightRunner.getInstance().platform = tmp + }) + it('is packaged and is linux', () => { + const tmp = CKBLightRunner.getInstance().platform + CKBLightRunner.getInstance().platform = mockFn + isPackagedMock.mockReturnValue(true) + resolveMock.mockReturnValue('prefixpath') + mockFn.mockReturnValue('linux') + expect(CKBLightRunner.getInstance().binary).toBe('prefixpath') + CKBLightRunner.getInstance().platform = tmp + }) + it('is not packaged', () => { + const tmp = CKBLightRunner.getInstance().platform + CKBLightRunner.getInstance().platform = mockFn + isPackagedMock.mockReturnValue(false) + resolveMock.mockReturnValue('prefixpath') + joinMock.mockReturnValue('joinpath') + mockFn.mockReturnValue('mac') + expect(CKBLightRunner.getInstance().binary).toBe('prefixpath') + expect(dirnameMock).toBeCalledTimes(0) + expect(getAppPathMock).toBeCalledTimes(0) + expect(joinMock).toBeCalledWith(lightRunnerDirpath, '../../bin') + expect(resolveMock).toBeCalledWith('joinpath', `./${CKBLightRunner.getInstance().platform()}`, './ckb-light-client') + CKBLightRunner.getInstance().platform = tmp + }) + }) + + describe('test templateConfigFile', () => { + it('app is packaged', () => { + isPackagedMock.mockReturnValue(true) + dirnameMock.mockReturnValue('dir') + joinMock.mockReturnValue('join') + resolveMock.mockReturnValue('resolve') + expect(CKBLightRunner.getInstance().templateConfigFile).toBe('resolve') + expect(joinMock).toBeCalledWith('dir', '..', './light') + expect(resolveMock).toBeCalledWith('join', './ckb_light.toml') + }) + it('app is not packaged', () => { + isPackagedMock.mockReturnValue(false) + dirnameMock.mockReturnValue('dir') + joinMock.mockReturnValue('join') + resolveMock.mockReturnValue('resolve') + expect(CKBLightRunner.getInstance().templateConfigFile).toBe('resolve') + expect(joinMock).toBeCalledWith(lightRunnerDirpath, '../../light') + expect(resolveMock).toBeCalledWith('join', './ckb_light.toml') + }) + }) + + describe('test configFile', () => { + lightDataPathMock.mockReturnValue('lightDataPath') + CKBLightRunner.getInstance().configFile + expect(resolveMock).toBeCalledWith('lightDataPath', './ckb_light.toml') + }) + + describe('test initConfig', () => { + it('configFile is exist', () => { + existsSyncMock.mockReturnValue(true) + CKBLightRunner.getInstance().initConfig() + expect(readFileSyncMock).toBeCalledTimes(0) + }) + it('configFile is not exist replace store path and network path', () => { + existsSyncMock.mockReturnValue(false) + readFileSyncMock.mockReturnValue('[store]\npath=aaa\n[network]\npath=bbb') + lightDataPathMock.mockReturnValue('light-data-path') + joinMock.mockReturnValue('new-path') + resolveMock.mockReturnValue('config') + CKBLightRunner.getInstance().initConfig() + expect(mkdirSyncMock).toBeCalledWith('light-data-path', { recursive: true }) + expect(writeFileSyncMock).toBeCalledWith('config', '[store]\npath = "new-path"\n[network]\npath = "new-path"') + }) + }) + + describe('test start', () => { + it('when runnerProcess is not undefined', async () => { + const tmp = CKBLightRunner.getInstance().stop + CKBLightRunner.getInstance().stop = mockFn + CKBLightRunner.getInstance().runnerProcess = {} + const eventEmitter = new EventEmitter() + spawnMock.mockReturnValue(eventEmitter) + existsSyncMock.mockReturnValue(true) + await CKBLightRunner.getInstance().start() + expect(mockFn).toBeCalledTimes(1) + CKBLightRunner.getInstance().stop = tmp + }) + it('when runnerProcess is undefined', async () => { + const tmp = CKBLightRunner.getInstance().stop + CKBLightRunner.getInstance().stop = mockFn + CKBLightRunner.getInstance().runnerProcess = undefined + const eventEmitter = new EventEmitter() + spawnMock.mockReturnValue(eventEmitter) + existsSyncMock.mockReturnValue(true) + await CKBLightRunner.getInstance().start() + expect(mockFn).toBeCalledTimes(0) + CKBLightRunner.getInstance().stop = tmp + }) + it('when runnerProcess is undefined and on error', async () => { + CKBLightRunner.getInstance().runnerProcess = undefined + const eventEmitter = new EventEmitter() + spawnMock.mockReturnValue(eventEmitter) + existsSyncMock.mockReturnValue(true) + await CKBLightRunner.getInstance().start() + expect(CKBLightRunner.getInstance().runnerProcess).toBeDefined() + expect(mockFn).toBeCalledTimes(0) + eventEmitter.emit('error', 'errorInfo') + expect(loggerErrorMock).toBeCalledWith('CKB Light Runner:\trun fail:', 'errorInfo') + expect(CKBLightRunner.getInstance().runnerProcess).toBeUndefined() + }) + it('when runnerProcess is undefined and on close', async () => { + CKBLightRunner.getInstance().runnerProcess = undefined + const eventEmitter = new EventEmitter() + spawnMock.mockReturnValue(eventEmitter) + existsSyncMock.mockReturnValue(true) + await CKBLightRunner.getInstance().start() + expect(CKBLightRunner.getInstance().runnerProcess).toBeDefined() + expect(mockFn).toBeCalledTimes(0) + eventEmitter.emit('close', 'closeInfo') + expect(loggerInfoMock).toBeCalledWith('CKB Light Runner:\tprocess closed') + expect(CKBLightRunner.getInstance().runnerProcess).toBeUndefined() + }) + it('when runnerProcess is undefined and on stderr', async () => { + CKBLightRunner.getInstance().runnerProcess = undefined + const eventEmitter: EventEmitter & { stderr?: EventEmitter } = new EventEmitter() + eventEmitter.stderr = new EventEmitter() + spawnMock.mockReturnValue(eventEmitter) + existsSyncMock.mockReturnValue(true) + await CKBLightRunner.getInstance().start() + expect(CKBLightRunner.getInstance().runnerProcess).toBeDefined() + expect(mockFn).toBeCalledTimes(0) + eventEmitter.stderr.emit('data', 'error-data') + expect(loggerErrorMock).toBeCalledWith('CKB Light Runner:\trun fail:', 'error-data') + expect(CKBLightRunner.getInstance().runnerProcess).toBeDefined() + }) + }) + + describe('test stop', () => { + it('runnerProcess is undefined', async () => { + CKBLightRunner.getInstance().runnerProcess = undefined + await CKBLightRunner.getInstance().stop() + }) + it('runnerProcess is defined', async () => { + const emitter: EventEmitter & { kill?: Function } = new EventEmitter() + emitter.kill = mockFn + CKBLightRunner.getInstance().runnerProcess = emitter + mockFn.mockImplementation(() => { emitter.emit('close') }) + await CKBLightRunner.getInstance().stop() + expect(mockFn).toBeCalledWith() + expect(CKBLightRunner.getInstance().runnerProcess).toBeUndefined() + }) + }) +}) \ No newline at end of file diff --git a/packages/neuron-wallet/tests/services/networks.test.ts b/packages/neuron-wallet/tests/services/networks.test.ts index d9921dd8bd..f35bcdae93 100644 --- a/packages/neuron-wallet/tests/services/networks.test.ts +++ b/packages/neuron-wallet/tests/services/networks.test.ts @@ -37,7 +37,7 @@ describe(`Unit tests of networks service`, () => { it(`has preset networks`, () => { const networks = service.getAll() - expect(networks.length).toBe(1) + expect(networks.length).toBe(2) expect(networks[0].id).toEqual('mainnet') }) @@ -126,10 +126,10 @@ describe(`Unit tests of networks service`, () => { const prevCurrentID = service.getCurrentID() const prevNetworks = service.getAll() expect(prevCurrentID).toBe(network.id) - expect(prevNetworks.map(n => n.id)).toEqual(['mainnet', network.id]) + expect(prevNetworks.map(n => n.id)).toEqual(['mainnet', 'light_client_testnet', network.id]) await service.delete(prevCurrentID || '') const currentNetworks = service.getAll() - expect(currentNetworks.map(n => n.id)).toEqual(['mainnet']) + expect(currentNetworks.map(n => n.id)).toEqual(['mainnet', 'light_client_testnet']) const currentID = service.getCurrentID() expect(currentID).toBe('mainnet') }) diff --git a/packages/neuron-wallet/tests/services/node.test.ts b/packages/neuron-wallet/tests/services/node.test.ts index 160c21afbc..5cf5b39d24 100644 --- a/packages/neuron-wallet/tests/services/node.test.ts +++ b/packages/neuron-wallet/tests/services/node.test.ts @@ -1,14 +1,17 @@ import { distinctUntilChanged, sampleTime, flatMap, delay, retry } from 'rxjs/operators' import { BUNDLED_CKB_URL, START_WITHOUT_INDEXER } from '../../src/utils/const' +import { NetworkType } from '../../src/models/network' describe('NodeService', () => { let nodeService: any const stubbedStartCKBNode = jest.fn() + const stubbedStopCkbNode = jest.fn() + const stubbedStartLightNode = jest.fn() + const stubbedStopLightNode = jest.fn() const stubbedConnectionStatusSubjectNext = jest.fn() const stubbedCKBSetNode = jest.fn() const stubbedGetTipBlockNumber = jest.fn() const stubbedRxjsDebounceTime = jest.fn() - const stubbedCKB = jest.fn() const stubbedCurrentNetworkIDSubjectSubscribe = jest.fn() const stubbedNetworsServiceGet = jest.fn() const stubbedLoggerInfo = jest.fn() @@ -25,14 +28,13 @@ describe('NodeService', () => { const getLocalNodeInfoMock = jest.fn() const fakeHTTPUrl = 'http://fakeurl' - const fakeHTTPSUrl = 'https://fakeurl' const resetMocks = () => { stubbedStartCKBNode.mockReset() + stubbedStopCkbNode.mockReset() stubbedConnectionStatusSubjectNext.mockReset() stubbedCKBSetNode.mockReset() stubbedGetTipBlockNumber.mockReset() - stubbedCKB.mockReset() stubbedCurrentNetworkIDSubjectSubscribe.mockReset() stubbedNetworsServiceGet.mockReset() stubbedLoggerInfo.mockReset() @@ -47,6 +49,8 @@ describe('NodeService', () => { rpcRequestMock.mockReset() getChainMock.mockReset() getLocalNodeInfoMock.mockReset() + stubbedStartLightNode.mockReset() + stubbedStopLightNode.mockReset() } beforeEach(() => { @@ -56,6 +60,7 @@ describe('NodeService', () => { jest.doMock('../../src/services/ckb-runner', () => { return { startCkbNode: stubbedStartCKBNode, + stopCkbNode: stubbedStopCkbNode, } }) jest.doMock('../../src/services/networks', () => { @@ -80,8 +85,14 @@ describe('NodeService', () => { }, } }) - jest.doMock('@nervosnetwork/ckb-sdk-core', () => { - return stubbedCKB + jest.doMock('../../src/utils/ckb-rpc', () => { + return { + generateRPC() { + return { + getTipBlockNumber: stubbedGetTipBlockNumber, + } + } + } }) jest.doMock('rxjs/operators', () => { return { @@ -136,7 +147,20 @@ describe('NodeService', () => { return function () { return { getChain: getChainMock, - getLocalNodeInfo: getLocalNodeInfoMock, + localNodeInfo: getLocalNodeInfoMock + } + } + }) + + jest.doMock('../../src/services/light-runner', () => { + return { + CKBLightRunner: { + getInstance() { + return { + start: stubbedStartLightNode, + stop: stubbedStopLightNode, + } + } } } }) @@ -151,16 +175,6 @@ describe('NodeService', () => { describe('when targets external node', () => { beforeEach(async () => { - stubbedCKB.mockImplementation(() => ({ - setNode: stubbedCKBSetNode, - rpc: { - getTipBlockNumber: stubbedGetTipBlockNumber, - }, - node: { - url: fakeHTTPUrl, - }, - })) - const NodeService = require('../../src/services/node').default nodeService = new NodeService() @@ -168,7 +182,7 @@ describe('NodeService', () => { }) it('emits disconnected event in ConnectionStatusSubject', () => { expect(stubbedConnectionStatusSubjectNext).toHaveBeenCalledWith({ - url: fakeHTTPUrl, + url: nodeService.nodeUrl, connected: false, isBundledNode: false, startedBundledNode: false, @@ -176,6 +190,7 @@ describe('NodeService', () => { }) describe('advance to next event', () => { beforeEach(async () => { + nodeService.setNetwork(fakeHTTPUrl) stubbedConnectionStatusSubjectNext.mockReset() stubbedGetTipBlockNumber.mockResolvedValueOnce('0x1') jest.advanceTimersByTime(1000) @@ -208,16 +223,6 @@ describe('NodeService', () => { }) describe('when targets bundled node', () => { beforeEach(async () => { - stubbedCKB.mockImplementation(() => ({ - setNode: stubbedCKBSetNode, - rpc: { - getTipBlockNumber: stubbedGetTipBlockNumber, - }, - node: { - url: BUNDLED_CKB_URL, - }, - })) - const NodeService = require('../../src/services/node').default nodeService = new NodeService() nodeService.verifyNodeVersion = () => {} @@ -228,20 +233,21 @@ describe('NodeService', () => { describe('when node starts', () => { beforeEach(async () => { stubbedStartCKBNode.mockResolvedValue(true) - await nodeService.tryStartNodeOnDefaultURI() + await nodeService.startNode() jest.advanceTimersByTime(1000) }) it('emits disconnected event in ConnectionStatusSubject', () => { - expect(stubbedConnectionStatusSubjectNext).toHaveBeenCalledWith({ - url: BUNDLED_CKB_URL, + expect(stubbedConnectionStatusSubjectNext).toHaveBeenLastCalledWith({ + url: nodeService.nodeUrl, connected: false, - isBundledNode: true, + isBundledNode: false, startedBundledNode: false, }) }) describe('advance to next event', () => { beforeEach(async () => { + nodeService.setNetwork(BUNDLED_CKB_URL) stubbedConnectionStatusSubjectNext.mockReset() stubbedGetTipBlockNumber.mockResolvedValueOnce('0x1') jest.advanceTimersByTime(1000) @@ -275,36 +281,37 @@ describe('NodeService', () => { describe('when node failed to start', () => { beforeEach(async () => { stubbedStartCKBNode.mockRejectedValue(new Error()) - await nodeService.tryStartNodeOnDefaultURI() - }) + await nodeService.startNode() + }); it('logs error', () => { expect(stubbedLoggerInfo).toHaveBeenCalledWith('CKB: fail to start bundled CKB with error:') expect(stubbedLoggerError).toHaveBeenCalledWith(new Error()) }) it('emits disconnected event in ConnectionStatusSubject', () => { expect(stubbedConnectionStatusSubjectNext).toHaveBeenCalledWith({ - url: BUNDLED_CKB_URL, + url: nodeService.nodeUrl, connected: false, - isBundledNode: true, + isBundledNode: false, startedBundledNode: false, }) }) + }); + describe('start light node', () => { + beforeEach(() => { + stubbedNetworsServiceGet.mockReset() + }) + it('start light node', async () => { + stubbedNetworsServiceGet.mockReturnValueOnce({type: NetworkType.Light}) + await nodeService.startNode() + expect(stubbedStartLightNode).toBeCalled() + expect(stubbedStopCkbNode).toBeCalled() + }) }) - }) + }); describe('CurrentNetworkIDSubject#subscribe', () => { let eventCallback: any const stubbedTipNumberSubjectCallback = jest.fn() beforeEach(async () => { - stubbedCKB.mockImplementation(() => ({ - setNode: stubbedCKBSetNode, - rpc: { - getTipBlockNumber: stubbedGetTipBlockNumber, - }, - node: { - url: fakeHTTPUrl, - }, - })) - const NodeService = require('../../src/services/node').default nodeService = new NodeService() nodeService.tipNumberSubject.subscribe(stubbedTipNumberSubjectCallback) @@ -314,7 +321,7 @@ describe('NodeService', () => { }) it('emits disconnected event in ConnectionStatusSubject', () => { expect(stubbedConnectionStatusSubjectNext).toHaveBeenCalledWith({ - url: fakeHTTPUrl, + url: nodeService.nodeUrl, connected: false, isBundledNode: false, startedBundledNode: false, @@ -330,7 +337,8 @@ describe('NodeService', () => { nodeService.ckb.node.url = bundledNodeUrl }) stubbedStartCKBNode.mockResolvedValue(true) - stubbedNetworsServiceGet.mockReturnValue({ remote: bundledNodeUrl }) + stubbedNetworsServiceGet.mockReturnValue({remote: bundledNodeUrl}) + getLocalNodeInfoMock.mockRejectedValue('not start') await nodeService.tryStartNodeOnDefaultURI() await eventCallback({ currentNetworkID: 'network1' }) @@ -347,11 +355,8 @@ describe('NodeService', () => { describe('switches to other network', () => { beforeEach(async () => { stubbedConnectionStatusSubjectNext.mockReset() - stubbedCKBSetNode.mockImplementation(() => { - nodeService.ckb.node.url = fakeHTTPUrl - }) - - await eventCallback({ currentNetworkID: 'network2' }) + stubbedNetworsServiceGet.mockReturnValue({remote: fakeHTTPUrl}) + await eventCallback({currentNetworkID: 'network2'}) jest.advanceTimersByTime(10000) }) it('sets startedBundledNode to true in ConnectionStatusSubject', () => { @@ -362,36 +367,8 @@ describe('NodeService', () => { startedBundledNode: false, }) }) - }) - }) - describe('with http url', () => { - beforeEach(async () => { - stubbedNetworsServiceGet.mockReturnValueOnce({ remote: fakeHTTPUrl }) - await eventCallback({ currentNetworkID: 'test' }) - }) - it('sets http agent', () => { - expect(stubbedCKBSetNode).toHaveBeenCalledWith( - expect.objectContaining({ - url: fakeHTTPUrl, - httpAgent: expect.anything(), - }) - ) - }) - }) - describe('with https url', () => { - beforeEach(async () => { - stubbedNetworsServiceGet.mockReturnValueOnce({ remote: fakeHTTPSUrl }) - await eventCallback({ currentNetworkID: 'test' }) - }) - it('sets https agent', () => { - expect(stubbedCKBSetNode).toHaveBeenCalledWith( - expect.objectContaining({ - url: fakeHTTPSUrl, - httpsAgent: expect.anything(), - }) - ) - }) - }) + }); + }); describe('with invalid url', () => { beforeEach(() => { stubbedNetworsServiceGet.mockReturnValueOnce({ remote: 'invalidurl' }) diff --git a/packages/neuron-wallet/tests/services/tx/transaction-sender.test.ts b/packages/neuron-wallet/tests/services/tx/transaction-sender.test.ts index 44f18dcbf2..853d4e6aa9 100644 --- a/packages/neuron-wallet/tests/services/tx/transaction-sender.test.ts +++ b/packages/neuron-wallet/tests/services/tx/transaction-sender.test.ts @@ -130,6 +130,26 @@ jest.doMock('services/hardware', () => ({ }), })) +jest.doMock('@nervosnetwork/ckb-sdk-core', () => { + return function() { + return { + calculateDaoMaximumWithdraw: stubbedCalculateDaoMaximumWithdraw, + } + } +}) + +jest.doMock('utils/ckb-rpc.ts', () => ({ + generateRPC() { + return { + sendTransaction: stubbedSendTransaction + } + } +})) + +jest.doMock('services/cells', () => ({ + getLiveCell: stubbedGetLiveCell +})) + import Transaction from '../../../src/models/chain/transaction' import TxStatus from '../../../src/models/chain/tx-status' import CellDep, { DepType } from '../../../src/models/chain/cell-dep' @@ -142,7 +162,6 @@ import { AddressType } from '../../../src/models/keys/address' import WitnessArgs from '../../../src/models/chain/witness-args' import CellWithStatus from '../../../src/models/chain/cell-with-status' import SystemScriptInfo from '../../../src/models/system-script-info' -import NodeService from '../../../src/services/node' import AssetAccountInfo from '../../../src/models/asset-account-info' import { CapacityNotEnoughForChange, @@ -260,12 +279,6 @@ describe('TransactionSender Test', () => { resetMocks() stubbedGetWallet.mockReturnValue(fakeWallet) - - //@ts-ignore - NodeService.getInstance().ckb.rpc = { - sendTransaction: stubbedSendTransaction, - } - NodeService.getInstance().ckb.calculateDaoMaximumWithdraw = stubbedCalculateDaoMaximumWithdraw }) describe('sign', () => { @@ -672,7 +685,7 @@ describe('TransactionSender Test', () => { const feeRate = '10' const fakeDepositOutPoint = OutPoint.fromObject({ txHash: '0x' + '0'.repeat(64), index: '0x0' }) beforeEach(async () => { - stubbedGetLiveCell.mockResolvedValue(fakeCellWithStatus) + stubbedGetLiveCell.mockResolvedValue(fakeCellWithStatus.cell!.output) stubbedGetTransaction.mockResolvedValue(fakeTx1) stubbedGetNextAddress.mockResolvedValue({ address: fakeAddress1 }) await transactionSender.generateWithdrawMultiSignTx(fakeWallet.id, fakeDepositOutPoint, fee, feeRate) @@ -701,7 +714,7 @@ describe('TransactionSender Test', () => { const fee = '1' const feeRate = '10' beforeEach(async () => { - stubbedGetLiveCell.mockResolvedValue(fakeCellWithStatus) + stubbedGetLiveCell.mockResolvedValue(fakeCellWithStatus.cell!.output) stubbedGetTransaction.mockResolvedValue(fakeTx1) stubbedGetHeader.mockResolvedValue(fakeDepositBlockHeader) stubbedGetNextChangeAddress.mockReturnValue({ @@ -727,7 +740,10 @@ describe('TransactionSender Test', () => { let tx: any beforeEach(async () => { - stubbedGetLiveCell.mockResolvedValue(fakeCellWithStatus) + const output = fakeCellWithStatus.cell!.output + output.daoData = '0x6400000000000000' + output.setDepositOutPoint(new OutPoint(`0x${'0'.repeat(64)}`, '0x0')) + stubbedGetLiveCell.mockResolvedValue(output) stubbedGetTransaction.mockResolvedValue(fakeTx1) stubbedGetBlockByNumber.mockResolvedValue({ header: { hash: '0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5' }, diff --git a/packages/neuron-wallet/tests/utils/ckb-rpc.test.ts b/packages/neuron-wallet/tests/utils/ckb-rpc.test.ts new file mode 100644 index 0000000000..a1eca58a5f --- /dev/null +++ b/packages/neuron-wallet/tests/utils/ckb-rpc.test.ts @@ -0,0 +1,19 @@ +import { FullCKBRPC, generateRPC, LightRPC } from '../../src/utils/ckb-rpc' +import { BUNDLED_LIGHT_CKB_URL, BUNDLED_CKB_URL } from '../../src/utils/const' + +describe('test ckb rpc file', () => { + describe('test generateRPC', () => { + it('url is light node', () => { + const result = generateRPC(BUNDLED_LIGHT_CKB_URL) + expect(result instanceof LightRPC).toBeTruthy() + }) + it('url is not light node', () => { + const result = generateRPC(BUNDLED_CKB_URL) + expect(result instanceof FullCKBRPC).toBeTruthy() + }) + it('url is https', () => { + const result = generateRPC('https://localhost:8114') + expect(result.node.httpsAgent).toBeDefined() + }) + }) +}) \ No newline at end of file diff --git a/packages/neuron-wallet/tests/utils/rpc-request.test.ts b/packages/neuron-wallet/tests/utils/rpc-request.test.ts index 82e7745a19..f93ad617b9 100644 --- a/packages/neuron-wallet/tests/utils/rpc-request.test.ts +++ b/packages/neuron-wallet/tests/utils/rpc-request.test.ts @@ -76,9 +76,6 @@ describe('rpc-request', () => { }, }) const res = await rpcRequest('url', option) - expect(res).toEqual({ - id: 2, - result: 2, - }) + expect(res).toEqual(2) }) }) diff --git a/scripts/download-ckb.sh b/scripts/download-ckb.sh index 179c541c82..335b0f36d2 100755 --- a/scripts/download-ckb.sh +++ b/scripts/download-ckb.sh @@ -1,6 +1,7 @@ #!/bin/bash CKB_VERSION=$(cat .ckb-version) +CKB_LIGHT_VERSION=$(cat .ckb-light-version) ROOT_DIR=$(pwd) # Be sure to run this from root directory! GITHUB_RELEASE_URL="https://github.com/nervosnetwork/ckb/releases/download" @@ -33,6 +34,18 @@ function download_macos_aarch64() { rm ${CKB_FILENAME}.zip } +function download_macos_light() { + # macOS + CKB_FILENAME="ckb-light-client_${CKB_LIGHT_VERSION}-x86_64-darwin-portable" + cd $ROOT_DIR/packages/neuron-wallet/bin/mac + + curl -O -L "https://github.com/nervosnetwork/ckb-light-client/releases/download/${CKB_LIGHT_VERSION}/${CKB_FILENAME}.tar.gz" + tar -xzvf ${CKB_FILENAME}.tar.gz + cp ./config/testnet.toml ../../light/ckb_light.toml + rm -rf ./config + rm ${CKB_FILENAME}.tar.gz +} + function download_linux() { # Linux CKB_FILENAME="ckb_${CKB_VERSION}_x86_64-unknown-linux-gnu-portable" @@ -45,6 +58,18 @@ function download_linux() { rm ${CKB_FILENAME}.tar.gz } +function download_linux_light() { + # macOS + CKB_FILENAME="ckb-light-client_${CKB_LIGHT_VERSION}-x86_64-linux-portable" + cd $ROOT_DIR/packages/neuron-wallet/bin/linux + + curl -O -L "https://github.com/nervosnetwork/ckb-light-client/releases/download/${CKB_LIGHT_VERSION}/${CKB_FILENAME}.tar.gz" + tar -xzvf ${CKB_FILENAME}.tar.gz + cp ./config/testnet.toml ../../light/ckb_light.toml + rm -rf ./config + rm ${CKB_FILENAME}.tar.gz +} + function download_windows() { # Windows CKB_FILENAME="ckb_${CKB_VERSION}_x86_64-pc-windows-msvc" @@ -57,17 +82,32 @@ function download_windows() { rm ${CKB_FILENAME}.zip } +function download_windows_light() { + # macOS + CKB_FILENAME="ckb-light-client_${CKB_LIGHT_VERSION}-x86_64-windows" + cd $ROOT_DIR/packages/neuron-wallet/bin/win + + curl -O -L "https://github.com/nervosnetwork/ckb-light-client/releases/download/${CKB_LIGHT_VERSION}/${CKB_FILENAME}.tar.gz" + tar -xzvf ${CKB_FILENAME}.tar.gz + cp ./config/testnet.toml ../../light/ckb_light.toml + rm -rf ./config + rm ${CKB_FILENAME}.tar.gz +} + case $1 in - mac) download_macos ;; - linux) download_linux ;; - win) download_windows ;; + mac) download_macos; download_macos_light;; + linux) download_linux; download_linux_light;; + win) download_windows; download_windows_light;; *) if [[ "$OSTYPE" == "darwin"* ]]; then download_macos + download_macos_light elif [[ "$OSTYPE" == "linux-gnu" ]]; then download_linux + download_linux_light else download_windows + download_windows_light fi ;; esac diff --git a/yarn.lock b/yarn.lock index a21335f56a..6cad60b959 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1286,6 +1286,20 @@ dependencies: jsbi "^4.1.0" +"@ckb-lumos/bi@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@ckb-lumos/bi/-/bi-0.19.0.tgz#ccd7e9e32e58eec7effc78cd171eb79f0f858d4a" + integrity sha512-+EFkUOqCtIwilAfrd680wEeMTXME9Wjjyl436+0x32jlbfo4sa+iNqwPaTjUQ2/SDwsuqK3eB+DntYxxFxoEtw== + dependencies: + jsbi "^4.1.0" + +"@ckb-lumos/codec@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@ckb-lumos/codec/-/codec-0.19.0.tgz#c9cda5e37fd5591abeaac9d5ed9577cfad206c09" + integrity sha512-MAts5rm5xLcvPslW6HwU/TlRoJ3jTtgsdtyakIa7qQo+0DlHdV6BOxTNDPtP3yqp8p5YFef7uCi/rcpi/KOGwA== + dependencies: + "@ckb-lumos/bi" "^0.19.0" + "@ckb-lumos/rpc@0.18.0": version "0.18.0" resolved "https://registry.yarnpkg.com/@ckb-lumos/rpc/-/rpc-0.18.0.tgz#3858b5eaa4b06a90d4a0931cc3168fab835f4a0c"