diff --git a/e2e/service/BlockService.spec.ts b/e2e/service/BlockService.spec.ts new file mode 100644 index 0000000000..43c1b8ca38 --- /dev/null +++ b/e2e/service/BlockService.spec.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2019 NEM + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert, expect } from 'chai'; +import { ReceiptRepository } from '../../src/infrastructure/ReceiptRepository'; +import { TransactionRepository } from '../../src/infrastructure/TransactionRepository'; +import { Account } from '../../src/model/account/Account'; +import { NetworkType } from '../../src/model/blockchain/NetworkType'; +import { PlainMessage } from '../../src/model/message/PlainMessage'; +import { NetworkCurrencyMosaic } from '../../src/model/mosaic/NetworkCurrencyMosaic'; +import { Deadline } from '../../src/model/transaction/Deadline'; +import { TransferTransaction } from '../../src/model/transaction/TransferTransaction'; +import { UInt64 } from '../../src/model/UInt64'; +import { BlockService } from '../../src/service/BlockService'; +import { IntegrationTestHelper } from '../infrastructure/IntegrationTestHelper'; + +describe('BlockService', () => { + const helper = new IntegrationTestHelper(); + let generationHash: string; + let account: Account; + let account2: Account; + let account3: Account; + let networkType: NetworkType; + let transactionHash: string; + let blockService: BlockService; + let transactionRepository: TransactionRepository; + let receiptRepository: ReceiptRepository; + + before(() => { + return helper.start().then(() => { + account = helper.account; + account2 = helper.account2; + account3 = helper.account3; + generationHash = helper.generationHash; + networkType = helper.networkType; + transactionRepository = helper.repositoryFactory.createTransactionRepository(); + receiptRepository = helper.repositoryFactory.createReceiptRepository(); + blockService = new BlockService(helper.repositoryFactory); + }); + }); + before(() => { + return helper.listener.open(); + }); + + after(() => { + helper.listener.close(); + }); + + /** + * ========================= + * Setup test data + * ========================= + */ + describe('Create a transfer', () => { + it('Announce TransferTransaction', () => { + const transferTransaction = TransferTransaction.create( + Deadline.create(), + account2.address, + [NetworkCurrencyMosaic.createAbsolute(1)], + PlainMessage.create('test-message'), + networkType, + helper.maxFee, + ); + + const signedTransaction = transferTransaction.signWith(account, generationHash); + transactionHash = signedTransaction.hash; + return helper.announce(signedTransaction); + }); + }); + + /** + * ========================= + * Test + * ========================= + */ + + describe('Validate transansaction', () => { + it('call block service', async () => { + const transaction = await transactionRepository.getTransaction(transactionHash).toPromise(); + const transactionInfo = transaction.transactionInfo; + if (transactionInfo && transactionInfo.height !== undefined) { + const validationResult = await blockService.validateTransactionInBlock(transactionHash, transactionInfo.height).toPromise(); + expect(validationResult).to.be.true; + } else { + assert(false, `Transaction (hash: ${transactionHash}) not found`); + } + }); + }); + + describe('Validate receipt', () => { + it('call block service', async () => { + const statements = await receiptRepository.getBlockReceipts(UInt64.fromUint(1)).toPromise(); + const statement = statements.transactionStatements[0]; + const validationResult = await blockService.validateStatementInBlock(statement.generateHash(), UInt64.fromUint(1)).toPromise(); + expect(validationResult).to.be.true; + }); + }); +}); diff --git a/src/service/BlockService.ts b/src/service/BlockService.ts new file mode 100644 index 0000000000..0c3b5ec955 --- /dev/null +++ b/src/service/BlockService.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2020 NEM + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { sha3_256 } from 'js-sha3'; +import { combineLatest, Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { BlockRepository } from '../infrastructure/BlockRepository'; +import { ReceiptRepository } from '../infrastructure/ReceiptRepository'; +import { RepositoryFactory } from '../infrastructure/RepositoryFactory'; +import { MerklePathItem } from '../model/blockchain/MerklePathItem'; +import { UInt64 } from '../model/UInt64'; + +/** + * Transaction Service + */ +export class BlockService { + private readonly blockRepository: BlockRepository; + private readonly receiptRepository: ReceiptRepository; + + /** + * Constructor + * @param repositoryFactory + */ + constructor(public readonly repositoryFactory: RepositoryFactory) { + this.blockRepository = repositoryFactory.createBlockRepository(); + this.receiptRepository = repositoryFactory.createReceiptRepository(); + } + + /** + * Validate transaction hash in block + * @param leaf transaction hash + * @param height block height + */ + public validateTransactionInBlock(leaf: string, height: UInt64): Observable { + const rootHashObservable = this.blockRepository.getBlockByHeight(height); + const merklePathItemObservable = this.blockRepository.getMerkleTransaction(height, leaf); + return combineLatest(rootHashObservable, merklePathItemObservable).pipe( + map((combined) => this.validateInBlock(leaf, combined[1].merklePath, combined[0].blockTransactionsHash)), + ).pipe(catchError(() => of(false))); + } + + /** + * Validate statement hash in block + * @param leaf statement hash + * @param height block height + */ + public validateStatementInBlock(leaf: string, height: UInt64): Observable { + const rootHashObservable = this.blockRepository.getBlockByHeight(height); + const merklePathItemObservable = this.receiptRepository.getMerkleReceipts(height, leaf); + return combineLatest(rootHashObservable, merklePathItemObservable).pipe( + map((combined) => this.validateInBlock(leaf, combined[1].merklePath, combined[0].blockReceiptsHash)), + ).pipe(catchError(() => of(false))); + } + + /** + * @internal + * Validate leaf against merkle tree in block + * @param leaf Leaf hash in merkle tree + * @param merklePathItem Merkle path item array + * @param rootHash Block root hash + */ + private validateInBlock(leaf: string, merklePathItem: MerklePathItem[] = [], rootHash: string): boolean { + if (merklePathItem.length === 0) { + return leaf.toUpperCase() === rootHash.toUpperCase(); + } + const rootToCompare = merklePathItem.reduce((proofHash, pathItem) => { + const hasher = sha3_256.create(); + // Left + if (pathItem.position === 1) { + return hasher.update(Buffer.from(pathItem.hash + proofHash, 'hex')).hex(); + } else { + // Right + return hasher.update(Buffer.from(proofHash + pathItem.hash, 'hex')).hex(); + } + }, leaf); + return rootToCompare.toUpperCase() === rootHash.toUpperCase(); + } +} diff --git a/src/service/interfaces/IBlockService.ts b/src/service/interfaces/IBlockService.ts new file mode 100644 index 0000000000..e8d349c25b --- /dev/null +++ b/src/service/interfaces/IBlockService.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 NEM + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Observable } from 'rxjs'; +import { UInt64 } from '../../model/UInt64'; + +/** + * Block Service Interface + */ +export interface IBlockService { + + /** + * Validate transaction hash in block + * @param leaf transaction hash + * @param height block height + */ + validateTransactionInBlock(leaf: string, height: UInt64): Observable; + + /** + * Validate statement hash in block + * @param leaf statement hash + * @param height block height + */ + validateStatementInBlock(leaf: string, height: UInt64): Observable; +} diff --git a/src/service/service.ts b/src/service/service.ts index fd6074d5c4..ed73b3ff59 100644 --- a/src/service/service.ts +++ b/src/service/service.ts @@ -20,3 +20,4 @@ export * from './AggregateTransactionService'; export * from './MetadataTransactionService'; export * from './MosaicRestrictionTransactionService'; export * from './TransactionService'; +export * from './BlockService'; diff --git a/test/service/BlockService.spec.ts b/test/service/BlockService.spec.ts new file mode 100644 index 0000000000..0fe6aa0068 --- /dev/null +++ b/test/service/BlockService.spec.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2019 NEM + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { of as observableOf } from 'rxjs'; +import { deepEqual, instance, mock, when } from 'ts-mockito'; +import { BlockRepository } from '../../src/infrastructure/BlockRepository'; +import { ReceiptRepository } from '../../src/infrastructure/ReceiptRepository'; +import { RepositoryFactory } from '../../src/infrastructure/RepositoryFactory'; +import { Account } from '../../src/model/account/Account'; +import { BlockInfo } from '../../src/model/blockchain/BlockInfo'; +import { MerklePathItem } from '../../src/model/blockchain/MerklePathItem'; +import { MerkleProofInfo } from '../../src/model/blockchain/MerkleProofInfo'; +import { NetworkType } from '../../src/model/blockchain/NetworkType'; +import { UInt64 } from '../../src/model/UInt64'; +import { BlockService } from '../../src/service/BlockService'; +import { TestingAccount } from '../conf/conf.spec'; + +describe('BlockService', () => { + + const mockBlockHash = 'D4EC16FCFE696EFDBF1820F68245C88135ACF4C6F888599C8E18BC09B9F08C7B'; + const leaf = '2717C8AAB0A21896D0C56375209E761F84383C3882F37A11D9D0159007263EB2'; + let blockService: BlockService; + let account: Account; + before(() => { + account = TestingAccount; + const mockBlockRepository = mock(); + const mockReceiptRepository = mock(); + const mockRepoFactory = mock(); + + when(mockBlockRepository.getBlockByHeight(deepEqual(UInt64.fromUint(1)))) + .thenReturn(observableOf(mockBlockInfo())); + when(mockBlockRepository.getBlockByHeight(deepEqual(UInt64.fromUint(2)))) + .thenReturn(observableOf(mockBlockInfo(true))); + when(mockBlockRepository.getMerkleTransaction(deepEqual(UInt64.fromUint(1)), leaf)) + .thenReturn(observableOf(mockMerklePath())); + when(mockBlockRepository.getMerkleTransaction(deepEqual(UInt64.fromUint(2)), leaf)) + .thenReturn(observableOf(mockMerklePath())); + when(mockReceiptRepository.getMerkleReceipts(deepEqual(UInt64.fromUint(1)), leaf)) + .thenReturn(observableOf(mockMerklePath())); + when(mockReceiptRepository.getMerkleReceipts(deepEqual(UInt64.fromUint(2)), leaf)) + .thenReturn(observableOf(mockMerklePath())); + const blockRepository = instance(mockBlockRepository); + const receiptRepository = instance(mockReceiptRepository); + + when(mockRepoFactory.createBlockRepository()).thenReturn(blockRepository); + when(mockRepoFactory.createReceiptRepository()).thenReturn(receiptRepository); + const repoFactory = instance(mockRepoFactory); + blockService = new BlockService(repoFactory); + }); + + it('should validate transaction', async () => { + const result = await blockService.validateTransactionInBlock(leaf, UInt64.fromUint(1)).toPromise(); + expect(result).to.be.true; + }); + + it('should validate transaction - wrong hash', async () => { + const result = await blockService.validateTransactionInBlock(leaf, UInt64.fromUint(2)).toPromise(); + expect(result).to.be.false; + }); + + it('should validate statement', async () => { + const result = await blockService.validateStatementInBlock(leaf, UInt64.fromUint(1)).toPromise(); + expect(result).to.be.true; + }); + + it('should validate statement - wrong hash', async () => { + const result = await blockService.validateStatementInBlock(leaf, UInt64.fromUint(2)).toPromise(); + expect(result).to.be.false; + }); + + function mockBlockInfo(isFake: boolean = false): BlockInfo { + if (isFake) { + return new BlockInfo( + 'hash', 'generationHash', UInt64.fromNumericString('0'), 1, 'signature', account.publicAccount, + NetworkType.MIJIN_TEST, 0, 0, UInt64.fromUint(1), UInt64.fromUint(0), UInt64.fromUint(0), 0, 'previousHash', + 'fakeHash', 'fakeHash', 'stateHash', undefined, + ); + } + return new BlockInfo( + 'hash', 'generationHash', UInt64.fromNumericString('0'), 1, 'signature', account.publicAccount, + NetworkType.MIJIN_TEST, 0, 0, UInt64.fromUint(1), UInt64.fromUint(0), UInt64.fromUint(0), 0, 'previousHash', + mockBlockHash, mockBlockHash, 'stateHash', undefined, + ); + } + + function mockMerklePath(): MerkleProofInfo { + return new MerkleProofInfo( + [ + new MerklePathItem(1, 'CDE45D740536E5361F392025A44B26546A138958E69CD6F18D22908F8F11ECF2'), + new MerklePathItem(2, '4EF55DAB8FEF9711B23DA71D2ACC58EFFF3969C3D572E06ACB898F99BED4827A'), + new MerklePathItem(1, '1BB95470065ED69D184948A0175EDC2EAB9E86A0CEB47B648A58A02A5445AF66'), + new MerklePathItem(2, 'D96B03809B8B198EFA5824191A979F7B85C0E9B7A6623DAFF38D4B2927EFDFB5'), + new MerklePathItem(2, '9981EBDBCA8E36BA4D4D4A450072026AC8C85BA6497666219E0E049BE3362E51'), + ], + ); + } +});