diff --git a/packages/neuron-wallet/src/block-sync-renderer/sync/indexer-cache-service.ts b/packages/neuron-wallet/src/block-sync-renderer/sync/indexer-cache-service.ts index a66fbf459d..aee4715bb5 100644 --- a/packages/neuron-wallet/src/block-sync-renderer/sync/indexer-cache-service.ts +++ b/packages/neuron-wallet/src/block-sync-renderer/sync/indexer-cache-service.ts @@ -4,6 +4,7 @@ import AddressMeta from '../../database/address/meta' import IndexerTxHashCache from '../../database/chain/entities/indexer-tx-hash-cache' import RpcService from '../../services/rpc-service' import TransactionWithStatus from '../../models/chain/transaction-with-status' +import SyncInfoEntity from '../../database/chain/entities/sync-info' import { TransactionCollector, CellCollector, Indexer as CkbIndexer } from '@ckb-lumos/ckb-indexer' export default class IndexerCacheService { @@ -11,6 +12,7 @@ export default class IndexerCacheService { private rpcService: RpcService private walletId: string private indexer: CkbIndexer + #cacheBlockNumberEntity?: SyncInfoEntity constructor(walletId: string, addressMetas: AddressMeta[], rpcService: RpcService, indexer: CkbIndexer) { for (const addressMeta of addressMetas) { @@ -25,16 +27,6 @@ export default class IndexerCacheService { this.indexer = indexer } - private async countTxHashes(): Promise { - return getConnection() - .getRepository(IndexerTxHashCache) - .createQueryBuilder() - .where({ - walletId: this.walletId, - }) - .getCount() - } - private async getTxHashes(): Promise { return getConnection() .getRepository(IndexerTxHashCache) @@ -79,6 +71,8 @@ export default class IndexerCacheService { } private async fetchTxMapping(): Promise>> { + const lastCacheBlockNumber = await this.getCachedBlockNumber() + const currentHeaderBlockNumber = await this.rpcService.getTipBlockNumber() const mappingsByTxHash = new Map() for (const addressMeta of this.addressMetas) { const lockScripts = [ @@ -88,9 +82,18 @@ export default class IndexerCacheService { ] for (const lockScript of lockScripts) { - const transactionCollector = new TransactionCollector(this.indexer, { lock: lockScript }, this.rpcService.url, { - includeStatus: false, - }) + const transactionCollector = new TransactionCollector( + this.indexer, + { + lock: lockScript, + fromBlock: lastCacheBlockNumber.value, + toBlock: currentHeaderBlockNumber, + }, + this.rpcService.url, + { + includeStatus: false, + } + ) const fetchedTxHashes = await transactionCollector.getTransactionHashes() if (!fetchedTxHashes.length) { @@ -126,6 +129,8 @@ export default class IndexerCacheService { args: lockScript.args.slice(0, 42), }, argsLen, + fromBlock: lastCacheBlockNumber.value, + toBlock: currentHeaderBlockNumber, }) for await (const cell of cellCollector.collect()) { @@ -142,17 +147,33 @@ export default class IndexerCacheService { return mappingsByTxHash } + private async getCachedBlockNumber() { + if (!this.#cacheBlockNumberEntity) { + this.#cacheBlockNumberEntity = (await getConnection().getRepository(SyncInfoEntity).findOne({ name: SyncInfoEntity.getLastCachedKey(this.walletId) })) ?? + SyncInfoEntity.fromObject({ + name: SyncInfoEntity.getLastCachedKey(this.walletId), + value: '0x0' + }) + } + + return this.#cacheBlockNumberEntity + } + + private async saveCacheBlockNumber(cacheBlockNumber: string) { + let cacheBlockNumberEntity = await this.getCachedBlockNumber() + cacheBlockNumberEntity.value = cacheBlockNumber + await getConnection().manager.save(cacheBlockNumberEntity) + } + public async upsertTxHashes(): Promise { + const tipBlockNumber = await this.rpcService.getTipBlockNumber() const mappingsByTxHash = await this.fetchTxMapping() const fetchedTxHashes = [...mappingsByTxHash.keys()] - const fetchedTxHashCount = fetchedTxHashes.reduce((sum, txHash) => sum + mappingsByTxHash.get(txHash)!.length, 0) - - const txCount = await this.countTxHashes() - if (fetchedTxHashCount === txCount) { + if (!fetchedTxHashes.length) { + await this.saveCacheBlockNumber(tipBlockNumber) return [] } - const txMetasCaches = await this.getTxHashes() const cachedTxHashes = txMetasCaches.map(meta => meta.txHash.toString()) @@ -161,6 +182,7 @@ export default class IndexerCacheService { const newTxHashes = fetchedTxHashes.filter(hash => !cachedTxHashesSet.has(hash)) if (!newTxHashes.length) { + await this.saveCacheBlockNumber(tipBlockNumber) return [] } @@ -208,6 +230,7 @@ export default class IndexerCacheService { } } + await this.saveCacheBlockNumber(tipBlockNumber) return newTxHashes } 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 e7f520e73f..aa927a6bc4 100644 --- a/packages/neuron-wallet/src/block-sync-renderer/sync/queue.ts +++ b/packages/neuron-wallet/src/block-sync-renderer/sync/queue.ts @@ -30,6 +30,7 @@ export default class Queue { #rpcService: RpcService #indexerConnector: Connector | undefined #checkAndSaveQueue: QueueObject<{ txHashes: CKBComponents.Hash[]; params: unknown }> | undefined + #lockArgsSet: Set = new Set() #multiSignBlake160s: string[] #anyoneCanPayLockHashes: string[] @@ -44,6 +45,11 @@ export default class Queue { this.#lockHashes = AddressParser.batchToLockHash(this.#addresses.map(meta => meta.address)) const blake160s = this.#addresses.map(meta => meta.blake160) + this.#lockArgsSet = new Set(blake160s.map(blake160 => [ + blake160, + Multisig.hash([blake160]), + SystemScriptInfo.generateSecpScript(blake160).computeHash().slice(0, 42) + ]).flat()) this.#multiSignBlake160s = blake160s.map(blake160 => Multisig.hash([blake160])) this.#anyoneCanPayLockHashes = blake160s.map(b => this.#assetAccountInfo.generateAnyoneCanPayScript(b).computeHash() @@ -216,7 +222,7 @@ export default class Queue { } } } - await TransactionPersistor.saveFetchTx(tx) + await TransactionPersistor.saveFetchTx(tx, this.#lockArgsSet) for (const info of anyoneCanPayInfos) { await AssetAccountService.checkAndSaveAssetAccountWhenSync(info.tokenID, info.blake160) } diff --git a/packages/neuron-wallet/src/database/chain/entities/sync-info.ts b/packages/neuron-wallet/src/database/chain/entities/sync-info.ts index 5b8e93bed0..9bbd26bf8a 100644 --- a/packages/neuron-wallet/src/database/chain/entities/sync-info.ts +++ b/packages/neuron-wallet/src/database/chain/entities/sync-info.ts @@ -13,4 +13,18 @@ export default class SyncInfo { type: 'varchar', }) value!: string + + static fromObject(params: { + name: string, + value: string + }) { + const res = new SyncInfo() + res.name = params.name + res.value = params.value + return res + } + + static getLastCachedKey(walletId: string) { + return `lastCachedBlockNumber_${walletId}` + } } diff --git a/packages/neuron-wallet/src/database/chain/entities/tx-lock.ts b/packages/neuron-wallet/src/database/chain/entities/tx-lock.ts new file mode 100644 index 0000000000..61aa2b0058 --- /dev/null +++ b/packages/neuron-wallet/src/database/chain/entities/tx-lock.ts @@ -0,0 +1,24 @@ +import { BaseEntity, Entity, PrimaryColumn } from 'typeorm' + +@Entity() +export default class TxLock extends BaseEntity { + @PrimaryColumn({ + type: 'varchar' + }) + transactionHash!: string + + @PrimaryColumn({ + type: 'varchar', + }) + lockHash!: string + + static fromObject(obj: { + txHash: string + lockHash: string + }) { + const res = new TxLock() + res.transactionHash = obj.txHash + res.lockHash = obj.lockHash + return res + } +} diff --git a/packages/neuron-wallet/src/database/chain/index.ts b/packages/neuron-wallet/src/database/chain/index.ts index d5c0a42c8e..69aebff209 100644 --- a/packages/neuron-wallet/src/database/chain/index.ts +++ b/packages/neuron-wallet/src/database/chain/index.ts @@ -8,14 +8,17 @@ 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' +import TxLock from './entities/tx-lock' /* * Clean local sqlite storage */ export const clean = async (clearAllLightClientData?: boolean) => { await Promise.all([ - ...[InputEntity, OutputEntity, TransactionEntity, IndexerTxHashCache, MultisigOutput].map(entity => { - return getConnection().getRepository(entity).clear() + ...[InputEntity, OutputEntity, TransactionEntity, IndexerTxHashCache, MultisigOutput, TxLock].map(entity => { + return getConnection() + .getRepository(entity) + .clear() }), clearAllLightClientData ? getConnection().getRepository(SyncProgress).clear() diff --git a/packages/neuron-wallet/src/database/chain/migrations/1684488676083-TxLock.ts b/packages/neuron-wallet/src/database/chain/migrations/1684488676083-TxLock.ts new file mode 100644 index 0000000000..02e3308247 --- /dev/null +++ b/packages/neuron-wallet/src/database/chain/migrations/1684488676083-TxLock.ts @@ -0,0 +1,15 @@ +import {MigrationInterface, QueryRunner, TableIndex} from "typeorm"; + +export class TxLock1684488676083 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "tx_lock" ("lockHash" varchar NOT NULL, "transactionHash" varchar NOT NULL, PRIMARY KEY ("transactionHash", "lockHash"))`) + await queryRunner.createIndex("tx_lock", new TableIndex({ columnNames: ['lockHash'] })) + await queryRunner.createIndex("tx_lock", new TableIndex({ columnNames: ['transactionHash'] })) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "tx_lock"`) + } + +} diff --git a/packages/neuron-wallet/src/database/chain/ormconfig.ts b/packages/neuron-wallet/src/database/chain/ormconfig.ts index 8d207d49f4..1ba8ad9595 100644 --- a/packages/neuron-wallet/src/database/chain/ormconfig.ts +++ b/packages/neuron-wallet/src/database/chain/ormconfig.ts @@ -18,6 +18,7 @@ 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 TxLock from './entities/tx-lock' import { InitMigration1566959757554 } from './migrations/1566959757554-InitMigration' import { AddTypeAndHasData1567144517514 } from './migrations/1567144517514-AddTypeAndHasData' @@ -51,6 +52,7 @@ import { UpdateOutputChequeLockHash1652945662504 } from './migrations/1652945662 import { RemoveAddressesMultisigConfig1651820157100 } from './migrations/1651820157100-RemoveAddressesMultisigConfig' import { AddSyncProgress1676441837373 } from './migrations/1676441837373-AddSyncProgress' import { AddTypeSyncProgress1681360188494 } from './migrations/1681360188494-AddTypeSyncProgress' +import { TxLock1684488676083 } from './migrations/1684488676083-TxLock' export const CONNECTION_NOT_FOUND_NAME = 'ConnectionNotFoundError' @@ -84,6 +86,7 @@ const connectOptions = async (genesisBlockHash: string): Promise => { + private static saveWithFetch = async ( + transaction: Transaction, + lockArgsSetNeedsDetail?: Set + ): Promise => { const connection = getConnection() const txEntity: TransactionEntity | undefined = await connection .getRepository(TransactionEntity) @@ -190,14 +195,15 @@ export class TransactionPersistor { return txEntity } - return TransactionPersistor.create(transaction, OutputStatus.Live, OutputStatus.Dead) + return TransactionPersistor.create(transaction, OutputStatus.Live, OutputStatus.Dead, lockArgsSetNeedsDetail) } // only create, check exist before this public static create = async ( transaction: Transaction, outputStatus: OutputStatus, - inputStatus: OutputStatus + inputStatus: OutputStatus, + lockArgsSetNeedsDetail?: Set ): Promise => { const connection = getConnection() const tx = new TransactionEntity() @@ -297,22 +303,30 @@ export class TransactionPersistor { } return output }) + let willSaveDetailInputs: InputEntity[] = inputs + let willSaveDetailOutputs: OutputEntity[] = outputs + let txLocks: TxLockEntity[] = [] + if (lockArgsSetNeedsDetail?.size) { + willSaveDetailInputs = inputs.filter(v => this.shouldSaveDetail(v, lockArgsSetNeedsDetail)) + willSaveDetailOutputs = outputs.filter(v => this.shouldSaveDetail(v, lockArgsSetNeedsDetail)) + txLocks = [ + ...new Set([ + ...inputs.filter(v => v.lockHash && !this.shouldSaveDetail(v, lockArgsSetNeedsDetail)).map(v => v.lockHash!), + ...outputs.filter(v => !this.shouldSaveDetail(v, lockArgsSetNeedsDetail)).map(v => v.lockHash), + ]), + ].map(v => TxLockEntity.fromObject({ txHash: tx.hash, lockHash: v })) + } - const sliceSize = 100 + const chunk = 100 const queryRunner = connection.createQueryRunner() await TransactionPersistor.waitUntilTransactionFinished(queryRunner) await queryRunner.startTransaction() try { await queryRunner.manager.save(tx) - for (const slice of ArrayUtils.eachSlice(inputs, sliceSize)) { - await queryRunner.manager.save(slice) - } - for (const slice of ArrayUtils.eachSlice(previousOutputs, sliceSize)) { - await queryRunner.manager.save(slice) - } - for (const slice of ArrayUtils.eachSlice(outputs, sliceSize)) { - await queryRunner.manager.save(slice) - } + await queryRunner.manager.save(willSaveDetailInputs, { chunk }) + await queryRunner.manager.save(previousOutputs, { chunk }) + await queryRunner.manager.save(willSaveDetailOutputs, { chunk }) + await queryRunner.manager.save(txLocks, { chunk }) await queryRunner.commitTransaction() } catch (err) { logger.error('Database:\tcreate transaction error:', err) @@ -342,7 +356,8 @@ export class TransactionPersistor { // when fetch a transaction, use TxSaveType.Fetch public static convertTransactionAndSave = async ( transaction: Transaction, - saveType: TxSaveType + saveType: TxSaveType, + lockArgsSetNeedsDetail?: Set ): Promise => { const tx: Transaction = transaction @@ -350,17 +365,21 @@ export class TransactionPersistor { if (saveType === TxSaveType.Sent) { txEntity = await TransactionPersistor.saveWithSent(tx) } else if (saveType === TxSaveType.Fetch) { - txEntity = await TransactionPersistor.saveWithFetch(tx) + txEntity = await TransactionPersistor.saveWithFetch(tx, lockArgsSetNeedsDetail) } else { throw new Error('Error TxSaveType!') } return txEntity } - public static saveFetchTx = async (transaction: Transaction): Promise => { + public static saveFetchTx = async ( + transaction: Transaction, + lockArgsSetNeedsDetail?: Set + ): Promise => { const txEntity: TransactionEntity = await TransactionPersistor.convertTransactionAndSave( transaction, - TxSaveType.Fetch + TxSaveType.Fetch, + lockArgsSetNeedsDetail ) return txEntity } @@ -373,6 +392,17 @@ export class TransactionPersistor { const txEntity: TransactionEntity = await TransactionPersistor.convertTransactionAndSave(tx, TxSaveType.Sent) return txEntity } + + private static shouldSaveDetail(cell: InputEntity | OutputEntity, lockArgsSetNeedsDetail: Set) { + return ( + cell.lockArgs && + (lockArgsSetNeedsDetail.has(cell.lockArgs) || + (cell.lockArgs.length === CHEQUE_ARGS_LENGTH && + [cell.lockArgs.slice(0, DEFAULT_ARGS_LENGTH), `0x${cell.lockArgs.slice(DEFAULT_ARGS_LENGTH)}`].some(v => + lockArgsSetNeedsDetail.has(v) + ))) + ) + } } 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 index 43bb93392e..c38d3a3e03 100644 --- a/packages/neuron-wallet/src/services/tx/transaction-service.ts +++ b/packages/neuron-wallet/src/services/tx/transaction-service.ts @@ -1,4 +1,5 @@ import { getConnection } from 'typeorm' +import CKB from '@nervosnetwork/ckb-sdk-core' import TransactionEntity from '../../database/chain/entities/transaction' import OutputEntity from '../../database/chain/entities/output' import Transaction, { @@ -15,6 +16,10 @@ import BufferUtils from '../../utils/buffer' import AssetAccountEntity from '../../database/chain/entities/asset-account' import SudtTokenInfoEntity from '../../database/chain/entities/sudt-token-info' import exportTransactions from '../../utils/export-history' +import RpcService from '../rpc-service' +import NetworksService from '../networks' +import Script from '../../models/chain/script' +import Input from '../../models/chain/input' export interface TransactionsByAddressesParam { pageNo: number @@ -64,8 +69,6 @@ export class TransactionsService { ): Promise> { const type: SearchType = TransactionsService.filterSearchType(searchValue) - const lockScripts = AddressParser.batchParse(params.addresses) - let lockHashes: string[] = lockScripts.map(s => s.computeHash()) const assetAccountInfo = new AssetAccountInfo() const connection = getConnection() @@ -76,32 +79,27 @@ export class TransactionsService { if (type === SearchType.Address) { const lockHashToSearch = AddressParser.parse(searchValue).computeHash() - lockHashes = [lockHashToSearch] - allTxHashes = await repository - .createQueryBuilder('tx') - .select('tx.hash', 'txHash') - .where( - `tx.hash - IN - ( + allTxHashes = await connection + .createQueryRunner() + .query( + ` SELECT transactionHash from ( - SELECT output.transactionHash FROM output WHERE output.lockHash in (:...lockHashes) + SELECT output.transactionHash FROM output WHERE output.lockHash = @0 UNION - SELECT input.transactionHash FROM input WHERE input.lockHash in (:...lockHashes) + SELECT input.transactionHash FROM input WHERE input.lockHash = @0 + UNION + SELECT tx_lock.transactionHash FROM tx_lock WHERE tx_lock.lockHash = @0 ) INTERSECT SELECT transactionHash from ( - SELECT output.transactionHash FROM output WHERE output.lockArgs in (select publicKeyInBlake160 from hd_public_key_info where walletId = :walletId) + SELECT output.transactionHash FROM output WHERE output.lockArgs in (select publicKeyInBlake160 from hd_public_key_info where walletId = @1) UNION - SELECT input.transactionHash FROM input WHERE input.lockArgs in (select publicKeyInBlake160 from hd_public_key_info where walletId = :walletId) + SELECT input.transactionHash FROM input WHERE input.lockArgs in (select publicKeyInBlake160 from hd_public_key_info where walletId = @1) ) - ) - `, - { lockHashes, walletId: params.walletID } + `, + [lockHashToSearch, params.walletID] ) - .orderBy('tx.timestamp', 'DESC') - .getRawMany() - .then(txs => txs.map(tx => tx.txHash)) + .then((txs: { transactionHash: string }[]) => txs.map(tx => tx.transactionHash)) } else if (type === SearchType.TxHash) { allTxHashes = await repository .createQueryBuilder('tx') @@ -485,29 +483,51 @@ export class TransactionsService { } public static async get(hash: string): Promise { - const tx = await getConnection() + const txInDB = await getConnection() .getRepository(TransactionEntity) .createQueryBuilder('transaction') .where('transaction.hash is :hash', { hash }) - .leftJoinAndSelect('transaction.inputs', 'input') - .orderBy({ - 'input.id': 'ASC', - }) .getOne() - const txOutputs = await getConnection() - .getRepository(OutputEntity) - .createQueryBuilder() - .where({ - outPointTxHash: hash, - }) - .getMany() - - if (!tx) { + if (!txInDB) { return undefined } + const url: string = NetworksService.getInstance().getCurrent().remote + const rpcService = new RpcService(url) + const txWithStatus = await rpcService.getTransaction(hash) + if (!txWithStatus?.transaction) { + return undefined + } + const tx = txInDB.toModel() + tx.inputs = await this.fillInputFields(txWithStatus.transaction.inputs) + tx.outputs = txWithStatus.transaction.outputs + return tx + } - tx.outputs = txOutputs - return tx.toModel() + private static async fillInputFields(inputs: Input[]) { + const inputTxHashes = inputs.map(v => v.previousOutput?.txHash).filter((v): v is string => !!v) + if (!inputTxHashes.length) return inputs + const url: string = NetworksService.getInstance().getCurrent().remote + const ckb = new CKB(url) + const inputTxs = await ckb.rpc + .createBatchRequest<'getTransaction', string[], CKBComponents.TransactionWithStatus[]>( + inputTxHashes.map(v => ['getTransaction', v]) + ) + .exec() + const inputTxMap = new Map() + inputTxs.forEach((v, idx) => { + inputTxMap.set(inputTxHashes[idx], v.transaction) + }) + return inputs.map(v => { + if (!v.previousOutput?.txHash) return v + const output = inputTxMap.get(v.previousOutput.txHash)?.outputs?.[+v.previousOutput.index] + if (!output) return v + v.setCapacity(output.capacity) + v.setLock(Script.fromSDK(output.lock)) + if (output.type) { + v.setType(Script.fromSDK(output.type)) + } + return v + }) } public static blake160sOfTx(tx: Transaction) { diff --git a/packages/neuron-wallet/src/utils/const.ts b/packages/neuron-wallet/src/utils/const.ts index 562394e4b1..45f147c7d0 100644 --- a/packages/neuron-wallet/src/utils/const.ts +++ b/packages/neuron-wallet/src/utils/const.ts @@ -9,6 +9,8 @@ export const DEFAULT_UDT_SYMBOL = 'Unknown' export const MIN_SUDT_CAPACITY = 142 * 10 ** 8 export const MIN_CELL_CAPACITY = 61 * 10 ** 8 export const START_WITHOUT_INDEXER = -4 +export const DEFAULT_ARGS_LENGTH = 42 +export const CHEQUE_ARGS_LENGTH = 82 export enum ResponseCode { Fail, diff --git a/packages/neuron-wallet/tests/block-sync-renderer/indexer-cache-service.intg.test.ts b/packages/neuron-wallet/tests/block-sync-renderer/indexer-cache-service.intg.test.ts index 53223fc156..a05f81e755 100644 --- a/packages/neuron-wallet/tests/block-sync-renderer/indexer-cache-service.intg.test.ts +++ b/packages/neuron-wallet/tests/block-sync-renderer/indexer-cache-service.intg.test.ts @@ -21,10 +21,12 @@ const stubbedCollectFn = jest.fn(() => { } }) const ckbRpcUrl = 'http://localhost:8114' +const stubbedGetTipBlockNumberFn = jest.fn() const stubbedRPCServiceConstructor = jest.fn().mockImplementation(() => ({ getTransaction: stubbedGetTransactionFn, getHeader: stubbedGetHeaderFn, + getTipBlockNumber: stubbedGetTipBlockNumberFn, })) const stubbedIndexerConstructor = jest.fn().mockImplementation(() => ({ @@ -39,6 +41,7 @@ const stubbedCellCollectorConstructor = jest.fn().mockImplementation(() => ({ const resetMocks = () => { stubbedGetTransactionFn.mockReset() stubbedGetHeaderFn.mockReset() + stubbedGetTipBlockNumberFn.mockReset() mockGetTransactionHashes() } @@ -95,13 +98,15 @@ const formattedLegacyAcpLockScript = { args: legacyAcpLockScript.args, } +const mockTipBlockNumber = '0x100' + const mockGetTransactionHashes = (mocks: any[] = []) => { const stubbedConstructor = when(stubbedTransactionCollectorConstructor) for (const lock of [formattedDefaultLockScript, formattedAcpLockScript, formattedLegacyAcpLockScript]) { const { hashes } = mocks.find(mock => mock.lock === lock) || { hashes: [] } stubbedConstructor - .calledWith(expect.anything(), { lock }, rpcService?.url, { includeStatus: false }) + .calledWith(expect.anything(), expect.objectContaining({ lock }), rpcService?.url, { includeStatus: false }) .mockReturnValue({ getTransactionHashes: jest.fn().mockReturnValue(hashes), }) @@ -145,6 +150,7 @@ describe('indexer cache service', () => { } }) + stubbedGetTipBlockNumberFn.mockResolvedValue(mockTipBlockNumber) rpcService = new stubbedRPCServiceConstructor() IndexerCacheService = require('../../src/block-sync-renderer/sync/indexer-cache-service').default indexerCacheService = new IndexerCacheService(walletId, addressMetas, rpcService, stubbedIndexerConstructor()) @@ -269,21 +275,21 @@ describe('indexer cache service', () => { stubbedCellCollectorConstructor.mockReset() when(stubbedCellCollectorConstructor) - .calledWith(expect.anything(), { + .calledWith(expect.anything(), expect.objectContaining({ lock: { ...formattedSingleMultiSignLockScript, args: formattedSingleMultiSignLockScript.args.slice(0, 42), }, argsLen: 28, - }) + })) .mockReturnValue(fakeCollectorObj) - .calledWith(expect.anything(), { + .calledWith(expect.anything(), expect.objectContaining({ lock: { ...formattedChequeLockScript, args: formattedChequeLockScript.args.slice(0, 42), }, argsLen: 40, - }) + })) .mockReturnValue(fakeCollectorObj) newTxHashes = await indexerCacheService.upsertTxHashes() 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 a0d08a6c3a..c510304219 100644 --- a/packages/neuron-wallet/tests/block-sync-renderer/queue.test.ts +++ b/packages/neuron-wallet/tests/block-sync-renderer/queue.test.ts @@ -250,7 +250,11 @@ describe('queue', () => { tx.blockHash = fakeTxWithStatus2.txStatus.blockHash! tx.blockNumber = BigInt(fakeTxWithStatus2.transaction.blockNumber!).toString() tx.timestamp = BigInt(fakeTxWithStatus2.transaction.timestamp!).toString() - expect(stubbedSaveFetchFn).toHaveBeenCalledWith(tx) + expect(stubbedSaveFetchFn).toHaveBeenCalledWith(tx, new Set([ + addressInfo.blake160, + Multisig.hash([addressInfo.blake160]), + SystemScriptInfo.generateSecpScript(addressInfo.blake160).computeHash().slice(0, 42), + ])) } }) it('checks and generate new addresses', () => { diff --git a/packages/neuron-wallet/tests/services/tx/transaction-persistor.test.ts b/packages/neuron-wallet/tests/services/tx/transaction-persistor.test.ts index 391af9d05c..9d236e5b11 100644 --- a/packages/neuron-wallet/tests/services/tx/transaction-persistor.test.ts +++ b/packages/neuron-wallet/tests/services/tx/transaction-persistor.test.ts @@ -4,6 +4,10 @@ import initConnection from '../../../src/database/chain/ormconfig' import TransactionEntity from '../../../src/database/chain/entities/transaction' import { getConnection } from 'typeorm' import transactions from '../../setupAndTeardown/transactions.fixture' +import AssetAccountInfo from '../../../src/models/asset-account-info' +import SystemScriptInfo from '../../../src/models/system-script-info' +import Multisig from '../../../src/models/multisig' +import TxLockEntity from '../../../src/database/chain/entities/tx-lock' const [tx, tx2] = transactions @@ -66,4 +70,95 @@ describe('TransactionPersistor', () => { }) }) }) + + describe('#convertTransactionAndSave with lockargs', () => { + beforeEach(async () => { + const connection = getConnection() + await connection.synchronize(true) + }) + it('filter not current args', async () => { + await TransactionPersistor.convertTransactionAndSave(tx, TxSaveType.Fetch, new Set([tx.outputs[0].lock.args])) + const loadedTx = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .leftJoinAndSelect('tx.inputs', 'input') + .leftJoinAndSelect('tx.outputs', 'output') + .where(`tx.hash = :txHash`, { txHash: tx.hash }) + .getOne() + expect(loadedTx?.inputs.length).toBe(0) + expect(loadedTx?.outputs.length).toBe(1) + expect(loadedTx?.outputs[0].lockArgs).toBe(tx.outputs[0].lock.args) + const txLocks = await getConnection() + .getRepository(TxLockEntity) + .find({ transactionHash: tx.hash }) + expect(txLocks.length).toBe(1) + expect(txLocks[0].lockHash).toBe(tx.inputs[0].lock?.computeHash()) + }) + it('all args is current', async () => { + const args = [...tx.inputs.map(v => v.lock?.args), ...tx.outputs.map(v => v.lock.args)].filter((v): v is string => !!v) + await TransactionPersistor.convertTransactionAndSave(tx, TxSaveType.Fetch, new Set(args)) + const loadedTx = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .leftJoinAndSelect('tx.inputs', 'input') + .leftJoinAndSelect('tx.outputs', 'output') + .where(`tx.hash = :txHash`, { txHash: tx.hash }) + .getOne() + expect(loadedTx?.inputs.length).toBe(1) + expect(loadedTx?.outputs.length).toBe(2) + const txLocks = await getConnection() + .getRepository(TxLockEntity) + .find({ transactionHash: tx.hash }) + expect(txLocks.length).toBe(0) + }) + it('filter with receive cheque and send cheque', async () => { + const assetAccountInfo = new AssetAccountInfo() + const txWithCheque = Transaction.fromObject(tx) + const outputReceiveChequeLock = assetAccountInfo.generateChequeScript(txWithCheque.outputs[0].lockHash, `0x${'0'.repeat(42)}`) + const outputSendChequeLock = assetAccountInfo.generateChequeScript(`0x${'0'.repeat(42)}`, txWithCheque.outputs[0].lockHash) + txWithCheque.outputs[0].setLock(outputReceiveChequeLock) + txWithCheque.outputs[1].setLock(outputSendChequeLock) + const args = [...tx.inputs.map(v => v.lock?.args), ...tx.outputs.map(v => v.lock.args)].filter((v): v is string => !!v).map(v => [ + v, + SystemScriptInfo.generateSecpScript(v).computeHash().slice(0, 42), + ]).flat() + await TransactionPersistor.convertTransactionAndSave(txWithCheque, TxSaveType.Fetch, new Set(args)) + const loadedTx = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .leftJoinAndSelect('tx.inputs', 'input') + .leftJoinAndSelect('tx.outputs', 'output') + .where(`tx.hash = :txHash`, { txHash: tx.hash }) + .getOne() + expect(loadedTx?.inputs.length).toBe(1) + expect(loadedTx?.outputs.length).toBe(2) + const txLocks = await getConnection() + .getRepository(TxLockEntity) + .find({ transactionHash: tx.hash }) + expect(txLocks.length).toBe(0) + }) + it('filter with multi lock time', async () => { + const txWithCheque = Transaction.fromObject(tx) + const multisigLockTimeLock = SystemScriptInfo.generateMultiSignScript(Multisig.hash([txWithCheque.outputs[0].lock.args])) + txWithCheque.outputs[0].setLock(multisigLockTimeLock) + const args = [...tx.inputs.map(v => v.lock?.args), ...tx.outputs.map(v => v.lock.args)].filter((v): v is string => !!v).map(v => [ + v, + Multisig.hash([v]), + ]).flat() + await TransactionPersistor.convertTransactionAndSave(txWithCheque, TxSaveType.Fetch, new Set(args)) + const loadedTx = await getConnection() + .getRepository(TransactionEntity) + .createQueryBuilder('tx') + .leftJoinAndSelect('tx.inputs', 'input') + .leftJoinAndSelect('tx.outputs', 'output') + .where(`tx.hash = :txHash`, { txHash: tx.hash }) + .getOne() + expect(loadedTx?.inputs.length).toBe(1) + expect(loadedTx?.outputs.length).toBe(2) + const txLocks = await getConnection() + .getRepository(TxLockEntity) + .find({ transactionHash: tx.hash }) + expect(txLocks.length).toBe(0) + }) + }) }) diff --git a/packages/neuron-wallet/tests/services/tx/transaction-service.test.ts b/packages/neuron-wallet/tests/services/tx/transaction-service.test.ts index 47164809b5..3aa87e818c 100644 --- a/packages/neuron-wallet/tests/services/tx/transaction-service.test.ts +++ b/packages/neuron-wallet/tests/services/tx/transaction-service.test.ts @@ -1,4 +1,4 @@ -import os from 'os' + import os from 'os' import fs from 'fs' import path from 'path' import TransactionService, { SearchType } from '../../../src/services/tx/transaction-service' @@ -9,6 +9,11 @@ import accounts from '../../setupAndTeardown/accounts.fixture' import transactions from '../../setupAndTeardown/transactions.fixture' import { getConnection } from 'typeorm' import HdPublicKeyInfo from '../../../src/database/chain/entities/hd-public-key-info' +import TransactionPersistor, { TxSaveType } from '../../../src/services/tx/transaction-persistor' +import SystemScriptInfo from '../../../src/models/system-script-info' +import { scriptToAddress } from '@nervosnetwork/ckb-sdk-utils' +import Input from '../../../src/models/chain/input' +import OutPoint from '../../../src/models/chain/out-point' jest.mock('../../../src/models/asset-account-info', () => { const originalModule = jest.requireActual('../../../src/models/asset-account-info').default @@ -17,6 +22,37 @@ jest.mock('../../../src/models/asset-account-info', () => { } }) +const getTransactionMock = jest.fn() + +jest.mock('../../../src/services/rpc-service', () => { + return function() { + return { + getTransaction: getTransactionMock + } + } +}) + +const ckbRpcExecMock = jest.fn() + +jest.mock('@nervosnetwork/ckb-sdk-core', () => { + return function() { + return { + rpc: { + createBatchRequest() { + return { + exec: ckbRpcExecMock + } + } + } + } + } +}) + +function resetMock() { + getTransactionMock.mockReset() + ckbRpcExecMock.mockReset() +} + describe('Test TransactionService', () => { beforeAll(async () => { await initConnection() @@ -196,9 +232,12 @@ describe('Test TransactionService', () => { const DESCRIPTION = 'new description' stubProvider.hash = HASH stubProvider.description = DESCRIPTION + resetMock() }) it('Should update the description', async () => { + getTransactionMock.mockResolvedValue({ transaction: transactions[0] }) + ckbRpcExecMock.mockResolvedValue([]) const origin = await TransactionService.get(stubProvider.hash) expect(origin!.description).toBe('') await TransactionService.updateDescription(stubProvider.hash, stubProvider.description) @@ -235,11 +274,32 @@ describe('Test TransactionService', () => { beforeEach(() => { const HASH = '0x230ab250ee0ae681e88e462102e5c01a9994ac82bf0effbfb58d6c11a86579f1' stubProvider.hash = HASH + resetMock() }) it('Should return a transaction', async () => { + getTransactionMock.mockResolvedValue({ transaction: transactions[0] }) + ckbRpcExecMock.mockResolvedValue([]) + const actual = await TransactionService.get(stubProvider.hash) + expect(actual).not.toBeUndefined() + }) + + it('Get input and outputs from rpc', async () => { + getTransactionMock.mockResolvedValue({ transaction: Transaction.fromObject(transactions[0]) }) + ckbRpcExecMock.mockResolvedValue([{ transaction: { outputs: [{ capacity: '0x100', lock: transactions[0].inputs[0].lock } ]} }]) + const actual = await TransactionService.get(stubProvider.hash) + expect(actual).not.toBeUndefined() + expect(actual?.inputs.length).toBe(transactions[0].inputs.length) + expect(actual?.inputs[0].capacity).toBe((+'0x100').toString()) + }) + + it('Get input and outputs from rpc no tx', async () => { + getTransactionMock.mockResolvedValue({ transaction: Transaction.fromObject(transactions[0]) }) + ckbRpcExecMock.mockResolvedValue([]) const actual = await TransactionService.get(stubProvider.hash) expect(actual).not.toBeUndefined() + expect(actual?.inputs.length).toBe(transactions[0].inputs.length) + expect(actual?.inputs[0].capacity).toBeUndefined() }) }) @@ -405,6 +465,17 @@ describe('Test TransactionService', () => { const actual = await TransactionService.getAllByAddresses(stubProvider, stubProvider.searchValue) expect(actual.totalCount).toBe(2) }) + + it('find from tx lock', async () => { + const tx = Transaction.fromObject(transactions[0]) + tx.hash = `0x01${'0'.repeat(62)}` + const args = `0x${'0'.repeat(42)}` + const script = SystemScriptInfo.generateSecpScript(args) + tx.inputs[0].setLock(script) + await TransactionPersistor.convertTransactionAndSave(tx, TxSaveType.Fetch, new Set([tx.outputs[0].lock.args, tx.outputs[1].lock.args])) + const actual = await TransactionService.getAllByAddresses(stubProvider, scriptToAddress(script)) + expect(actual.totalCount).toBe(1) + }) }) }) @@ -492,4 +563,93 @@ describe('Test TransactionService', () => { }) }) }) + + describe('fillInputFields', () => { + it('inputs is empty', async () => { + const inputs: Input[] = [] + //@ts-ignore private-method + const actual = await TransactionService.fillInputFields(inputs) + expect(actual).toBe(inputs) + }) + it('inputs without txHash', async () => { + const inputs = [ + Input.fromObject({ + previousOutput: null + }) + ] + //@ts-ignore private-method + const actual = await TransactionService.fillInputFields(inputs) + expect(actual).toBe(inputs) + }) + it('can not get output', async () => { + const inputs = [ + Input.fromObject({ + previousOutput: new OutPoint(`0x${'0'.repeat(64)}`, '0x0') + }) + ] + ckbRpcExecMock.mockResolvedValueOnce([]) + //@ts-ignore private-method + const actual = await TransactionService.fillInputFields(inputs) + expect(actual).toStrictEqual(inputs) + }) + it('success fill input fields without type', async () => { + const inputs = [ + Input.fromObject({ + previousOutput: new OutPoint(`0x${'0'.repeat(64)}`, '0x0') + }) + ] + const transactionWithStatus = { + transaction: { + outputs: [ + { + capacity: '0x1000', + lock: { + codeHash: `0x${'0'.repeat(64)}`, + hashType: 'data', + args: '0x0' + }, + } + ] + } + } + ckbRpcExecMock.mockResolvedValueOnce([transactionWithStatus]) + //@ts-ignore private-method + const actual = await TransactionService.fillInputFields(inputs) + expect(actual[0].capacity).toBe('4096') + expect(actual[0].lock?.toSDK()).toStrictEqual(transactionWithStatus.transaction.outputs[0].lock) + expect(actual[0].type).toBeUndefined() + }) + it('success fill input fields with type', async () => { + const inputs = [ + Input.fromObject({ + previousOutput: new OutPoint(`0x${'0'.repeat(64)}`, '0x0') + }) + ] + const transactionWithStatus = { + transaction: { + outputs: [ + { + capacity: '0x1000', + lock: { + codeHash: `0x${'0'.repeat(64)}`, + hashType: 'data', + args: '0x0' + }, + type: { + codeHash: `0x${'1'.repeat(64)}`, + hashType: 'data', + args: '0x1' + } + } + ] + } + } + ckbRpcExecMock.mockResolvedValueOnce([transactionWithStatus]) + //@ts-ignore private-method + const actual = await TransactionService.fillInputFields(inputs) + expect(actual[0].capacity).toBe('4096') + expect(actual[0].lock?.toSDK()).toStrictEqual(transactionWithStatus.transaction.outputs[0].lock) + expect(actual[0].type?.toSDK()).toStrictEqual(transactionWithStatus.transaction.outputs[0].type) + }) + }) })