diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6f61978f..6f544b719d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# [0.17.0-alpha.2](https://github.com/nervosnetwork/neuron/compare/v0.17.0-alpha.1...v0.17.0-alpha.2) (2019-07-31) + + +### Bug Fixes + +* check input of lock hashes ([c76590c](https://github.com/nervosnetwork/neuron/commit/c76590c)) +* Connection "address" was not found in test ([c121a09](https://github.com/nervosnetwork/neuron/commit/c121a09)) + + +### Features + +* Only package AppImage for Linux ([c06b7bd](https://github.com/nervosnetwork/neuron/commit/c06b7bd)) +* **neuron-ui:** set message dismission duration to 8s ([a9a09e1](https://github.com/nervosnetwork/neuron/commit/a9a09e1)) +* **neuron-ui:** set page no to 1 on wallet switch ([9aa8d67](https://github.com/nervosnetwork/neuron/commit/9aa8d67)) +* **neuron-wallet:** add default parameters in getAllByKeywords method ([bfd9b0d](https://github.com/nervosnetwork/neuron/commit/bfd9b0d)) +* split tx service to multi files ([1dcf5e1](https://github.com/nervosnetwork/neuron/commit/1dcf5e1)) +* using new tx service and delete old ([00da601](https://github.com/nervosnetwork/neuron/commit/00da601)) + + + # [0.17.0-alpha.1](https://github.com/nervosnetwork/neuron/compare/v0.17.0-alpha.0...v0.17.0-alpha.1) (2019-07-30) diff --git a/lerna.json b/lerna.json index da06e996e3..0713a99436 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "packages": [ "packages/*" ], - "version": "0.17.0-alpha.1", + "version": "0.17.0-alpha.2", "npmClient": "yarn", "useWorkspaces": true } diff --git a/package.json b/package.json index 3b273d90ba..24e12fdb3b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "neuron", "productName": "Neuron", "description": "CKB Neuron Wallet", - "version": "0.17.0-alpha.1", + "version": "0.17.0-alpha.2", "private": true, "author": { "name": "Nervos Core Dev", @@ -10,8 +10,8 @@ "url": "https://github.com/nervosnetwork/neuron" }, "repository": { - "type" : "git", - "url" : "https://github.com/nervosnetwork/neuron" + "type": "git", + "url": "https://github.com/nervosnetwork/neuron" }, "license": "MIT", "engines": { diff --git a/packages/neuron-ui/package.json b/packages/neuron-ui/package.json index 6559afb741..ab479b2c47 100644 --- a/packages/neuron-ui/package.json +++ b/packages/neuron-ui/package.json @@ -1,6 +1,6 @@ { "name": "neuron-ui", - "version": "0.17.0-alpha.1", + "version": "0.17.0-alpha.2", "private": true, "author": { "name": "Nervos Core Dev", @@ -8,8 +8,8 @@ "url": "https://github.com/nervosnetwork/neuron" }, "repository": { - "type" : "git", - "url" : "https://github.com/nervosnetwork/neuron" + "type": "git", + "url": "https://github.com/nervosnetwork/neuron" }, "homepage": "./", "main": "./build", diff --git a/packages/neuron-ui/src/components/History/index.tsx b/packages/neuron-ui/src/components/History/index.tsx index c20f9cb48b..a47fa6266a 100644 --- a/packages/neuron-ui/src/components/History/index.tsx +++ b/packages/neuron-ui/src/components/History/index.tsx @@ -25,12 +25,12 @@ const History = ({ }: React.PropsWithoutRef) => { const [t] = useTranslation() - const { keywords, onKeywordsChange, setKeywords } = useSearch(search, id, dispatch) + const { keywords, onKeywordsChange } = useSearch(search, id, dispatch) useEffect(() => { if (id) { - setKeywords('') + history.push(`${Routes.History}?pageNo=1&keywords=${''}`) } - }, [id, setKeywords]) + }, [id, history]) const onSearch = useCallback(() => history.push(`${Routes.History}?keywords=${keywords}`), [history, keywords]) return ( diff --git a/packages/neuron-ui/src/containers/Notification/Notification.module.scss b/packages/neuron-ui/src/containers/Notification/Notification.module.scss index 36cfba1bc3..170518c8b6 100644 --- a/packages/neuron-ui/src/containers/Notification/Notification.module.scss +++ b/packages/neuron-ui/src/containers/Notification/Notification.module.scss @@ -8,7 +8,7 @@ &>div { max-width: auto; margin: 3px; - animation: autoDismiss 2.5s ease-out forwards; + animation: autoDismiss 6.8s ease-out forwards; transform-origin: center top; box-sizing: border-box; box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); @@ -20,8 +20,8 @@ transform: translateX(110%) } - 15%, - 85% { + 5%, + 90% { transform: translateX(0) } diff --git a/packages/neuron-ui/src/services/subjects.ts b/packages/neuron-ui/src/services/subjects.ts index 22b4e801a5..f18331f18e 100644 --- a/packages/neuron-ui/src/services/subjects.ts +++ b/packages/neuron-ui/src/services/subjects.ts @@ -30,7 +30,7 @@ const SubjectConstructor = ( }) return { unsubscribe: () => { - window.ipcRenderer.removeListener(channel, handler) + window.ipcRenderer.removeAllListeners(channel) }, } }, diff --git a/packages/neuron-ui/src/states/stateProvider/actionCreators/app.ts b/packages/neuron-ui/src/states/stateProvider/actionCreators/app.ts index ad6d14afd3..45eba47efb 100644 --- a/packages/neuron-ui/src/states/stateProvider/actionCreators/app.ts +++ b/packages/neuron-ui/src/states/stateProvider/actionCreators/app.ts @@ -95,7 +95,7 @@ export const addPopup = (text: string) => (dispatch: StateDispatch) => { type: AppActions.PopOut, payload: null, }) - }, 3000) + }, 8000) } export default { diff --git a/packages/neuron-ui/src/types/global/index.d.ts b/packages/neuron-ui/src/types/global/index.d.ts index 2148d10ccc..454588a6ed 100644 --- a/packages/neuron-ui/src/types/global/index.d.ts +++ b/packages/neuron-ui/src/types/global/index.d.ts @@ -12,6 +12,7 @@ declare interface Window { ipcRenderer: { on(channel: string, listener: Function) removeListener(channel: string, listener: Function) + removeAllListeners(channel: string) } } diff --git a/packages/neuron-wallet/electron-builder.yml b/packages/neuron-wallet/electron-builder.yml index 4db8d52eae..c25ae00356 100644 --- a/packages/neuron-wallet/electron-builder.yml +++ b/packages/neuron-wallet/electron-builder.yml @@ -68,4 +68,3 @@ linux: icon: assets/images/ target: - AppImage - - deb diff --git a/packages/neuron-wallet/package.json b/packages/neuron-wallet/package.json index 4d06628357..ef7b6e7751 100644 --- a/packages/neuron-wallet/package.json +++ b/packages/neuron-wallet/package.json @@ -3,7 +3,7 @@ "productName": "Neuron", "description": "CKB Neuron Wallet", "homepage": "https://www.nervos.org/", - "version": "0.17.0-alpha.1", + "version": "0.17.0-alpha.2", "private": true, "author": { "name": "Nervos Core Dev", @@ -11,8 +11,8 @@ "url": "https://github.com/nervosnetwork/neuron" }, "repository": { - "type" : "git", - "url" : "https://github.com/nervosnetwork/neuron" + "type": "git", + "url": "https://github.com/nervosnetwork/neuron" }, "main": "dist/main.js", "license": "MIT", @@ -64,7 +64,7 @@ "electron-devtools-installer": "2.2.4", "electron-notarize": "0.1.1", "lint-staged": "9.2.0", - "neuron-ui": "0.17.0-alpha.1", + "neuron-ui": "0.17.0-alpha.2", "rimraf": "2.6.3", "spectron": "7.0.0" } diff --git a/packages/neuron-wallet/src/controllers/transactions.ts b/packages/neuron-wallet/src/controllers/transactions.ts index d4242b4d92..63701acd40 100644 --- a/packages/neuron-wallet/src/controllers/transactions.ts +++ b/packages/neuron-wallet/src/controllers/transactions.ts @@ -1,5 +1,5 @@ import { Transaction } from '../types/cell-types' -import TransactionsService, { PaginationResult, TransactionsByLockHashesParam } from '../services/transactions' +import { TransactionsService, PaginationResult, TransactionsByLockHashesParam } from '../services/tx' import AddressesService from '../services/addresses' import WalletsService from '../services/wallets' @@ -35,7 +35,7 @@ export default class TransactionsController { public static async getAllByKeywords( params: Controller.Params.TransactionsByKeywords ): Promise & Controller.Params.TransactionsByKeywords>> { - const { pageNo, pageSize, keywords = '', walletID = '' } = params + const { pageNo = 1, pageSize = 15, keywords = '', walletID = '' } = params const addresses = (await AddressesService.allAddressesByWalletId(walletID)).map(addr => addr.address) diff --git a/packages/neuron-wallet/src/database/address/dao.ts b/packages/neuron-wallet/src/database/address/dao.ts index 65545dc56c..51defa6384 100644 --- a/packages/neuron-wallet/src/database/address/dao.ts +++ b/packages/neuron-wallet/src/database/address/dao.ts @@ -2,10 +2,11 @@ import { Not, In } from 'typeorm' import AddressEntity, { AddressVersion } from './entities/address' import { AddressType } from '../../models/keys/address' import { getConnection } from './ormconfig' -import TransactionsService, { OutputStatus } from '../../services/transactions' +import { TransactionsService } from '../../services/tx' import CellsService from '../../services/cells' import LockUtils from '../../models/lock-utils' import { TransactionStatus } from '../../types/cell-types' +import { OutputStatus } from '../../services/tx/params' export interface Address { walletId: string diff --git a/packages/neuron-wallet/src/database/chain/migrations/1562038960990-AddStatusToTx.ts b/packages/neuron-wallet/src/database/chain/migrations/1562038960990-AddStatusToTx.ts index 26842a61df..b8f91ce94f 100644 --- a/packages/neuron-wallet/src/database/chain/migrations/1562038960990-AddStatusToTx.ts +++ b/packages/neuron-wallet/src/database/chain/migrations/1562038960990-AddStatusToTx.ts @@ -1,6 +1,6 @@ import {MigrationInterface, QueryRunner, TableColumn, getConnection} from "typeorm"; import TransactionEntity from '../entities/transaction' -import { OutputStatus } from '../../../services/transactions' +import { OutputStatus } from '../../../services/tx/params' import { TransactionStatus } from '../../../types/cell-types' export class AddStatusToTx1562038960990 implements MigrationInterface { diff --git a/packages/neuron-wallet/src/listeners/tx-status.ts b/packages/neuron-wallet/src/listeners/tx-status.ts index 4723e4e6bd..99e9b25ba6 100644 --- a/packages/neuron-wallet/src/listeners/tx-status.ts +++ b/packages/neuron-wallet/src/listeners/tx-status.ts @@ -1,9 +1,9 @@ import { remote } from 'electron' import { interval } from 'rxjs' -import TransactionsService from '../services/transactions' import { TransactionStatus } from '../types/cell-types' import LockUtils from '../models/lock-utils' import AddressesUsedSubject from '../models/subjects/addresses-used-subject' +import { FailedTransaction } from '../services/tx' const { nodeService } = remote.require('./startup/sync-block-task/params') @@ -21,7 +21,7 @@ const getTransactionStatus = async (hash: string) => { } const trackingStatus = async () => { - const pendingTransactions = await TransactionsService.pendings() + const pendingTransactions = await FailedTransaction.pendings() if (!pendingTransactions.length) { return } @@ -39,7 +39,7 @@ const trackingStatus = async () => { if (!failedTxs.length) { return } - const blake160s = await TransactionsService.updateFailedTxs(failedTxs.map(tx => tx.hash)) + const blake160s = await FailedTransaction.updateFailedTxs(failedTxs.map(tx => tx.hash)) const usedAddresses = blake160s.map(blake160 => LockUtils.blake160ToAddress(blake160)) AddressesUsedSubject.getSubject().next(usedAddresses) } diff --git a/packages/neuron-wallet/src/services/cells.ts b/packages/neuron-wallet/src/services/cells.ts index 3084f46579..3bc1404190 100644 --- a/packages/neuron-wallet/src/services/cells.ts +++ b/packages/neuron-wallet/src/services/cells.ts @@ -2,7 +2,7 @@ import { getConnection, In } from 'typeorm' import OutputEntity from '../database/chain/entities/output' import { Cell, OutPoint, Input } from '../types/cell-types' import { CapacityNotEnough } from '../exceptions' -import { OutputStatus } from './transactions' +import { OutputStatus } from './tx/params' export const MIN_CELL_CAPACITY = '6000000000' diff --git a/packages/neuron-wallet/src/services/sync/check-and-save/tx.ts b/packages/neuron-wallet/src/services/sync/check-and-save/tx.ts index 1ee57a1871..067e20816d 100644 --- a/packages/neuron-wallet/src/services/sync/check-and-save/tx.ts +++ b/packages/neuron-wallet/src/services/sync/check-and-save/tx.ts @@ -2,7 +2,7 @@ import { getConnection } from 'typeorm' import { Subject } from 'rxjs' import { Transaction, Cell, OutPoint } from '../../../types/cell-types' import OutputEntity from '../../../database/chain/entities/output' -import TransactionsService from '../../transactions' +import { TransactionPersistor } from '../../tx' import CheckOutput from './output' import LockUtils from '../../../models/lock-utils' import { addressesUsedSubject as addressesUsedSubjectParam } from '../renderer-params' @@ -18,7 +18,7 @@ export default class CheckTx { public check = async (lockHashes: string[]): Promise => { const outputs: Cell[] = this.filterOutputs(lockHashes) - const inputAddresses = await this.filterInputs() + const inputAddresses = await this.filterInputs(lockHashes) const outputAddresses: string[] = outputs.map(output => { return LockUtils.lockScriptToAddress(output.lock) @@ -32,7 +32,7 @@ export default class CheckTx { public checkAndSave = async (lockHashes: string[]): Promise => { const addresses = await this.check(lockHashes) if (addresses.length > 0) { - await TransactionsService.saveFetchTx(this.tx) + await TransactionPersistor.saveFetchTx(this.tx) this.addressesUsedSubject.next(addresses) return true } @@ -48,24 +48,23 @@ export default class CheckTx { /* eslint no-await-in-loop: "off" */ /* eslint no-restricted-syntax: "warn" */ - public filterInputs = async (): Promise => { + public filterInputs = async (lockHashes: string[]): Promise => { const inputs = this.tx.inputs! const addresses: string[] = [] for (const input of inputs) { const outPoint: OutPoint = input.previousOutput const { cell } = outPoint - if (!cell) { - break - } - const output = await getConnection() - .getRepository(OutputEntity) - .findOne({ - outPointTxHash: cell.txHash, - outPointIndex: cell.index, - }) - if (output) { - addresses.push(LockUtils.lockScriptToAddress(output.lock)) + if (cell) { + const output = await getConnection() + .getRepository(OutputEntity) + .findOne({ + outPointTxHash: cell.txHash, + outPointIndex: cell.index, + }) + if (output && lockHashes.includes(output.lockHash)) { + addresses.push(LockUtils.lockScriptToAddress(output.lock)) + } } } diff --git a/packages/neuron-wallet/src/services/sync/queue.ts b/packages/neuron-wallet/src/services/sync/queue.ts index 8c36914e22..c8df609f0f 100644 --- a/packages/neuron-wallet/src/services/sync/queue.ts +++ b/packages/neuron-wallet/src/services/sync/queue.ts @@ -3,8 +3,8 @@ import { Block, BlockHeader } from '../../types/cell-types' import RangeForCheck from './range-for-check' import BlockNumber from './block-number' import Utils from './utils' -import TransactionsService from '../transactions' import QueueAdapter from './queue-adapter' +import { TransactionPersistor } from '../tx' export default class Queue { private q: any @@ -106,7 +106,7 @@ export default class Queue { const rangeFirstBlockHeader: BlockHeader = range[0] await this.currentBlockNumber.updateCurrent(BigInt(rangeFirstBlockHeader.number)) await this.rangeForCheck.setRange([]) - await TransactionsService.deleteWhenFork(rangeFirstBlockHeader.number) + await TransactionPersistor.deleteWhenFork(rangeFirstBlockHeader.number) await this.cleanQueue() this.startBlockNumber = await this.currentBlockNumber.getCurrent() this.batchPush() diff --git a/packages/neuron-wallet/src/services/transactions.ts b/packages/neuron-wallet/src/services/transactions.ts deleted file mode 100644 index 3bcc8c1698..0000000000 --- a/packages/neuron-wallet/src/services/transactions.ts +++ /dev/null @@ -1,687 +0,0 @@ -import { getConnection, In, ObjectLiteral } from 'typeorm' -import { ReplaySubject } from 'rxjs' -import { - OutPoint, - Transaction, - TransactionWithoutHash, - Input, - Cell, - TransactionStatus, - ScriptHashType, -} from '../types/cell-types' -import CellsService, { MIN_CELL_CAPACITY } from './cells' -import InputEntity from '../database/chain/entities/input' -import OutputEntity from '../database/chain/entities/output' -import TransactionEntity from '../database/chain/entities/transaction' -import NodeService from './node' -import LockUtils from '../models/lock-utils' -import { CapacityTooSmall } from '../exceptions' - -const { core } = NodeService.getInstance() - -export interface TransactionsByAddressesParam { - pageNo: number - pageSize: number - addresses: string[] -} - -export interface TransactionsByLockHashesParam { - pageNo: number - pageSize: number - lockHashes: string[] -} - -export interface TransactionsByPubkeysParams { - pageNo: number - pageSize: number - pubkeys: string[] -} - -export interface PaginationResult { - totalCount: number - items: T[] -} - -export interface TargetOutput { - address: string - capacity: string -} - -enum TxSaveType { - Sent = 'sent', - Fetch = 'fetch', -} - -export enum OutputStatus { - Sent = 'sent', - Live = 'live', - Pending = 'pending', - Dead = 'dead', - Failed = 'failed', -} - -export enum SearchType { - Address = 'address', - TxHash = 'txHash', - Date = 'date', - Amount = 'amount', - Empty = 'empty', - Unknown = 'unknown', -} - -/* eslint @typescript-eslint/no-unused-vars: "warn" */ -/* eslint no-await-in-loop: "off" */ -/* eslint no-restricted-syntax: "off" */ -export default class TransactionsService { - public static txSentSubject = new ReplaySubject<{ transaction: TransactionWithoutHash; txHash: string }>(100) - - public static filterSearchType = (value: string) => { - if (value === '') { - return SearchType.Empty - } - if (value.startsWith('ckb') || value.startsWith('ckt')) { - return SearchType.Address - } - if (value.startsWith('0x')) { - return SearchType.TxHash - } - // like '2019-02-09' - if (value.match(/\d{4}-\d{2}-\d{2}/)) { - return SearchType.Date - } - if (value.match(/^(\d+|-\d+)$/)) { - return SearchType.Amount - } - return SearchType.Unknown - } - - // only deal with address / txHash / Date - private static searchSQL = async (params: TransactionsByLockHashesParam, type: SearchType, value: string = '') => { - const base = [ - '(input.lockHash in (:...lockHashes) OR output.lockHash in (:...lockHashes))', - { lockHashes: params.lockHashes }, - ] - if (type === SearchType.Empty) { - return base - } - if (type === SearchType.Address) { - const lockHashes: string[] = await LockUtils.addressToAllLockHashes(value) - return ['input.lockHash IN (:...lockHashes) OR output.lockHash IN (:...lockHashes)', { lockHashes }] - } - if (type === SearchType.TxHash) { - return [`${base[0]} AND tx.hash = :hash`, { lockHashes: params.lockHashes, hash: value }] - } - if (type === SearchType.Date) { - const beginTimestamp = +new Date(value) - const endTimestamp = beginTimestamp + 86400000 // 24 * 60 * 60 * 1000 - return [ - `${ - base[0] - } AND (CAST(ifnull("tx"."timestamp", "tx"."createdAt") AS UNSIGNED BIG INT) >= :beginTimestamp AND CAST(ifnull("tx"."timestamp", "tx"."createdAt") AS UNSIGNED BIG INT) < :endTimestamp)`, - { - lockHashes: params.lockHashes, - beginTimestamp, - endTimestamp, - }, - ] - } - return base - } - - public static searchByAmount = async (params: TransactionsByLockHashesParam, amount: string) => { - // 1. get all transactions - const result = await TransactionsService.getAll({ - pageNo: 1, - pageSize: 100, - lockHashes: params.lockHashes, - }) - - let transactions = result.items - if (result.totalCount > 100) { - transactions = (await TransactionsService.getAll({ - pageNo: 1, - pageSize: result.totalCount, - lockHashes: params.lockHashes, - })).items - } - // 2. filter by value - const txs = transactions.filter(tx => tx.value === amount) - const skip = (params.pageNo - 1) * params.pageSize - return { - totalCount: txs.length || 0, - items: txs.slice(skip, skip + params.pageSize), - } - } - - public static getAll = async ( - params: TransactionsByLockHashesParam, - searchValue: string = '' - ): Promise> => { - const skip = (params.pageNo - 1) * params.pageSize - - const type = TransactionsService.filterSearchType(searchValue) - if (type === SearchType.Amount) { - return TransactionsService.searchByAmount(params, searchValue) - } - if (type === SearchType.Unknown) { - return { - totalCount: 0, - items: [], - } - } - const searchParams = await TransactionsService.searchSQL(params, type, searchValue) - - const query = getConnection() - .getRepository(TransactionEntity) - .createQueryBuilder('tx') - .addSelect(`ifnull('tx'.timestamp, 'tx'.createdAt)`, 'tt') - .leftJoinAndSelect('tx.inputs', 'input') - .leftJoinAndSelect('tx.outputs', 'output') - .where(searchParams[0], searchParams[1] as ObjectLiteral) - .orderBy(`tt`, 'DESC') - - const totalCount: number = await query.getCount() - - const transactions: TransactionEntity[] = await query - .skip(skip) - .take(params.pageSize) - .getMany() - - const txs: Transaction[] = transactions!.map(tx => { - const outputCapacities: bigint = tx.outputs - .filter(o => params.lockHashes.includes(o.lockHash)) - .map(o => BigInt(o.capacity)) - .reduce((result, c) => result + c, BigInt(0)) - const inputCapacities: bigint = tx.inputs - .filter(i => { - if (i.lockHash) { - return params.lockHashes.includes(i.lockHash) - } - return false - }) - .map(i => BigInt(i.capacity)) - .reduce((result, c) => result + c, BigInt(0)) - const value: bigint = outputCapacities - inputCapacities - return { - timestamp: tx.timestamp, - value: value.toString(), - hash: tx.hash, - version: tx.version, - type: value > BigInt(0) ? 'receive' : 'send', - status: tx.status, - description: tx.description, - createdAt: tx.createdAt, - updatedAt: tx.updatedAt, - } - }) - - return { - totalCount: totalCount || 0, - items: txs, - } - } - - public static getAllByAddresses = async ( - params: TransactionsByAddressesParam, - searchValue: string = '' - ): Promise> => { - const lockHashes: string[] = await LockUtils.addressesToAllLockHashes(params.addresses) - - return TransactionsService.getAll( - { - pageNo: params.pageNo, - pageSize: params.pageSize, - lockHashes, - }, - searchValue - ) - } - - public static getAllByPubkeys = async ( - params: TransactionsByPubkeysParams, - searchValue: string = '' - ): Promise> => { - const addresses: string[] = params.pubkeys.map(pubkey => { - const addr = core.utils.pubkeyToAddress(pubkey) - return addr - }) - - const lockHashes = await LockUtils.addressesToAllLockHashes(addresses) - - return TransactionsService.getAll( - { - pageNo: params.pageNo, - pageSize: params.pageSize, - lockHashes, - }, - searchValue - ) - } - - public static get = async (hash: string): Promise => { - const tx = await getConnection() - .getRepository(TransactionEntity) - .findOne(hash, { relations: ['inputs', 'outputs'] }) - - if (!tx) { - return undefined - } - - const transaction: Transaction = tx.toInterface() - - return transaction - } - - // After the tx is sent: - // 1. If the tx is not persisted before sending, output = sent, input = pending - // 2. If the tx is already persisted before sending, do nothing - public static saveWithSent = async (transaction: Transaction): Promise => { - const txEntity: TransactionEntity | undefined = await getConnection() - .getRepository(TransactionEntity) - .findOne(transaction.hash) - - if (txEntity) { - // nothing to do if exists already - return txEntity - } - return TransactionsService.create(transaction, OutputStatus.Sent, OutputStatus.Pending) - } - - // After the tx is fetched: - // 1. If the tx is not persisted before fetching, output = live, input = dead - // 2. If the tx is already persisted before fetching, output = live, input = dead - public static saveWithFetch = async (transaction: Transaction): Promise => { - const connection = getConnection() - const txEntity: TransactionEntity | undefined = await connection - .getRepository(TransactionEntity) - .findOne(transaction.hash, { relations: ['inputs', 'outputs'] }) - - // return if success - if (txEntity && txEntity.status === TransactionStatus.Success) { - return txEntity - } - - if (txEntity) { - // input -> previousOutput => dead - // output => live - const outputs: OutputEntity[] = await Promise.all( - txEntity.outputs.map(async o => { - const output = o - output.status = OutputStatus.Live - return output - }) - ) - - const previousOutputsWithUndefined: Array = await Promise.all( - txEntity.inputs.map(async input => { - const outPoint: OutPoint = input.previousOutput() - const { cell } = outPoint - - if (cell) { - const outputEntity: OutputEntity | undefined = await connection.getRepository(OutputEntity).findOne({ - outPointTxHash: cell.txHash, - outPointIndex: cell.index, - }) - if (outputEntity) { - outputEntity.status = OutputStatus.Dead - } - return outputEntity - } - return undefined - }) - ) - - const previousOutputs: OutputEntity[] = previousOutputsWithUndefined.filter(o => !!o) as OutputEntity[] - - // should update timestamp / blockNumber / blockHash / status - txEntity.timestamp = transaction.timestamp - txEntity.blockHash = transaction.blockHash - txEntity.blockNumber = transaction.blockNumber - txEntity.status = TransactionStatus.Success - await connection.manager.save([txEntity, ...outputs.concat(previousOutputs)]) - - return txEntity - } - - return TransactionsService.create(transaction, OutputStatus.Live, OutputStatus.Dead) - } - - // only create, check exist before this - public static create = async ( - transaction: Transaction, - outputStatus: OutputStatus, - inputStatus: OutputStatus - ): Promise => { - const connection = getConnection() - const tx = new TransactionEntity() - tx.hash = transaction.hash - tx.version = transaction.version - tx.deps = transaction.deps! - tx.timestamp = transaction.timestamp! - tx.blockHash = transaction.blockHash! - tx.blockNumber = transaction.blockNumber! - tx.witnesses = transaction.witnesses! - tx.description = transaction.description - // update tx status here - tx.status = outputStatus === OutputStatus.Sent ? TransactionStatus.Pending : TransactionStatus.Success - tx.inputs = [] - tx.outputs = [] - const inputs: InputEntity[] = [] - const previousOutputs: OutputEntity[] = [] - for (const i of transaction.inputs!) { - const input = new InputEntity() - const { cell } = i.previousOutput - if (cell) { - input.outPointTxHash = cell.txHash - input.outPointIndex = cell.index - } - input.transaction = tx - input.capacity = i.capacity || null - input.lockHash = i.lockHash || null - input.since = i.since! - inputs.push(input) - - if (cell) { - const previousOutput: OutputEntity | undefined = await connection.getRepository(OutputEntity).findOne({ - outPointTxHash: input.previousOutput().cell!.txHash, - outPointIndex: input.previousOutput().cell!.index, - }) - - if (previousOutput) { - // update previousOutput status here - previousOutput.status = inputStatus - previousOutputs.push(previousOutput) - } - } - } - - const outputs: OutputEntity[] = await Promise.all( - transaction.outputs!.map(async (o, index) => { - const output = new OutputEntity() - output.outPointTxHash = transaction.hash - output.outPointIndex = index.toString() - output.capacity = o.capacity - output.lock = o.lock - output.lockHash = o.lockHash! - output.transaction = tx - output.status = outputStatus - return output - }) - ) - - await connection.manager.save([tx, ...inputs, ...previousOutputs, ...outputs]) - return tx - } - - public static deleteWhenFork = async (blockNumber: string) => { - const txs = await getConnection() - .getRepository(TransactionEntity) - .createQueryBuilder('tx') - .where( - 'CAST(tx.blockNumber AS UNSIGNED BIG INT) > CAST(:blockNumber AS UNSIGNED BIG INT) AND tx.status = :status', - { - blockNumber, - status: TransactionStatus.Success, - } - ) - .getMany() - return getConnection().manager.remove(txs) - } - - // update previousOutput's status to 'dead' if found - // calculate output lockHash, input lockHash and capacity - // when send a transaction, use TxSaveType.Sent - // when fetch a transaction, use TxSaveType.Fetch - public static convertTransactionAndSave = async ( - transaction: Transaction, - saveType: TxSaveType - ): Promise => { - const tx: Transaction = transaction - tx.outputs = tx.outputs!.map(o => { - const output = o - output.lockHash = LockUtils.lockScriptToHash(output.lock!) - return output - }) - - tx.inputs = await Promise.all( - tx.inputs!.map(async i => { - const input: Input = i - const { cell } = i.previousOutput - - if (cell) { - const outputEntity: OutputEntity | undefined = await getConnection() - .getRepository(OutputEntity) - .findOne({ - outPointTxHash: cell.txHash, - outPointIndex: cell.index, - }) - if (outputEntity) { - input.capacity = outputEntity.capacity - input.lockHash = outputEntity.lockHash - } - } - return input - }) - ) - let txEntity: TransactionEntity - if (saveType === TxSaveType.Sent) { - txEntity = await TransactionsService.saveWithSent(transaction) - } else if (saveType === TxSaveType.Fetch) { - txEntity = await TransactionsService.saveWithFetch(transaction) - } else { - throw new Error('Error TxSaveType!') - } - return txEntity - } - - public static saveFetchTx = async (transaction: Transaction): Promise => { - const txEntity: TransactionEntity = await TransactionsService.convertTransactionAndSave( - transaction, - TxSaveType.Fetch - ) - return txEntity - } - - public static saveSentTx = async ( - transaction: TransactionWithoutHash, - txHash: string - ): Promise => { - const tx: Transaction = { - hash: txHash, - ...transaction, - } - const txEntity: TransactionEntity = await TransactionsService.convertTransactionAndSave(tx, TxSaveType.Sent) - return txEntity - } - - // lockHashes for each inputs - public static generateTx = async ( - lockHashes: string[], - targetOutputs: TargetOutput[], - changeAddress: string, - fee: string = '0' - ): Promise => { - const { codeHash, outPoint } = await LockUtils.systemScript() - - const needCapacities: bigint = targetOutputs - .map(o => BigInt(o.capacity)) - .reduce((result, c) => result + c, BigInt(0)) - - const minCellCapacity = BigInt(MIN_CELL_CAPACITY) - - const outputs: Cell[] = targetOutputs.map(o => { - const { capacity, address } = o - - if (BigInt(capacity) < minCellCapacity) { - throw new CapacityTooSmall() - } - - const blake160: string = LockUtils.addressToBlake160(address) - - const output: Cell = { - capacity, - data: '0x', - lock: { - codeHash, - args: [blake160], - hashType: ScriptHashType.Data, - }, - } - - return output - }) - - const { inputs, capacities } = await CellsService.gatherInputs(needCapacities.toString(), lockHashes, fee) - - // change - if (BigInt(capacities) > needCapacities + BigInt(fee)) { - const changeBlake160: string = LockUtils.addressToBlake160(changeAddress) - - const output: Cell = { - capacity: `${BigInt(capacities) - needCapacities - BigInt(fee)}`, - data: '0x', - lock: { - codeHash, - args: [changeBlake160], - hashType: ScriptHashType.Data, - }, - } - - outputs.push(output) - } - - return { - version: '0', - deps: [outPoint], - inputs, - outputs, - witnesses: [], - } - } - - public static blake160sOfTx = (tx: TransactionWithoutHash | Transaction) => { - let inputBlake160s: string[] = [] - let outputBlake160s: string[] = [] - if (tx.inputs) { - inputBlake160s = tx.inputs - .map(input => input.lock && input.lock.args && input.lock.args[0]) - .filter(blake160 => blake160) as string[] - } - if (tx.outputs) { - outputBlake160s = tx.outputs.map(output => output.lock.args![0]) - } - return [...new Set(inputBlake160s.concat(outputBlake160s))] - } - - // tx count with one lockHash and status - public static getCountByLockHashesAndStatus = async ( - lockHashes: string[], - status: TransactionStatus[] - ): Promise => { - const count: number = await getConnection() - .getRepository(TransactionEntity) - .createQueryBuilder('tx') - .leftJoinAndSelect('tx.inputs', 'input') - .leftJoinAndSelect('tx.outputs', 'output') - .where( - `(input.lockHash IN (:...lockHashes) OR output.lockHash IN (:...lockHashes)) AND tx.status IN (:...status)`, - { - lockHashes, - status, - } - ) - .getCount() - - return count - } - - public static getCountByAddressAndStatus = async (address: string, status: TransactionStatus[]): Promise => { - const lockHashes: string[] = await LockUtils.addressToAllLockHashes(address) - return TransactionsService.getCountByLockHashesAndStatus(lockHashes, status) - } - - public static pendings = async (): Promise => { - const pendingTransactions = await getConnection() - .getRepository(TransactionEntity) - .createQueryBuilder('tx') - .where({ - status: TransactionStatus.Pending, - }) - .getMany() - - return pendingTransactions - } - - // update tx status to TransactionStatus.Failed - // update outputs status to OutputStatus.Failed - // update previous outputs (inputs) to OutputStatus.Live - public static updateFailedTxs = async (hashes: string[]) => { - const txs = await getConnection() - .getRepository(TransactionEntity) - .createQueryBuilder('tx') - .leftJoinAndSelect('tx.inputs', 'input') - .leftJoinAndSelect('tx.outputs', 'output') - .where({ - hash: In(hashes), - status: TransactionStatus.Pending, - }) - .getMany() - - const txToUpdate = txs.map(tx => { - const transaction = tx - transaction.status = TransactionStatus.Failed - return transaction - }) - const allOutputs = txs - .map(tx => tx.outputs) - .reduce((acc, val) => acc.concat(val), []) - .map(o => { - const output = o - output.status = OutputStatus.Failed - return output - }) - const allInputs = txs.map(tx => tx.inputs).reduce((acc, val) => acc.concat(val), []) - const previousOutputs = await Promise.all( - allInputs.map(async input => { - const output = await getConnection() - .getRepository(OutputEntity) - .createQueryBuilder('output') - .where({ - outPointTxHash: input.outPointTxHash, - outPointIndex: input.outPointIndex, - }) - .getOne() - if (output) { - output.status = OutputStatus.Live - } - return output - }) - ) - const previous = previousOutputs.filter(output => output) as OutputEntity[] - await getConnection().manager.save([...txToUpdate, ...allOutputs, ...previous]) - const blake160s = txs.map(tx => TransactionsService.blake160sOfTx(tx.toInterface())) - const uniqueBlake160s = [...new Set(blake160s.reduce((acc, val) => acc.concat(val), []))] - return uniqueBlake160s - } - - public static updateDescription = async (hash: string, description: string) => { - const transactionEntity = await getConnection() - .getRepository(TransactionEntity) - .createQueryBuilder('tx') - .where({ - hash, - }) - .getOne() - - if (!transactionEntity) { - return undefined - } - transactionEntity.description = description - return getConnection().manager.save(transactionEntity) - } -} - -// listen to send tx event -TransactionsService.txSentSubject.subscribe(msg => { - TransactionsService.saveSentTx(msg.transaction, msg.txHash) -}) diff --git a/packages/neuron-wallet/src/services/tx/failed-transaction.ts b/packages/neuron-wallet/src/services/tx/failed-transaction.ts new file mode 100644 index 0000000000..ecf71f9e52 --- /dev/null +++ b/packages/neuron-wallet/src/services/tx/failed-transaction.ts @@ -0,0 +1,74 @@ +import { getConnection, In } from 'typeorm' +import { TransactionStatus } from '../../types/cell-types' +import OutputEntity from '../../database/chain/entities/output' +import TransactionEntity from '../../database/chain/entities/transaction' +import { OutputStatus } from './params' +import TransactionsService from './transaction-service' + +export class FailedTransaction { + public static pendings = async (): Promise => { + const pendingTransactions = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .where({ + status: TransactionStatus.Pending, + }) + .getMany() + + return pendingTransactions + } + + // update tx status to TransactionStatus.Failed + // update outputs status to OutputStatus.Failed + // update previous outputs (inputs) to OutputStatus.Live + public static updateFailedTxs = async (hashes: string[]) => { + const txs = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .leftJoinAndSelect('tx.inputs', 'input') + .leftJoinAndSelect('tx.outputs', 'output') + .where({ + hash: In(hashes), + status: TransactionStatus.Pending, + }) + .getMany() + + const txToUpdate = txs.map(tx => { + const transaction = tx + transaction.status = TransactionStatus.Failed + return transaction + }) + const allOutputs = txs + .map(tx => tx.outputs) + .reduce((acc, val) => acc.concat(val), []) + .map(o => { + const output = o + output.status = OutputStatus.Failed + return output + }) + const allInputs = txs.map(tx => tx.inputs).reduce((acc, val) => acc.concat(val), []) + const previousOutputs = await Promise.all( + allInputs.map(async input => { + const output = await getConnection() + .getRepository(OutputEntity) + .createQueryBuilder('output') + .where({ + outPointTxHash: input.outPointTxHash, + outPointIndex: input.outPointIndex, + }) + .getOne() + if (output) { + output.status = OutputStatus.Live + } + return output + }) + ) + const previous = previousOutputs.filter(output => output) as OutputEntity[] + await getConnection().manager.save([...txToUpdate, ...allOutputs, ...previous]) + const blake160s = txs.map(tx => TransactionsService.blake160sOfTx(tx.toInterface())) + const uniqueBlake160s = [...new Set(blake160s.reduce((acc, val) => acc.concat(val), []))] + return uniqueBlake160s + } +} + +export default FailedTransaction diff --git a/packages/neuron-wallet/src/services/tx/index.ts b/packages/neuron-wallet/src/services/tx/index.ts new file mode 100644 index 0000000000..fb43d7afe2 --- /dev/null +++ b/packages/neuron-wallet/src/services/tx/index.ts @@ -0,0 +1,4 @@ +export * from './transaction-service' +export * from './transaction-persistor' +export * from './transaction-generator' +export * from './failed-transaction' diff --git a/packages/neuron-wallet/src/services/tx/params.ts b/packages/neuron-wallet/src/services/tx/params.ts new file mode 100644 index 0000000000..ecaee83103 --- /dev/null +++ b/packages/neuron-wallet/src/services/tx/params.ts @@ -0,0 +1,17 @@ +export interface TargetOutput { + address: string + capacity: string +} + +export enum TxSaveType { + Sent = 'sent', + Fetch = 'fetch', +} + +export enum OutputStatus { + Sent = 'sent', + Live = 'live', + Pending = 'pending', + Dead = 'dead', + Failed = 'failed', +} diff --git a/packages/neuron-wallet/src/services/tx/transaction-generator.ts b/packages/neuron-wallet/src/services/tx/transaction-generator.ts new file mode 100644 index 0000000000..c125ac3848 --- /dev/null +++ b/packages/neuron-wallet/src/services/tx/transaction-generator.ts @@ -0,0 +1,74 @@ +import { TransactionWithoutHash, Cell, ScriptHashType } from '../../types/cell-types' +import CellsService, { MIN_CELL_CAPACITY } from '../cells' +import LockUtils from '../../models/lock-utils' +import { CapacityTooSmall } from '../../exceptions' +import { TargetOutput } from './params' + +export class TransactionGenerator { + // lockHashes for each inputs + public static generateTx = async ( + lockHashes: string[], + targetOutputs: TargetOutput[], + changeAddress: string, + fee: string = '0' + ): Promise => { + const { codeHash, outPoint } = await LockUtils.systemScript() + + const needCapacities: bigint = targetOutputs + .map(o => BigInt(o.capacity)) + .reduce((result, c) => result + c, BigInt(0)) + + const minCellCapacity = BigInt(MIN_CELL_CAPACITY) + + const outputs: Cell[] = targetOutputs.map(o => { + const { capacity, address } = o + + if (BigInt(capacity) < minCellCapacity) { + throw new CapacityTooSmall() + } + + const blake160: string = LockUtils.addressToBlake160(address) + + const output: Cell = { + capacity, + data: '0x', + lock: { + codeHash, + args: [blake160], + hashType: ScriptHashType.Data, + }, + } + + return output + }) + + const { inputs, capacities } = await CellsService.gatherInputs(needCapacities.toString(), lockHashes, fee) + + // change + if (BigInt(capacities) > needCapacities + BigInt(fee)) { + const changeBlake160: string = LockUtils.addressToBlake160(changeAddress) + + const output: Cell = { + capacity: `${BigInt(capacities) - needCapacities - BigInt(fee)}`, + data: '0x', + lock: { + codeHash, + args: [changeBlake160], + hashType: ScriptHashType.Data, + }, + } + + outputs.push(output) + } + + return { + version: '0', + deps: [outPoint], + inputs, + outputs, + witnesses: [], + } + } +} + +export default TransactionGenerator diff --git a/packages/neuron-wallet/src/services/tx/transaction-persistor.ts b/packages/neuron-wallet/src/services/tx/transaction-persistor.ts new file mode 100644 index 0000000000..f50bb2d8ce --- /dev/null +++ b/packages/neuron-wallet/src/services/tx/transaction-persistor.ts @@ -0,0 +1,236 @@ +import { getConnection } from 'typeorm' +import { OutPoint, Transaction, TransactionWithoutHash, Input, TransactionStatus } from '../../types/cell-types' +import InputEntity from '../../database/chain/entities/input' +import OutputEntity from '../../database/chain/entities/output' +import TransactionEntity from '../../database/chain/entities/transaction' +import LockUtils from '../../models/lock-utils' +import { OutputStatus, TxSaveType } from './params' + +/* eslint @typescript-eslint/no-unused-vars: "warn" */ +/* eslint no-await-in-loop: "off" */ +/* eslint no-restricted-syntax: "off" */ +export class TransactionPersistor { + // After the tx is sent: + // 1. If the tx is not persisted before sending, output = sent, input = pending + // 2. If the tx is already persisted before sending, do nothing + public static saveWithSent = async (transaction: Transaction): Promise => { + const txEntity: TransactionEntity | undefined = await getConnection() + .getRepository(TransactionEntity) + .findOne(transaction.hash) + + if (txEntity) { + // nothing to do if exists already + return txEntity + } + return TransactionPersistor.create(transaction, OutputStatus.Sent, OutputStatus.Pending) + } + + // After the tx is fetched: + // 1. If the tx is not persisted before fetching, output = live, input = dead + // 2. If the tx is already persisted before fetching, output = live, input = dead + public static saveWithFetch = async (transaction: Transaction): Promise => { + const connection = getConnection() + const txEntity: TransactionEntity | undefined = await connection + .getRepository(TransactionEntity) + .findOne(transaction.hash, { relations: ['inputs', 'outputs'] }) + + // return if success + if (txEntity && txEntity.status === TransactionStatus.Success) { + return txEntity + } + + if (txEntity) { + // input -> previousOutput => dead + // output => live + const outputs: OutputEntity[] = await Promise.all( + txEntity.outputs.map(async o => { + const output = o + output.status = OutputStatus.Live + return output + }) + ) + + const previousOutputsWithUndefined: Array = await Promise.all( + txEntity.inputs.map(async input => { + const outPoint: OutPoint = input.previousOutput() + const { cell } = outPoint + + if (cell) { + const outputEntity: OutputEntity | undefined = await connection.getRepository(OutputEntity).findOne({ + outPointTxHash: cell.txHash, + outPointIndex: cell.index, + }) + if (outputEntity) { + outputEntity.status = OutputStatus.Dead + } + return outputEntity + } + return undefined + }) + ) + + const previousOutputs: OutputEntity[] = previousOutputsWithUndefined.filter(o => !!o) as OutputEntity[] + + // should update timestamp / blockNumber / blockHash / status + txEntity.timestamp = transaction.timestamp + txEntity.blockHash = transaction.blockHash + txEntity.blockNumber = transaction.blockNumber + txEntity.status = TransactionStatus.Success + await connection.manager.save([txEntity, ...outputs.concat(previousOutputs)]) + + return txEntity + } + + return TransactionPersistor.create(transaction, OutputStatus.Live, OutputStatus.Dead) + } + + // only create, check exist before this + public static create = async ( + transaction: Transaction, + outputStatus: OutputStatus, + inputStatus: OutputStatus + ): Promise => { + const connection = getConnection() + const tx = new TransactionEntity() + tx.hash = transaction.hash + tx.version = transaction.version + tx.deps = transaction.deps! + tx.timestamp = transaction.timestamp! + tx.blockHash = transaction.blockHash! + tx.blockNumber = transaction.blockNumber! + tx.witnesses = transaction.witnesses! + tx.description = transaction.description + // update tx status here + tx.status = outputStatus === OutputStatus.Sent ? TransactionStatus.Pending : TransactionStatus.Success + tx.inputs = [] + tx.outputs = [] + const inputs: InputEntity[] = [] + const previousOutputs: OutputEntity[] = [] + for (const i of transaction.inputs!) { + const input = new InputEntity() + const { cell } = i.previousOutput + if (cell) { + input.outPointTxHash = cell.txHash + input.outPointIndex = cell.index + } + input.transaction = tx + input.capacity = i.capacity || null + input.lockHash = i.lockHash || null + input.since = i.since! + inputs.push(input) + + if (cell) { + const previousOutput: OutputEntity | undefined = await connection.getRepository(OutputEntity).findOne({ + outPointTxHash: input.previousOutput().cell!.txHash, + outPointIndex: input.previousOutput().cell!.index, + }) + + if (previousOutput) { + // update previousOutput status here + previousOutput.status = inputStatus + previousOutputs.push(previousOutput) + } + } + } + + const outputs: OutputEntity[] = await Promise.all( + transaction.outputs!.map(async (o, index) => { + const output = new OutputEntity() + output.outPointTxHash = transaction.hash + output.outPointIndex = index.toString() + output.capacity = o.capacity + output.lock = o.lock + output.lockHash = o.lockHash! + output.transaction = tx + output.status = outputStatus + return output + }) + ) + + await connection.manager.save([tx, ...inputs, ...previousOutputs, ...outputs]) + return tx + } + + public static deleteWhenFork = async (blockNumber: string) => { + const txs = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .where( + 'CAST(tx.blockNumber AS UNSIGNED BIG INT) > CAST(:blockNumber AS UNSIGNED BIG INT) AND tx.status = :status', + { + blockNumber, + status: TransactionStatus.Success, + } + ) + .getMany() + return getConnection().manager.remove(txs) + } + + // update previousOutput's status to 'dead' if found + // calculate output lockHash, input lockHash and capacity + // when send a transaction, use TxSaveType.Sent + // when fetch a transaction, use TxSaveType.Fetch + public static convertTransactionAndSave = async ( + transaction: Transaction, + saveType: TxSaveType + ): Promise => { + const tx: Transaction = transaction + tx.outputs = tx.outputs!.map(o => { + const output = o + output.lockHash = LockUtils.lockScriptToHash(output.lock!) + return output + }) + + tx.inputs = await Promise.all( + tx.inputs!.map(async i => { + const input: Input = i + const { cell } = i.previousOutput + + if (cell) { + const outputEntity: OutputEntity | undefined = await getConnection() + .getRepository(OutputEntity) + .findOne({ + outPointTxHash: cell.txHash, + outPointIndex: cell.index, + }) + if (outputEntity) { + input.capacity = outputEntity.capacity + input.lockHash = outputEntity.lockHash + } + } + return input + }) + ) + let txEntity: TransactionEntity + if (saveType === TxSaveType.Sent) { + txEntity = await TransactionPersistor.saveWithSent(transaction) + } else if (saveType === TxSaveType.Fetch) { + txEntity = await TransactionPersistor.saveWithFetch(transaction) + } else { + throw new Error('Error TxSaveType!') + } + return txEntity + } + + public static saveFetchTx = async (transaction: Transaction): Promise => { + const txEntity: TransactionEntity = await TransactionPersistor.convertTransactionAndSave( + transaction, + TxSaveType.Fetch + ) + return txEntity + } + + public static saveSentTx = async ( + transaction: TransactionWithoutHash, + txHash: string + ): Promise => { + const tx: Transaction = { + hash: txHash, + ...transaction, + } + const txEntity: TransactionEntity = await TransactionPersistor.convertTransactionAndSave(tx, TxSaveType.Sent) + return txEntity + } +} + +export default TransactionPersistor diff --git a/packages/neuron-wallet/src/services/tx/transaction-service.ts b/packages/neuron-wallet/src/services/tx/transaction-service.ts new file mode 100644 index 0000000000..5ae994eed9 --- /dev/null +++ b/packages/neuron-wallet/src/services/tx/transaction-service.ts @@ -0,0 +1,298 @@ +import { getConnection, ObjectLiteral } from 'typeorm' +import { pubkeyToAddress } from '@nervosnetwork/ckb-sdk-utils' +import { Transaction, TransactionWithoutHash, TransactionStatus } from '../../types/cell-types' +import TransactionEntity from '../../database/chain/entities/transaction' +import LockUtils from '../../models/lock-utils' + +export interface TransactionsByAddressesParam { + pageNo: number + pageSize: number + addresses: string[] +} + +export interface TransactionsByLockHashesParam { + pageNo: number + pageSize: number + lockHashes: string[] +} + +export interface TransactionsByPubkeysParams { + pageNo: number + pageSize: number + pubkeys: string[] +} + +export interface PaginationResult { + totalCount: number + items: T[] +} + +export enum SearchType { + Address = 'address', + TxHash = 'txHash', + Date = 'date', + Amount = 'amount', + Empty = 'empty', + Unknown = 'unknown', +} + +/* eslint @typescript-eslint/no-unused-vars: "warn" */ +/* eslint no-await-in-loop: "off" */ +/* eslint no-restricted-syntax: "off" */ +export class TransactionsService { + public static filterSearchType = (value: string) => { + if (value === '') { + return SearchType.Empty + } + if (value.startsWith('ckb') || value.startsWith('ckt')) { + return SearchType.Address + } + if (value.startsWith('0x')) { + return SearchType.TxHash + } + // like '2019-02-09' + if (value.match(/\d{4}-\d{2}-\d{2}/)) { + return SearchType.Date + } + if (value.match(/^(\d+|-\d+)$/)) { + return SearchType.Amount + } + return SearchType.Unknown + } + + // only deal with address / txHash / Date + private static searchSQL = async (params: TransactionsByLockHashesParam, type: SearchType, value: string = '') => { + const base = [ + '(input.lockHash in (:...lockHashes) OR output.lockHash in (:...lockHashes))', + { lockHashes: params.lockHashes }, + ] + if (type === SearchType.Empty) { + return base + } + if (type === SearchType.Address) { + const lockHashes: string[] = await LockUtils.addressToAllLockHashes(value) + return ['input.lockHash IN (:...lockHashes) OR output.lockHash IN (:...lockHashes)', { lockHashes }] + } + if (type === SearchType.TxHash) { + return [`${base[0]} AND tx.hash = :hash`, { lockHashes: params.lockHashes, hash: value }] + } + if (type === SearchType.Date) { + const beginTimestamp = +new Date(value) + const endTimestamp = beginTimestamp + 86400000 // 24 * 60 * 60 * 1000 + return [ + `${ + base[0] + } AND (CAST(ifnull("tx"."timestamp", "tx"."createdAt") AS UNSIGNED BIG INT) >= :beginTimestamp AND CAST(ifnull("tx"."timestamp", "tx"."createdAt") AS UNSIGNED BIG INT) < :endTimestamp)`, + { + lockHashes: params.lockHashes, + beginTimestamp, + endTimestamp, + }, + ] + } + return base + } + + public static searchByAmount = async (params: TransactionsByLockHashesParam, amount: string) => { + // 1. get all transactions + const result = await TransactionsService.getAll({ + pageNo: 1, + pageSize: 100, + lockHashes: params.lockHashes, + }) + + let transactions = result.items + if (result.totalCount > 100) { + transactions = (await TransactionsService.getAll({ + pageNo: 1, + pageSize: result.totalCount, + lockHashes: params.lockHashes, + })).items + } + // 2. filter by value + const txs = transactions.filter(tx => tx.value === amount) + const skip = (params.pageNo - 1) * params.pageSize + return { + totalCount: txs.length || 0, + items: txs.slice(skip, skip + params.pageSize), + } + } + + public static getAll = async ( + params: TransactionsByLockHashesParam, + searchValue: string = '' + ): Promise> => { + const skip = (params.pageNo - 1) * params.pageSize + + const type = TransactionsService.filterSearchType(searchValue) + if (type === SearchType.Amount) { + return TransactionsService.searchByAmount(params, searchValue) + } + if (type === SearchType.Unknown) { + return { + totalCount: 0, + items: [], + } + } + const searchParams = await TransactionsService.searchSQL(params, type, searchValue) + + const query = getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .addSelect(`ifnull('tx'.timestamp, 'tx'.createdAt)`, 'tt') + .leftJoinAndSelect('tx.inputs', 'input') + .leftJoinAndSelect('tx.outputs', 'output') + .where(searchParams[0], searchParams[1] as ObjectLiteral) + .orderBy(`tt`, 'DESC') + + const totalCount: number = await query.getCount() + + const transactions: TransactionEntity[] = await query + .skip(skip) + .take(params.pageSize) + .getMany() + + const txs: Transaction[] = transactions!.map(tx => { + const outputCapacities: bigint = tx.outputs + .filter(o => params.lockHashes.includes(o.lockHash)) + .map(o => BigInt(o.capacity)) + .reduce((result, c) => result + c, BigInt(0)) + const inputCapacities: bigint = tx.inputs + .filter(i => { + if (i.lockHash) { + return params.lockHashes.includes(i.lockHash) + } + return false + }) + .map(i => BigInt(i.capacity)) + .reduce((result, c) => result + c, BigInt(0)) + const value: bigint = outputCapacities - inputCapacities + return { + timestamp: tx.timestamp, + value: value.toString(), + hash: tx.hash, + version: tx.version, + type: value > BigInt(0) ? 'receive' : 'send', + status: tx.status, + description: tx.description, + createdAt: tx.createdAt, + updatedAt: tx.updatedAt, + } + }) + + return { + totalCount: totalCount || 0, + items: txs, + } + } + + public static getAllByAddresses = async ( + params: TransactionsByAddressesParam, + searchValue: string = '' + ): Promise> => { + const lockHashes: string[] = await LockUtils.addressesToAllLockHashes(params.addresses) + + return TransactionsService.getAll( + { + pageNo: params.pageNo, + pageSize: params.pageSize, + lockHashes, + }, + searchValue + ) + } + + public static getAllByPubkeys = async ( + params: TransactionsByPubkeysParams, + searchValue: string = '' + ): Promise> => { + const addresses: string[] = params.pubkeys.map(pubkey => { + const addr = pubkeyToAddress(pubkey) + return addr + }) + + const lockHashes = await LockUtils.addressesToAllLockHashes(addresses) + + return TransactionsService.getAll( + { + pageNo: params.pageNo, + pageSize: params.pageSize, + lockHashes, + }, + searchValue + ) + } + + public static get = async (hash: string): Promise => { + const tx = await getConnection() + .getRepository(TransactionEntity) + .findOne(hash, { relations: ['inputs', 'outputs'] }) + + if (!tx) { + return undefined + } + + const transaction: Transaction = tx.toInterface() + + return transaction + } + + public static blake160sOfTx = (tx: TransactionWithoutHash | Transaction) => { + let inputBlake160s: string[] = [] + let outputBlake160s: string[] = [] + if (tx.inputs) { + inputBlake160s = tx.inputs + .map(input => input.lock && input.lock.args && input.lock.args[0]) + .filter(blake160 => blake160) as string[] + } + if (tx.outputs) { + outputBlake160s = tx.outputs.map(output => output.lock.args![0]) + } + return [...new Set(inputBlake160s.concat(outputBlake160s))] + } + + // tx count with one lockHash and status + public static getCountByLockHashesAndStatus = async ( + lockHashes: string[], + status: TransactionStatus[] + ): Promise => { + const count: number = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .leftJoinAndSelect('tx.inputs', 'input') + .leftJoinAndSelect('tx.outputs', 'output') + .where( + `(input.lockHash IN (:...lockHashes) OR output.lockHash IN (:...lockHashes)) AND tx.status IN (:...status)`, + { + lockHashes, + status, + } + ) + .getCount() + + return count + } + + public static getCountByAddressAndStatus = async (address: string, status: TransactionStatus[]): Promise => { + const lockHashes: string[] = await LockUtils.addressToAllLockHashes(address) + return TransactionsService.getCountByLockHashesAndStatus(lockHashes, status) + } + + public static updateDescription = async (hash: string, description: string) => { + const transactionEntity = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .where({ + hash, + }) + .getOne() + + if (!transactionEntity) { + return undefined + } + transactionEntity.description = description + return getConnection().manager.save(transactionEntity) + } +} + +export default TransactionsService diff --git a/packages/neuron-wallet/src/services/wallets.ts b/packages/neuron-wallet/src/services/wallets.ts index 7a0125ffd1..cae8cfd17d 100644 --- a/packages/neuron-wallet/src/services/wallets.ts +++ b/packages/neuron-wallet/src/services/wallets.ts @@ -1,6 +1,6 @@ import { v4 as uuid } from 'uuid' import { debounceTime } from 'rxjs/operators' -import TransactionsService from './transactions' +import { TransactionsService, TransactionPersistor, TransactionGenerator } from './tx' import { AccountExtendedPublicKey, PathAndPrivateKey } from '../models/keys/key' import Keystore from '../models/keys/keystore' import Store from '../models/store' @@ -325,7 +325,7 @@ export default class WalletService { const changeAddress: string = await this.getChangeAddress() - const tx: TransactionWithoutHash = await TransactionsService.generateTx( + const tx: TransactionWithoutHash = await TransactionGenerator.generateTx( lockHashes, targetOutputs, changeAddress, @@ -358,7 +358,7 @@ export default class WalletService { await core.rpc.sendTransaction(txToSend) tx.description = description - await TransactionsService.saveSentTx(tx, txHash) + await TransactionPersistor.saveSentTx(tx, txHash) // update addresses txCount and balance const blake160s = TransactionsService.blake160sOfTx(tx) diff --git a/packages/neuron-wallet/tests/services/transactions.test.ts b/packages/neuron-wallet/tests/services/transactions.test.ts index d6fb577dbb..2e181b5d8b 100644 --- a/packages/neuron-wallet/tests/services/transactions.test.ts +++ b/packages/neuron-wallet/tests/services/transactions.test.ts @@ -1,4 +1,4 @@ -import TransactionsService, { SearchType } from '../../src/services/transactions' +import TransactionsService, { SearchType } from '../../src/services/tx/transaction-service' describe('transactions service', () => { describe('filterSearchType', () => { diff --git a/packages/neuron-wallet/tests/services/wallets.test.ts b/packages/neuron-wallet/tests/services/wallets.test.ts index e99faa8353..28f7bbbe7d 100644 --- a/packages/neuron-wallet/tests/services/wallets.test.ts +++ b/packages/neuron-wallet/tests/services/wallets.test.ts @@ -4,6 +4,13 @@ import Keystore from '../../src/models/keys/keystore' import Keychain from '../../src/models/keys/keychain' import { mnemonicToSeedSync } from '../../src/models/keys/mnemonic' import { ExtendedPrivateKey, AccountExtendedPublicKey } from '../../src/models/keys/key' +import AddressService from '../../src/services/addresses' + +const mockDeleteAddressByWalletId = () => { + const mockDeleteAddress = jest.fn() + mockDeleteAddress.mockReturnValue(undefined) + AddressService.deleteByWalletId = mockDeleteAddress.bind(AddressService) +} describe('wallet service', () => { let walletService: WalletService @@ -115,6 +122,7 @@ describe('wallet service', () => { }) it('delete wallet', () => { + mockDeleteAddressByWalletId() const w1 = walletService.create(wallet1) walletService.create(wallet2) expect(walletService.getAll().length).toBe(2) @@ -147,6 +155,7 @@ describe('wallet service', () => { }) it('delete current wallet', () => { + mockDeleteAddressByWalletId() const w1 = walletService.create(wallet1) const w2 = walletService.create(wallet2) walletService.delete(w1.id) @@ -156,6 +165,7 @@ describe('wallet service', () => { }) it('delete none current wallet', () => { + mockDeleteAddressByWalletId() const w1 = walletService.create(wallet1) const w2 = walletService.create(wallet2) walletService.delete(w2.id)