import _ from 'lodash' ; import { BN , ChannelConstants , unreachable } from '@statechannels/wallet-core' ; import { State } from '../models/channel/state' ; import { SimpleAllocationOutcome } from '../models/channel/outcome' ; import { RichLedgerRequest } from '../models/ledger-request' ; interface ReadonlyChannel extends ChannelConstants { myIndex : number ; latestTurnNum : number ; uniqueStateAt ( turn ) : State | undefined ; } export class LedgerProtocol { param ledger param requests returns crank ( ledger , requests ) { const ledgerState = this . determineLedgerState ( ledger ) ; const amLeader = ledger . myIndex === 0 ; let statesToSign ; switch ( ledgerState . type ) { case 'agreement' : statesToSign = amLeader ? this . crankAsLeaderInAgreement ( ledgerState , requests ) : this . crankAsFollowerInAgreement ( ledgerState , requests ) ; break ; case 'proposal' : statesToSign = amLeader ? [ ] : this . crankAsFollowerInProposal ( ledgerState , requests ) ; break ; case 'counter-proposal' : statesToSign = amLeader ? this . crankAsLeaderInCounterProposal ( ledgerState , requests ) : [ ] ; break ; case 'protocol-violation' : throw new Error ( 'protocol violation' ) ; default : unreachable ( ledgerState ) ; } return statesToSign ; } private crankAsLeaderInAgreement ( ledgerState , requests ) { const { agreed } = ledgerState ; const statesToSign = [ ] ; this . processAgreedState ( agreed , requests ) ; if ( _ . some ( requests , r => r . isQueued ) ) { const proposedOutcome = this . buildOutcome ( agreed , requests . filter ( r => r . isQueued ) ) ; const proposed = agreed . advanceToOutcome ( proposedOutcome ) ; if ( ! proposedOutcome . isEqualTo ( agreed . simpleAllocationOutcome ) ) { this . markIncludedRequestsAsPending ( requests , proposed ) ; statesToSign . push ( proposed ) ; } } return statesToSign ; } private crankAsFollowerInAgreement ( ledgerState , requests ) { this . processAgreedState ( ledgerState . agreed , requests ) ; return [ ] ; } private crankAsLeaderInCounterProposal ( ledgerState , requests ) { const { agreed , counterProposed } = ledgerState ; const result = this . compareChangesWithRequests ( requests , agreed , counterProposed ) ; if ( result . type !== 'full-agreement' ) throw new Error ( 'protocol error' ) ; const stateToSign = counterProposed ; const otherStatesToSign = this . crankAsLeaderInAgreement ( { type : 'agreement' , agreed : counterProposed } , requests ) ; return [ stateToSign , ... otherStatesToSign ] ; } private crankAsFollowerInProposal ( ledgerState , requests ) { const statesToSign = [ ] ; const { agreed , proposed } = ledgerState ; this . processAgreedState ( agreed , requests ) ; const result = this . compareChangesWithRequests ( requests , agreed , proposed ) ; switch ( result . type ) { case 'full-agreement' : statesToSign . push ( proposed ) ; this . processAgreedState ( proposed , requests ) ; break ; case 'some-overlap' : case 'no-overlap' : { const counterProposed = proposed . advanceToOutcome ( result . narrowedOutcome ) ; statesToSign . push ( counterProposed ) ; this . markIncludedRequestsAsPending ( requests , counterProposed ) ; break ; } default : unreachable ( result ) ; } return statesToSign ; } private processAgreedState ( agreedState , requests ) { const { cancellationDefunds , nonCancellations } = this . identifyPotentialCancellations ( requests ) ; this . markSuccessesAndInconsistencies ( nonCancellations , agreedState ) ; this . resetPendingRequestsToQueued ( requests ) ; const nonApplicableCancellations = this . applyCancellations ( cancellationDefunds , requests , agreedState . turnNum ) ; this . markSuccessesAndInconsistencies ( nonApplicableCancellations , agreedState ) ; this . updateStateSeenAndMissedOps ( requests , agreedState . turnNum ) ; } private markSuccessesAndInconsistencies ( requests , agreedState ) { const agreedOutcome = agreedState . simpleAllocationOutcome ; if ( ! agreedOutcome ) throw Error ( "Ledger state doesn't have a simple allocation outcome" ) ; const [ fundings , defundings ] = _ . partition ( requests , r => r . isFund ) ; for ( const defund of defundings ) { const matches = agreedOutcome . destinations . filter ( d => d === defund . channelToBeFunded ) ; if ( matches . length > 1 ) { throw new Error ( `Duplicate entries for destination` ) ; } else if ( matches . length === 0 ) { defund . status = 'succeeded' ; defund . lastSeenAgreedState = agreedState . turnNum ; } else { const outcomeBal = agreedOutcome . balanceFor ( defund . channelToBeFunded ) ; const amountsMatch = outcomeBal && BN . eq ( outcomeBal , defund . totalAmount ) ; if ( ! amountsMatch ) { defund . status = 'inconsistent' ; defund . lastSeenAgreedState = agreedState . turnNum ; } } } const includedFundings = _ . intersectionWith ( fundings , agreedOutcome . destinations , ( fund , dest ) => fund . channelToBeFunded === dest ) ; includedFundings . forEach ( f => { const outcomeBal = agreedOutcome . balanceFor ( f . channelToBeFunded ) ; const amountsMatch = outcomeBal && BN . eq ( outcomeBal , f . totalAmount ) ; f . status = amountsMatch ? 'succeeded' : 'inconsistent' ; f . lastSeenAgreedState = agreedState . turnNum ; } ) ; } private updateStateSeenAndMissedOps ( requests , turnNum ) { requests . filter ( r => r . isQueued ) . forEach ( r => { if ( r . lastSeenAgreedState !== turnNum ) { if ( r . lastSeenAgreedState ) r . missedOpportunityCount = 1 + r . missedOpportunityCount ; r . lastSeenAgreedState = turnNum ; } } ) ; } private resetPendingRequestsToQueued ( requests ) { requests . filter ( r => r . isPending ) . forEach ( r => ( r . status = 'queued' ) ) ; } private markIncludedRequestsAsPending ( requests , proposed ) { const proposedOutcome = proposed . simpleAllocationOutcome ; if ( ! proposedOutcome ) throw Error ( "Ledger state doesn't have a simple allocation outcome" ) ; const [ fundings , defundings ] = _ . partition ( requests . filter ( r => r . isQueued ) , r => r . isFund ) ; const missingDefundings = _ . differenceWith ( defundings , proposedOutcome . destinations , ( defund , dest ) => defund . channelToBeFunded === dest ) ; missingDefundings . forEach ( d => ( d . status = 'pending' ) ) ; const includedFundings = _ . intersectionWith ( fundings , proposedOutcome . destinations , ( fund , dest ) => fund . channelToBeFunded === dest ) ; includedFundings . forEach ( f => { const outcomeBal = proposedOutcome . balanceFor ( f . channelToBeFunded ) ; const amountsMatch = outcomeBal && BN . eq ( outcomeBal , f . totalAmount ) ; if ( ! amountsMatch ) throw new Error ( "amounts don't match" ) ; f . status = 'pending' ; } ) ; } private identifyPotentialCancellations ( requests ) { const [ fundings , defundings ] = _ . partition ( requests , r => r . isFund ) ; const cancellationDefunds = _ . intersectionWith ( defundings , fundings , ( x , y ) => x . channelToBeFunded === y . channelToBeFunded ) ; const nonCancellations = _ . difference ( requests , cancellationDefunds ) ; for ( const d of cancellationDefunds ) { if ( ! d . isQueued ) throw new Error ( `cancellation defund is not in queued state: ${ d . $id }` ) ; } return { cancellationDefunds , nonCancellations } ; } private applyCancellations ( cancellations , requests , turnNum ) { const nonApplicableCancellations = [ ] ; for ( const defund of cancellations ) { const fund = requests . find ( r => r . isFund && r . channelToBeFunded === defund . channelToBeFunded ) ; if ( fund && fund . isQueued ) { fund . status = 'cancelled' ; fund . lastSeenAgreedState = turnNum ; defund . status = 'cancelled' ; defund . lastSeenAgreedState = turnNum ; } else { nonApplicableCancellations . push ( defund ) ; } } return nonApplicableCancellations ; } private compareChangesWithRequests ( requests , baselineState , candidateState ) { const baselineOutcome = baselineState . simpleAllocationOutcome ; const candidateOutcome = candidateState . simpleAllocationOutcome ; if ( ! baselineOutcome || ! candidateOutcome ) throw Error ( "Ledger state doesn't have simple allocation outcome" ) ; const changedDestinations = _ . xor ( baselineOutcome . destinations , candidateOutcome . destinations ) ; if ( changedDestinations . length === 0 ) return { type : 'full-agreement' } ; const { nonCancellations } = this . identifyPotentialCancellations ( requests ) ; const overlappingRequests = nonCancellations . filter ( req => changedDestinations . includes ( req . channelToBeFunded ) ) ; if ( overlappingRequests . length === 0 ) return { type : 'no-overlap' , narrowedOutcome : baselineOutcome } ; const calculatedOutcome = this . buildOutcome ( baselineState , overlappingRequests ) ; if ( overlappingRequests . length === changedDestinations . length ) { if ( candidateOutcome . isEqualTo ( calculatedOutcome ) ) { return { type : 'full-agreement' } ; } else { throw new Error ( 'Outcomes inconsistent' ) ; } } else { return { type : 'some-overlap' , narrowedOutcome : calculatedOutcome } ; } } private determineLedgerState ( ledger ) { const [ leader , follower ] = ledger . participants . map ( p => p . signingAddress ) ; const latestTurnNum = ledger . latestTurnNum ; const latestState = ledger . uniqueStateAt ( latestTurnNum ) ; if ( ! latestState ) return { type : 'protocol-violation' } ; if ( latestState . fullySigned ) { return { type : 'agreement' , agreed : latestState } ; } const penultimateState = ledger . uniqueStateAt ( latestTurnNum - 1 ) ; if ( ! penultimateState ) return { type : 'protocol-violation' } ; if ( penultimateState . fullySigned ) { if ( latestState . signedBy ( leader ) ) { return { type : 'proposal' , proposed : latestState , agreed : penultimateState } ; } else { return { type : 'protocol-violation' } ; } } const antepenultimateState = ledger . uniqueStateAt ( latestTurnNum - 2 ) ; if ( ! antepenultimateState ) return { type : 'protocol-violation' } ; if ( antepenultimateState . fullySigned && penultimateState . signedBy ( leader ) && latestState . signedBy ( follower ) ) { return { type : 'counter-proposal' , counterProposed : latestState , proposed : penultimateState , agreed : antepenultimateState , } ; } else { return { type : 'protocol-violation' } ; } } private buildOutcome ( state , requests ) { if ( ! state . simpleAllocationOutcome ) throw Error ( "Ledger doesn't have simple allocation outcome" ) ; let currentOutcome = state . simpleAllocationOutcome . dup ( ) ; for ( const defundReq of requests . filter ( r => r . isDefund ) ) { const updatedOutcome = currentOutcome . remove ( defundReq . channelToBeFunded , state . participantDestinations , [ defundReq . amountA , defundReq . amountB ] ) ; if ( updatedOutcome ) { currentOutcome = updatedOutcome ; } else { defundReq . status = 'inconsistent' ; } } for ( const fundingReq of _ . sortBy ( requests . filter ( r => r . isFund ) , 'channelToBeFunded' ) ) { const updatedOutcome = currentOutcome . add ( fundingReq . channelToBeFunded , state . participantDestinations , [ fundingReq . amountA , fundingReq . amountB ] ) ; if ( updatedOutcome ) { currentOutcome = updatedOutcome ; } else { fundingReq . status = 'insufficient-funds' ; } } return currentOutcome ; } } type LedgerState = Agreement | Proposal | CounterProposal | ProtocolViolation ; type Agreement = { type : 'agreement' ; agreed : State ; } ; type Proposal = { type : 'proposal' ; agreed : State ; proposed : State ; } ; type CounterProposal = { type : 'counter-proposal' ; counterProposed : State ; proposed : State ; agreed : State ; } ; type ProtocolViolation = { type : 'protocol-violation' ; } ; type CompareChangesWithRequestsResult = FullAgreement | SomeOverlap | NoOverlap ; type FullAgreement = { type : 'full-agreement' ; } ; type SomeOverlap = { type : 'some-overlap' ; narrowedOutcome : SimpleAllocationOutcome ; } ; type NoOverlap = { type : 'no-overlap' ; narrowedOutcome : SimpleAllocationOutcome ; } ;