import { ServiceConfig, StateMachine, Machine, ActionFunction, DoneInvokeEvent, assign, MachineConfig, AssignAction, Interpreter } from 'xstate'; import { outcomesEqual, Participant, Objective, CloseLedger, DomainBudget, BN } from '@statechannels/wallet-core'; import {map, filter} from 'rxjs/operators'; import {ChannelChainInfo} from '../chain'; import {Store} from '../store'; import {MessagingServiceInterface} from '../messaging'; import {getDataAndInvoke} from '../utils'; import {CommonWorkflowActions, commonWorkflowActions, CommonActions} from '../utils/workflow-utils'; import {SupportState} from '.'; interface Initial { requestId: number; opponent: Participant; player: Participant; domain: string; } interface BudgetExists extends Initial { budget: DomainBudget; } interface LedgerExists extends BudgetExists { ledgerId: string; } interface Transaction { transactionId: string; } interface FundsWithdrawn { type: 'FUNDS_WITHDRAWN'; } type WorkflowTypeState = | {value: 'fetchBudget'; context: Initial} | {value: 'waitForUserApproval'; context: BudgetExists} | {value: 'createObjective'; context: LedgerExists} | {value: {closeLedger: 'constructFinalState'}; context: LedgerExists} | {value: {closeLedger: 'supportState'}; context: LedgerExists} | {value: {closeLedger: 'done'}; context: LedgerExists} | {value: {withdraw: 'submitTransaction'}; context: LedgerExists} | {value: {withdraw: 'waitMining'}; context: LedgerExists & Transaction} | {value: {withdraw: 'done'}; context: LedgerExists} | {value: 'done'; context: LedgerExists} | {value: 'clearBudget'; context: LedgerExists} | {value: 'budgetFailure'; context: Initial} | {value: 'userDeclinedFailure'; context: Initial}; export type WorkflowContext = WorkflowTypeState['context']; export type WorkflowEvent = | UserApproves | UserRejects | DoneInvokeEvent | DoneInvokeEvent | DoneInvokeEvent | DoneInvokeEvent | FundsWithdrawn; interface UserApproves { type: 'USER_APPROVES_CLOSE'; } interface UserRejects { type: 'USER_REJECTS_CLOSE'; } enum Actions { sendResponse = 'sendResponse', assignLedgerId = 'assignLedgerId', setTransactionId = 'setTransactionId', assignBudget = 'assignBudget' } enum Services { constructFinalState = 'constructFinalState', supportState = 'supportState', submitWithdrawTransaction = 'submitWithdrawTransaction', createObjective = 'createObjective', observeFundsWithdrawal = 'observeFundsWithdrawal', fetchBudget = 'fetchBudget', clearBudget = 'clearBudget' } export type WorkflowActions = CommonWorkflowActions & Record< Actions, ActionFunction | AssignAction >; export type WorkflowServices = Record>; export const config: MachineConfig = { id: 'close-and-withdraw', initial: 'fetchBudget', states: { fetchBudget: { invoke: { src: Services.fetchBudget, onDone: {target: 'waitForUserApproval', actions: [Actions.assignBudget]} } }, waitForUserApproval: { entry: [CommonActions.displayUI], on: { USER_APPROVES_CLOSE: {target: 'createObjective'}, USER_REJECTS_CLOSE: {target: 'userDeclinedFailure'} } }, createObjective: { invoke: { src: Services.createObjective, onDone: {target: 'closeLedger', actions: [Actions.assignLedgerId]} } }, closeLedger: getDataAndInvoke( {src: Services.constructFinalState}, {src: Services.supportState}, 'withdraw' ) as any, withdraw: { initial: 'submitTransaction', on: { FUNDS_WITHDRAWN: 'clearBudget' }, invoke: { id: 'observeChain', src: Services.observeFundsWithdrawal }, states: { submitTransaction: { invoke: { id: 'submitTransaction', src: Services.submitWithdrawTransaction, onDone: { target: 'waitMining', actions: [Actions.setTransactionId] } } }, waitMining: {} } }, clearBudget: { invoke: {src: Services.clearBudget, onDone: 'done', onError: 'budgetFailure'} }, done: {type: 'final', entry: [Actions.sendResponse, CommonActions.hideUI]}, userDeclinedFailure: { type: 'final', entry: [CommonActions.hideUI, CommonActions.sendUserDeclinedErrorResponse] }, budgetFailure: { type: 'final', entry: [CommonActions.hideUI] /* TODO Should we send a response? */ } } }; const constructFinalState = (store: Store) => async ({opponent: hub}) => { const {latestSignedByMe: latestSupportedByMe, latest} = await store.getLedger(hub.participantId); // If we've received a new final state that matches our outcome we support that if (latest.isFinal && outcomesEqual(latestSupportedByMe.outcome, latest.outcome)) { return {state: latest}; } // Otherwise send out our final state that we support if (latestSupportedByMe.isFinal) { return {state: latestSupportedByMe}; } // Otherwise create a new final state return { state: { ...latestSupportedByMe, turnNum: latestSupportedByMe.turnNum + 1, isFinal: true } }; }; const submitWithdrawTransaction = (store: Store) => async context => { // TODO: Should we just fetch this once and store on the context const ledgerEntry = await store.getLedger(context.opponent.participantId); if (!ledgerEntry.hasConclusionProof) { throw new Error(`Channel ${ledgerEntry.channelId} is not finalized`); } return store.chain.finalizeAndWithdraw(ledgerEntry.support); }; const createObjective = (store: Store) => async context => { const ledgerEntry = await store.getLedger(context.opponent.participantId); const objective: Objective = { type: 'CloseLedger', participants: [context.player, context.opponent], data: {ledgerId: ledgerEntry.channelId} }; await store.addObjective(objective); return objective; }; const observeFundsWithdrawal = (store: Store) => ({ledgerId}: LedgerExists) => store.chain.chainUpdatedFeed(ledgerId).pipe( filter(c => BN.eq(c.amount, 0)), map(() => ({type: 'FUNDS_WITHDRAWN'})) ); const clearBudget = (store: Store): ServiceConfig => async context => { await store.clearBudget(context.domain); }; const fetchBudget = (store: Store): ServiceConfig => async context => store.getBudget(context.domain); const assignBudget = (): AssignAction> => assign((context, event) => ({ ...context, budget: event.data })); const options = ( store: Store, messagingService: MessagingServiceInterface ): {services: WorkflowServices; actions: WorkflowActions} => ({ services: { constructFinalState: constructFinalState(store), supportState: SupportState.machine(store), submitWithdrawTransaction: submitWithdrawTransaction(store), createObjective: createObjective(store), observeFundsWithdrawal: observeFundsWithdrawal(store), clearBudget: clearBudget(store), fetchBudget: fetchBudget(store) }, actions: { ...commonWorkflowActions(messagingService), assignBudget: assignBudget(), setTransactionId: assign({ transactionId: (context, event: DoneInvokeEvent) => event.data }), sendResponse: async context => await messagingService.sendResponse(context.requestId, {success: true}), assignLedgerId: assign((context: Initial, event: DoneInvokeEvent) => ({ ...context, ledgerId: event.data.data.ledgerId })) } }); export const workflow = ( store: Store, messagingService: MessagingServiceInterface, context: WorkflowContext ): StateMachine => Machine(config) .withConfig(options(store, messagingService)) .withContext(context); export type CloseLedgerAndWithdrawService = Interpreter< WorkflowContext, any, WorkflowEvent, WorkflowTypeState >;