import { ContractArtifacts , TestContractArtifacts , createERC20DepositTransaction , createETHDepositTransaction , getChallengeRegisteredEvent , getChannelId , Transactions , computeNewOutcome , } from '@statechannels/nitro-protocol' ; import { Address , BN , fromNitroSignedState , makeAddress , PrivateKey , SignedState , State , toNitroSignedState , toNitroState , unreachable , } from '@statechannels/wallet-core' ; import { constants , Contract , Event , providers , Wallet } from 'ethers' ; import { NonceManager } from '@ethersproject/experimental' ; import PQueue from 'p-queue' ; import { Logger } from 'pino' ; import _ from 'lodash' ; const MAGIC_ADDRESS_INDICATING_ETH = constants . AddressZero ; import { Bytes32 } from '../type-aliases' ; import { createLogger } from '../logger' ; import { defaultTestWalletConfig } from '../config' ; import { AllowanceMode , AllocationUpdatedArg , ChainEventSubscriberInterface , ChainRequest , ChainServiceArgs , ChainServiceInterface , FundChannelArg , HoldingUpdatedArg , } from './types' ; import { EventTracker } from './event-tracker' ; const Deposited = 'Deposited' as const ; const AllocationUpdated = 'AllocationUpdated' as const ; const ChallengeRegistered = 'ChallengeRegistered' as const ; const Concluded = 'Concluded' as const ; type DepositedEvent = { type : 'Deposited' ; ethersEvent : Event ; } & HoldingUpdatedArg ; type AllocationUpdatedEvent = { type : typeof AllocationUpdated ; ethersEvent : Event ; } & AllocationUpdatedArg ; export class ChainService implements ChainServiceInterface { private logger : Logger ; private readonly ethWallet : NonceManager ; private provider : providers . JsonRpcProvider ; private allowanceMode : AllowanceMode ; private channelToEventTrackers : Map < Bytes32 , EventTracker [ ] > = new Map ( ) ; private nitroAdjudicator : Contract ; private readonly blockConfirmations : number ; private transactionQueue = new PQueue ( { concurrency : 1 } ) ; private finalizingChannels : { finalizesAtS : number ; channelId : Bytes32 ; } [ ] = [ ] ; constructor ( { provider , pk , pollingInterval , logger , blockConfirmations , allowanceMode , } ) { if ( ! pk ) throw new Error ( 'ChainService: Private key not provided' ) ; this . provider = new providers . JsonRpcProvider ( provider ) ; this . ethWallet = new NonceManager ( new Wallet ( pk , this . provider ) ) ; this . blockConfirmations = blockConfirmations ?? 5 ; this . logger = logger ? logger . child ( { module : 'ChainService' } ) : createLogger ( defaultTestWalletConfig ( ) ) ; this . allowanceMode = allowanceMode || 'MaxUint' ; if ( provider && ( provider . includes ( '0.0.0.0' ) || provider . includes ( 'localhost' ) ) ) { pollingInterval = pollingInterval ?? 50 ; } if ( pollingInterval ) this . provider . pollingInterval = pollingInterval ; const nitroAdjudicatorAddress = makeAddress ( process . env . NITRO_ADJUDICATOR_ADDRESS || constants . AddressZero ) ; this . nitroAdjudicator = new Contract ( nitroAdjudicatorAddress , ContractArtifacts . NitroAdjudicatorArtifact . abi , this . ethWallet ) ; this . nitroAdjudicator . on ( ChallengeRegistered , ( ... args ) => { const event = getChallengeRegisteredEvent ( args ) ; this . addFinalizingChannel ( { channelId : event . channelId , finalizesAtS : event . finalizesAt } ) ; const { channelId , challengeStates , finalizesAt } = event ; this . channelToEventTrackers . get ( event . channelId ) ?. map ( subscriber => subscriber . challengeRegistered ( { channelId , challengeStates : challengeStates . map ( s => fromNitroSignedState ( s ) ) , finalizesAt , } ) ) ; } ) ; this . nitroAdjudicator . on ( Concluded , ( channelId , finalizesAtS ) => { this . addFinalizingChannel ( { channelId , finalizesAtS } ) ; } ) ; this . listenForAssetEvents ( this . nitroAdjudicator ) ; this . provider . on ( 'block' , async ( blockTag ) => this . checkFinalizingChannels ( await this . provider . getBlock ( blockTag ) ) ) ; } param chainRequests returns public async handleChainRequests ( chainRequests ) { if ( chainRequests . length === 0 ) { return [ ] ; } const responses = [ ] ; this . logger . trace ( { chainRequests } , 'Handling chain requests' ) ; for ( const chainRequest of chainRequests ) { let response ; switch ( chainRequest . type ) { case 'Challenge' : response = await this . challenge ( chainRequest . challengeStates , chainRequest . privateKey ) ; break ; case 'ConcludeAndWithdraw' : response = await this . concludeAndWithdraw ( chainRequest . finalizationProof ) ; break ; case 'FundChannel' : response = await this . fundChannel ( chainRequest ) ; break ; case 'Withdraw' : response = await this . withdraw ( chainRequest . state ) ; break ; default : unreachable ( chainRequest ) ; } responses . push ( response ) ; } return responses ; } public async checkChainId ( networkChainId ) { const rpcChainId = ( await this . provider . getNetwork ( ) ) . chainId ; if ( rpcChainId !== networkChainId ) throw Error ( `ChainId mismatch: ${ networkChainId } is required but provider reports ${ rpcChainId }` ) ; } destructor ( ) { this . logger . trace ( 'Starting destroy' ) ; this . channelToEventTrackers . clear ( ) ; this . provider . polling = false ; this . provider . removeAllListeners ( ) ; this . nitroAdjudicator . removeAllListeners ( ) ; this . logger . trace ( 'Completed destroy' ) ; } private async sendTransaction ( transactionRequest ) { return this . transactionQueue . add ( async ( ) => { try { this . logger . debug ( { ... transactionRequest } , 'Submitting transaction to the blockchain' ) ; return await this . ethWallet . sendTransaction ( transactionRequest ) ; } catch ( err ) { this . ethWallet . incrementTransactionCount ( - 1 ) ; this . logger . error ( { err } , 'Transaction submission failed' ) ; throw err ; } } ) ; } async fundChannel ( arg ) { this . logger . info ( { ... arg } , 'fundChannel: entry' ) ; let depositRequest ; if ( arg . asset === MAGIC_ADDRESS_INDICATING_ETH ) { depositRequest = { to : this . nitroAdjudicator . address , value : arg . amount , ... createETHDepositTransaction ( arg . channelId , arg . expectedHeld , arg . amount ) , } ; } else { await this . increaseAllowance ( arg . asset , arg . amount ) ; depositRequest = { to : this . nitroAdjudicator . address , ... createERC20DepositTransaction ( arg . asset , arg . channelId , arg . expectedHeld , arg . amount ) , } ; } const tx = await this . sendTransaction ( depositRequest ) ; this . logger . info ( { channelId : arg . channelId , nitroAdjudicatorAddress : this . nitroAdjudicator . address , tx : tx . hash , } , 'Finished funding channel' ) ; return tx ; } async concludeAndWithdraw ( finalizationProof ) { if ( ! finalizationProof . length ) throw new Error ( 'ChainService: concludeAndWithdraw was called with an empty array?' ) ; const channelId = getChannelId ( { ... finalizationProof [ 0 ] , participants : finalizationProof [ 0 ] . participants . map ( p => p . signingAddress ) , } ) ; this . logger . info ( { channelId } , 'concludeAndWithdraw: entry' ) ; const transactionRequest = { ... Transactions . createConcludeAndTransferAllAssetsTransaction ( finalizationProof . flatMap ( toNitroSignedState ) ) , to : this . nitroAdjudicator . address , } ; const captureExpectedErrors = async ( reason ) => { if ( reason . error ?. message . includes ( 'Channel finalized' ) ) { this . logger . warn ( { channelId , determinedBy : 'Revert reason' } , 'Transaction to conclude channel failed: channel is already finalized' ) ; throw new Error ( 'Conclude failed' ) ; } const [ , finalizesAt ] = await this . nitroAdjudicator . unpackStatus ( channelId ) ; const { timestamp : latestBlockTimestamp } = await this . provider . getBlock ( this . provider . getBlockNumber ( ) ) ; if ( latestBlockTimestamp >= Number ( finalizesAt ) ) { this . logger . warn ( { channelId , determinedBy : 'Javascript check' } , 'Transaction to conclude channel failed: channel is already finalized' ) ; throw new Error ( 'Conclude failed' ) ; } throw reason ; } ; const transactionResponse = this . sendTransaction ( transactionRequest ) . catch ( captureExpectedErrors ) ; transactionResponse . then ( receipt => { if ( receipt ) return receipt . wait ( ) ; return ; } ) . catch ( captureExpectedErrors ) ; return transactionResponse ; } async challenge ( challengeStates , privateKey ) { this . logger . info ( { challengeStates } , 'challenge: entry' ) ; if ( ! challengeStates . length ) { throw new Error ( 'Must challenge with at least one state' ) ; } const challengeTransactionRequest = { ... Transactions . createChallengeTransaction ( challengeStates . flatMap ( toNitroSignedState ) , privateKey ) , to : this . nitroAdjudicator . address , } ; return this . sendTransaction ( challengeTransactionRequest ) ; } async withdraw ( state ) { this . logger . info ( 'withdraw: entry' ) ; const lastState = toNitroState ( state ) ; const transactionRequest = { ... Transactions . createTransferAllAssetsTransaction ( lastState ) , to : this . nitroAdjudicator . address , } ; return this . sendTransaction ( transactionRequest ) ; } registerChannel ( channelId , assets , subscriber ) { this . logger . info ( { channelId } , 'registerChannel: entry' ) ; const eventTracker = new EventTracker ( subscriber , this . logger ) ; this . channelToEventTrackers . set ( channelId , [ ... ( this . channelToEventTrackers . get ( channelId ) ?? [ ] ) , eventTracker , ] ) ; for ( const asset of assets ) { this . getInitialHoldings ( asset , channelId , eventTracker ) ; } this . registerFinalizationStatus ( channelId ) ; } unregisterChannel ( channelId ) { this . logger . info ( { channelId } , 'unregisterChannel: entry' ) ; this . channelToEventTrackers . set ( channelId , [ ] ) ; } private async checkFinalizingChannels ( block ) { const finalizingChannel = this . finalizingChannels . shift ( ) ; if ( ! finalizingChannel ) return ; if ( finalizingChannel . finalizesAtS > block . timestamp ) { this . addFinalizingChannel ( finalizingChannel ) ; return ; } const { channelId } = finalizingChannel ; const [ , finalizesAt ] = await this . nitroAdjudicator . unpackStatus ( channelId ) ; if ( finalizesAt === finalizingChannel . finalizesAtS ) { this . channelToEventTrackers . get ( channelId ) ?. map ( subscriber => subscriber . channelFinalized ( { channelId , blockNumber : block . number , blockTimestamp : block . timestamp , finalizedAt : finalizingChannel . finalizesAtS , } ) ) ; } else if ( finalizesAt ) { this . addFinalizingChannel ( { channelId , finalizesAtS : finalizesAt } ) ; } this . checkFinalizingChannels ( block ) ; } private addFinalizingChannel ( arg ) { const { channelId , finalizesAtS } = arg ; if ( ! this . finalizingChannels . some ( c => c . channelId === channelId ) ) { this . finalizingChannels = [ ... this . finalizingChannels , { channelId , finalizesAtS : finalizesAtS } , ] ; this . finalizingChannels . sort ( ( a , b ) => a . finalizesAtS - b . finalizesAtS ) ; } } private async registerFinalizationStatus ( channelId ) { const finalizesAtS = await this . getFinalizedAt ( channelId ) ; if ( finalizesAtS !== 0 ) { this . addFinalizingChannel ( { channelId , finalizesAtS } ) ; } } private async getFinalizedAt ( channelId ) { const [ , finalizesAt ] = await this . nitroAdjudicator . unpackStatus ( channelId ) ; return finalizesAt ; } private async getInitialHoldings ( asset , channelId , eventTracker ) { if ( ! this . provider . polling ) return ; const currentBlock = await this . provider . getBlockNumber ( ) ; const confirmedBlock = currentBlock - this . blockConfirmations ; const currentHolding = BN . from ( await this . nitroAdjudicator . holdings ( asset , channelId ) ) ; let confirmedHolding = BN . from ( 0 ) ; try { const overrides = { blockTag : confirmedBlock , } ; confirmedHolding = BN . from ( await this . nitroAdjudicator . holdings ( asset , channelId , overrides ) ) ; this . logger . debug ( `Successfully read confirmedHoldings (${ confirmedHolding }), from block ${ confirmedBlock }.` ) ; } catch ( e ) { this . logger . error ( `Error caught while trying to query confirmed holding in block ${ confirmedBlock }. ` + `This is likely because the channel is new and is safe to ignore. The error is ${ JSON . stringify ( e ) }` ) ; } this . logger . debug ( `getConfirmedHoldings: from block ${ confirmedBlock }, confirmed holding ${ confirmedHolding }, current holding ${ currentHolding }.` ) ; /**
         * Report confirmed holding immediately.
         *
         * TODO: below can result in duplicate notifications to subscribers. Consider the following:
         * 1. Funds are deposited on block 0, generating a deposit event D.
         * 2. Events are defined as confirmed when the 5th block is mined after a transaction.
         * 3. A channel is registered with the chain service at block 5.
         * 4. We check for confirmed events:
         *      D is confirmed, so holdingUpdated is called with the block number 0 and the correct log index.
         * 5. We check for confirmed holdings:
         *      Holdings in block 5 are confirmed, so holdingUpdated is called with block number of 0 and the DEFAULT log index.
         * 6. Subscribers are notified of the holdings twice.
         */ eventTracker . holdingUpdated ( { channelId : channelId , asset , amount : confirmedHolding , } , confirmedBlock ) ; const ethersEvents = ( await this . nitroAdjudicator . queryFilter ( { } , confirmedBlock ) ) . sort ( e => e . blockNumber ) ; for ( const ethersEvent of ethersEvents ) { await this . waitForConfirmations ( ethersEvent ) ; this . onAssetEvent ( ethersEvent ) ; } } private async waitForConfirmations ( event ) { await ( await event . getTransaction ( ) ) . wait ( this . blockConfirmations + 1 ) ; this . logger . debug ( { tx : event . transactionHash } , 'Finished waiting for confirmations; considering transaction finalized' ) ; } private listenForAssetEvents ( adjudicatorContract ) { adjudicatorContract . on ( { } , async ( ethersEvent ) => { this . logger . trace ( { address : adjudicatorContract . address , event : ethersEvent } , 'Asset event being handled' ) ; await this . waitForConfirmations ( ethersEvent ) ; this . onAssetEvent ( ethersEvent ) ; } ) ; } private async onAssetEvent ( ethersEvent ) { switch ( ethersEvent . event ) { case Deposited : { const depositedEvent = await this . getDepositedEvent ( ethersEvent ) ; this . channelToEventTrackers . get ( depositedEvent . channelId ) ?. forEach ( eventTracker => { eventTracker . holdingUpdated ( depositedEvent , depositedEvent . ethersEvent . blockNumber , depositedEvent . ethersEvent . logIndex ) ; } ) ; } break ; case AllocationUpdated : { const AllocationUpdatedEvent = await this . getAllocationUpdatedEvent ( ethersEvent ) ; this . channelToEventTrackers . get ( AllocationUpdatedEvent . channelId ) ?. forEach ( eventTracker => { eventTracker . allocationUpdated ( AllocationUpdatedEvent , AllocationUpdatedEvent . ethersEvent . blockNumber , AllocationUpdatedEvent . ethersEvent . logIndex ) ; } ) ; } break ; default : this . logger . error ( `Unexpected event ${ ethersEvent }` ) ; } } private async increaseAllowance ( tokenAddress , amount ) { const tokenContract = new Contract ( tokenAddress , TestContractArtifacts . TokenArtifact . abi , this . ethWallet ) ; switch ( this . allowanceMode ) { case 'PerDeposit' : { const increaseAllowance = tokenContract . interface . encodeFunctionData ( 'increaseAllowance' , [ this . nitroAdjudicator . address , amount , ] ) ; const increaseAllowanceRequest = { data : increaseAllowance , to : tokenAddress , } ; const tx = await this . sendTransaction ( increaseAllowanceRequest ) ; this . logger . info ( { tx : tx . hash } , 'Transaction to increase asset holder token allowance successfully submitted' ) ; break ; } case 'MaxUint' : { const currentAllowance = await tokenContract . allowance ( await this . ethWallet . getAddress ( ) , this . nitroAdjudicator . address ) ; if ( BN . gt ( BN . div ( constants . MaxUint256 , 2 ) , currentAllowance ) ) { const approveAllowance = tokenContract . interface . encodeFunctionData ( 'approve' , [ this . nitroAdjudicator . address , constants . MaxUint256 , ] ) ; const approveAllowanceRequest = { data : approveAllowance , to : tokenContract . address , } ; const tx = await this . sendTransaction ( approveAllowanceRequest ) ; this . logger . info ( { tx : tx . hash } , 'Transaction to approve maximum amount of asset holder spending successfully submitted' ) ; break ; } } } } private async getDepositedEvent ( event ) { if ( ! event . args ) { throw new Error ( 'Deposited event must have args' ) ; } const [ destination , asset , _amountDeposited , destinationHoldings ] = event . args ; return { type : Deposited , channelId : destination , asset , amount : BN . from ( destinationHoldings ) , ethersEvent : event , } ; } private async getAllocationUpdatedEvent ( event ) { if ( ! event . args ) { throw new Error ( 'AllocationUpdated event must have args' ) ; } const [ channelId , assetIndex , initialHoldings ] = event . args ; const tx = await this . provider . getTransaction ( event . transactionHash ) ; const { newOutcome , newHoldings , externalPayouts , internalPayouts } = computeNewOutcome ( this . nitroAdjudicator . address , { channelId , assetIndex , initialHoldings } , tx ) ; return { type : AllocationUpdated , channelId : channelId , asset : makeAddress ( newOutcome [ assetIndex ] . asset ) , newHoldings : BN . from ( newHoldings ) , externalPayouts : externalPayouts , internalPayouts : internalPayouts , newAssetOutcome : newOutcome [ assetIndex ] , ethersEvent : event , } ; } param appDefinition public async fetchBytecode ( appDefinition ) { const result = await this . provider . getCode ( appDefinition ) ; if ( result === '0x' ) throw new Error ( 'Bytecode missing' ) ; return result ; } }