import { EventEmitter } from 'eventemitter3' ; import { parseRequest , CreateChannelRequest , UpdateChannelRequest , CloseChannelRequest , JoinChannelRequest , StateChannelsResponse , ChannelResult , StateChannelsNotification , ChannelClosingNotification , ChannelUpdatedNotification , ApproveBudgetAndFundRequest , ChannelProposedNotification , CloseAndWithdrawRequest , StateChannelsErrorResponse , ChallengeChannelRequest , parseResponse , FundingStrategy } from '@statechannels/client-api-schema' ; import { fromEvent , Observable } from 'rxjs' ; import { validateMessage } from '@statechannels/wire-format' ; import { Payload , DomainBudget , Participant , unreachable , isSimpleEthAllocation , makeDestination , serializeDomainBudget , deserializeMessage , serializeMessage , deserializeAllocations , deserializeBudgetRequest , makeAddress } from '@statechannels/wallet-core' ; import { serializeChannelEntry } from './utils/wallet-core-v0.8.0' ; import { AppRequestEvent } from './event-types' ; import { CHALLENGE_DURATION , GIT_VERSION , CHAIN_NETWORK_ID , HUB_PARTICIPANT_ID , HUB_ADDRESS , HUB_DESTINATION , WALLET_VERSION } from './config' ; import { Store } from './store' ; type ChannelRequest = ChallengeChannelRequest | CreateChannelRequest | JoinChannelRequest | UpdateChannelRequest | CloseChannelRequest | ApproveBudgetAndFundRequest | CloseAndWithdrawRequest ; interface InternalEvents { AppRequest : [ AppRequestEvent ] ; CreateChannelRequest : [ CreateChannelRequest ] ; SendMessage : [ StateChannelsResponse | StateChannelsNotification | StateChannelsErrorResponse ] ; } export const isChannelUpdated = ( m ) : m is ChannelUpdatedNotification => 'method' in m && m . method === 'ChannelUpdated' ; export const isChannelProposed = ( m ) : m is ChannelProposedNotification => 'method' in m && m . method === 'ChannelProposed' ; export interface MessagingServiceInterface { readonly outboxFeed : Observable < StateChannelsResponse | StateChannelsNotification | StateChannelsErrorResponse > ; readonly requestFeed : Observable < AppRequestEvent > ; receiveRequest ( jsonRpcMessage , fromDomain ) : Promise < void > ; sendBudgetNotification ( notificationData ) : Promise < void > ; sendChannelNotification ( method , notificationData ) ; sendChannelNotification ( method , notificationData ) ; sendMessageNotification ( message ) : Promise < void > ; sendDisplayMessage ( displayMessage ) ; sendResponse ( id , result ) : Promise < void > ; sendError ( id , error ) : Promise < void > ; } export class MessagingService implements MessagingServiceInterface { private eventEmitter = new EventEmitter < InternalEvents > ( ) ; constructor ( private store ) { this . eventEmitter = new EventEmitter ( ) ; } public get outboxFeed ( ) : Observable < StateChannelsResponse > { return fromEvent ( this . eventEmitter , 'SendMessage' ) ; } get requestFeed ( ) : Observable < AppRequestEvent > { return fromEvent ( this . eventEmitter , 'AppRequest' ) ; } public async sendResponse ( id , result ) { const response = parseResponse ( { id , jsonrpc : '2.0' , result } ) ; this . eventEmitter . emit ( 'SendMessage' , response ) ; } public async sendError ( id , error ) { const response = { id , jsonrpc : '2.0' , error } as StateChannelsErrorResponse ; this . eventEmitter . emit ( 'SendMessage' , response ) ; } public async sendBudgetNotification ( notificationData ) { const notification = { jsonrpc : '2.0' , method : 'BudgetUpdated' , params : serializeDomainBudget ( notificationData ) } ; this . eventEmitter . emit ( 'SendMessage' , notification ) ; } public async sendChannelNotification ( method , notificationData ) ; public async sendChannelNotification ( method , notificationData ) ; public async sendChannelNotification ( method , notificationData ) { const notification = { jsonrpc : '2.0' , method , params : notificationData } ; this . eventEmitter . emit ( 'SendMessage' , notification ) ; } public async sendMessageNotification ( message ) { const ourAddress = await this . store . getAddress ( ) ; const sender = ourAddress ; const objectiveRecipients = message . objectives ?. map ( o => o . participants ) . reduce ( ( a , b ) => a . concat ( b ) , [ ] ) || [ ] ; const stateRecipients = message . signedStates ?. map ( ss => ss . participants ) . reduce ( ( a , b ) => a . concat ( b ) , [ ] ) || [ ] ; const filteredRecipients = [ ... new Set ( ( objectiveRecipients || [ ] ) . concat ( stateRecipients ) ) ] . filter ( p => p . signingAddress !== sender ) . map ( p => p . participantId ) ; filteredRecipients . forEach ( recipient => { const notification = { jsonrpc : '2.0' , method : 'MessageQueued' , params : validateMessage ( serializeMessage ( WALLET_VERSION , message , recipient , sender ) ) } ; this . eventEmitter . emit ( 'SendMessage' , notification ) ; } ) ; } public sendDisplayMessage ( displayMessage ) { const showWallet = displayMessage === 'Show' ; const notification = { jsonrpc : '2.0' , method : 'UIUpdate' , params : { showWallet } } ; this . eventEmitter . emit ( 'SendMessage' , notification ) ; } public async receiveRequest ( jsonRpcRequest , fromDomain ) { const request = parseRequest ( jsonRpcRequest ) ; const { id : requestId } = request ; switch ( request . method ) { case 'GetWalletInformation' : await this . sendResponse ( requestId , { signingAddress : await this . store . getAddress ( ) , destinationAddress : ( await this . store . getDestinationAddress ( ) ) ?? undefined , walletVersion : GIT_VERSION } ) ; break ; case 'EnableEthereum' : const destinationAddress = await this . store . getDestinationAddress ( ) ; if ( this . store . chain . ethereumIsEnabled && destinationAddress ) { await this . sendResponse ( requestId , { signingAddress : await this . store . getAddress ( ) , destinationAddress , walletVersion : GIT_VERSION } ) ; } else { this . eventEmitter . emit ( 'AppRequest' , { type : 'ENABLE_ETHEREUM' , requestId } ) ; } break ; case 'GetChannels' : const channelEntries = await this . store . getApplicationChannels ( fromDomain , request . params . includeClosed ) ; const serializedChannelEntries = await Promise . all ( channelEntries . map ( serializeChannelEntry ) ) ; await this . sendResponse ( requestId , serializedChannelEntries ) ; break ; case 'ChallengeChannel' : case 'CreateChannel' : case 'UpdateChannel' : case 'CloseChannel' : case 'JoinChannel' : case 'ApproveBudgetAndFund' : case 'CloseAndWithdraw' : const appRequest = await convertToInternalEvent ( request , this . store , fromDomain ) ; this . eventEmitter . emit ( 'AppRequest' , appRequest ) ; break ; case 'PushMessage' : const message = validateMessage ( request . params ) ; if ( message . recipient !== ( await this . store . getAddress ( ) ) ) { throw new Error ( `Received message not addressed to us ${ JSON . stringify ( message ) }` ) ; } await this . store . pushMessage ( deserializeMessage ( message ) ) ; await this . sendResponse ( requestId , { success : true } ) ; break ; case 'GetBudget' : const DomainBudget = await this . store . getBudget ( fromDomain ) ; await this . sendResponse ( requestId , DomainBudget ? serializeDomainBudget ( DomainBudget ) : { } ) ; break ; case 'GetState' : case 'SyncChannel' : break ; default : unreachable ( request ) ; } } } export function convertToInternalParticipant ( { destination , signingAddress , participantId } ) { return { participantId , signingAddress : makeAddress ( signingAddress ) , destination : makeDestination ( destination ) } ; } async function convertToInternalEvent ( request , store , domain ) { switch ( request . method ) { case 'ChallengeChannel' : return { type : 'PLAYER_REQUEST_CHALLENGE' , requestId : request . id , channelId : request . params . channelId } ; case 'CloseAndWithdraw' : const closeAndWithdrawDestination = await store . getDestinationAddress ( ) ; if ( ! closeAndWithdrawDestination ) { throw new Error ( 'No selected destination' ) ; } if ( ! ( request . params . hubParticipantId !== HUB_PARTICIPANT_ID ) ) { throw new Error ( `You may only closeAndWithdraw for hub with id ${ HUB_PARTICIPANT_ID }` ) ; } return { type : 'CLOSE_AND_WITHDRAW' , requestId : request . id , player : convertToInternalParticipant ( { participantId : await store . getAddress ( ) , signingAddress : await store . getAddress ( ) , destination : closeAndWithdrawDestination } ) , hub : convertToInternalParticipant ( { participantId : request . params . hubParticipantId , signingAddress : HUB_ADDRESS , destination : HUB_DESTINATION } ) , domain : domain } ; case 'ApproveBudgetAndFund' : const { hub , playerParticipantId } = request . params ; const signingAddress = await store . getAddress ( ) ; const destination = await store . getDestinationAddress ( ) ; if ( ! destination ) { throw new Error ( 'No selected destination' ) ; } return { type : 'APPROVE_BUDGET_AND_FUND' , requestId : request . id , budget : deserializeBudgetRequest ( request . params , domain ) , player : convertToInternalParticipant ( { participantId : playerParticipantId , signingAddress , destination } ) , hub : convertToInternalParticipant ( hub ) } ; case 'CloseChannel' : return { type : 'PLAYER_REQUEST_CONCLUDE' , requestId : request . id , channelId : request . params . channelId } ; case 'CreateChannel' : { const outcome = deserializeAllocations ( request . params . allocations ) ; if ( ! isSimpleEthAllocation ( outcome ) ) { throw new Error ( 'Currently only a simple ETH allocation is supported' ) ; } const fundingStrategy = supportedFundingStrategyOrThrow ( request . params . fundingStrategy ) ; return { type : 'CREATE_CHANNEL' , ... request . params , fundingStrategy , participants : request . params . participants . map ( convertToInternalParticipant ) , outcome , challengeDuration : CHALLENGE_DURATION , chainId : CHAIN_NETWORK_ID , requestId : request . id , applicationDomain : domain } ; } case 'JoinChannel' : return { type : 'JOIN_CHANNEL' , ... request . params , requestId : request . id , applicationDomain : domain } ; case 'UpdateChannel' : const outcome = deserializeAllocations ( request . params . allocations ) ; if ( ! isSimpleEthAllocation ( outcome ) ) { throw new Error ( 'Currently only a simple ETH allocation is supported' ) ; } return { type : 'PLAYER_STATE_UPDATE' , requestId : request . id , outcome , channelId : request . params . channelId , appData : request . params . appData } ; } } export type SupportedFundingStrategy = Exclude < FundingStrategy , 'Unknown' | 'Fake' > ; function isSupportedFundingStrategy ( fs ) { return fs !== 'Fake' && fs !== 'Unknown' ; } export function supportedFundingStrategyOrThrow ( fs ) { if ( ! isSupportedFundingStrategy ( fs ) ) { throw new Error ( `Unsupported funding strategy ${ fs }` ) ; } return fs ; }