import { Injectable } from '@angular/core'; import { GasRefundApiService } from '@core/services/backend/gas-refund-api/gas-refund-api.service'; import { BehaviorSubject, from, Observable } from 'rxjs'; import { Promotion } from '@features/my-trades/models/promotion'; import { AuthService } from '@core/services/auth/auth.service'; import { filter, map, switchMap } from 'rxjs/operators'; import { soliditySha3 } from 'web3-utils'; import BigNumber from 'bignumber.js'; import { MerkleTree } from 'merkletreejs'; import { RootData } from '@features/my-trades/models/root-data'; import { EthLikeWeb3PrivateService } from '@core/services/blockchain/blockchain-adapters/eth-like/web3-private/eth-like-web3-private.service'; import { PublicBlockchainAdapterService } from '@core/services/blockchain/blockchain-adapters/public-blockchain-adapter.service'; import { REFUND_ABI } from '@features/my-trades/constants/refund-abi'; import { UnknownError } from '@core/errors/models/unknown.error'; import { TransactionReceipt } from 'web3-eth'; import { REFUND_ADDRESS } from '@features/my-trades/constants/refund-address'; import { WalletConnectorService } from 'src/app/core/services/blockchain/wallets/wallet-connector-service/wallet-connector.service'; import { mapToVoid } from '@shared/utils/utils'; import { BlockchainsInfo } from '@core/services/blockchain/blockchain-info'; import CustomError from '@core/errors/models/custom-error'; @Injectable() export class GasRefundService { public readonly userPromotions$: Observable<Promotion[]>; private refundBlockchain = REFUND_ADDRESS.blockchain; private readonly refundContractAbi = REFUND_ABI; private refundContractAddress = REFUND_ADDRESS.address; private readonly _userPromotions$ = new BehaviorSubject<Promotion[]>([]); public get userPromotions(): Promotion[] { return this._userPromotions$.getValue(); } constructor( private readonly gasRefundApiService: GasRefundApiService, private readonly authService: AuthService, private readonly web3Private: EthLikeWeb3PrivateService, private readonly publicBlockchainAdapterService: PublicBlockchainAdapterService, private readonly walletConnectorService: WalletConnectorService ) { this.userPromotions$ = this._userPromotions$.asObservable(); this.setUpdatePromotionsSubscription(); } /** * Specific hash function based on keccak256. Hashes similarly to functions from a merkle contract. * @param arg two values to hash. Each value takes 32 byte. */ private static merkleKeccak256(arg: Uint8Array): string { const first = `0x${(<Buffer>arg).toString('hex').slice(0, 64)}`; const second = `0x${(<Buffer>arg).toString('hex').slice(64)}`; let res; if (new BigNumber(first).lt(second)) { res = soliditySha3(first, second); } else { res = soliditySha3(second, first); } return res.slice(2); } /** * Subscribes to user changes and updates promotions when it emits. */ private setUpdatePromotionsSubscription(): void { this.authService .getCurrentUser() .pipe(filter(user => !!user?.address)) .subscribe(() => this.updateUserPromotions()); } /** * Fetches actual user promotions list, updates promotions storage, then emits void and completes stream. */ public updateUserPromotions(): Observable<void> { const userPromotions$ = this.gasRefundApiService.getUserPromotions(); const comparator = (a: Promotion, b: Promotion) => a.refundDate.valueOf() - b.refundDate.valueOf(); userPromotions$ .pipe(map(promotions => [...promotions].sort(comparator))) .subscribe(promotions => this._userPromotions$.next(promotions)); return userPromotions$.pipe(mapToVoid()); } /** * Calculates the proof for a refund, sends a refund transaction. If successful, notifies the backend of a successful refund. * @param promotionId promotion id to refund. * @param onTransactionHash a function to be called after sending a refund transaction. * @return stream that emits the transaction hash once and completes. */ public refund( promotionId: number, onTransactionHash?: (hash: string) => void ): Observable<string> { const address = this.authService.userAddress; return from(this.checkChain()).pipe( filter(success => success), switchMap(() => this.gasRefundApiService.getPromotionMerkleData(promotionId)), switchMap(({ leaves, rootIndex, amount }) => { // leaf is keccak256(abi.encodePacked(address, weiAmount)) const leaf = soliditySha3(address, amount.toFixed(0)); const tree = new MerkleTree(leaves, GasRefundService.merkleKeccak256); const root = `0x${tree.getRoot().toString('hex')}`; const proof = tree.getHexProof(leaf); return from(this.sendRefund(proof, amount, { root, rootIndex }, onTransactionHash)).pipe( map(receipt => ({ txHash: receipt.transactionHash, leaf })) ); }), switchMap(({ txHash, leaf }) => { return this.gasRefundApiService.markPromotionAsUsed(txHash, leaf).pipe(map(() => txHash)); }) ); } /** * Checks the network selected in the wallet for compliance with the contract network, and switches the network if necessary. * @return is the correct network selected as a result. */ private checkChain(): Promise<boolean> { if (this.walletConnectorService.networkName !== this.refundBlockchain) { return this.walletConnectorService.switchChain(this.refundBlockchain); } return Promise.resolve(true); } /** * Safely calls the contract method to refund gas. * @param proof merkle tree proof. * @param amount BRBC amount to refund in wei. * @param rootData root index and root hash. * @param onTransactionHash a function to be called after sending a refund transaction. * @return refund promise resolved by transaction receipt. */ private async sendRefund( proof: string[], amount: BigNumber, rootData: RootData, onTransactionHash?: (hash: string) => void ): Promise<TransactionReceipt> { const address = this.authService.userAddress; if (BlockchainsInfo.getBlockchainType(this.refundBlockchain) !== 'ethLike') { throw new CustomError('Wrong blockchain error'); } const blockchainAdapter = this.publicBlockchainAdapterService[this.refundBlockchain]; const hexRootFromContract = await blockchainAdapter.callContractMethod( this.refundContractAddress, this.refundContractAbi, 'merkleRoots', { methodArguments: [rootData.rootIndex] } ); if (hexRootFromContract !== rootData.root) { throw new UnknownError( `Roots are not equal: expected ${rootData.root} but got ${hexRootFromContract}` ); } return this.web3Private.tryExecuteContractMethod( this.refundContractAddress, this.refundContractAbi, 'getTokensByMerkleProof', [proof, address, amount.toFixed(0), rootData.rootIndex], { onTransactionHash } ); } }