UNPKG

raiden-ts

Version:

Raiden Light Client Typescript/Javascript SDK

224 lines 12 kB
import { MaxUint256 } from '@ethersproject/constants'; import { uniq } from 'lodash/fp'; import unset from 'lodash/fp/unset'; import isEqual from 'lodash/isEqual'; import { BehaviorSubject, combineLatest, concat, from, merge, of, timer } from 'rxjs'; import { catchError, combineLatestWith, delayWhen, distinctUntilChanged, filter, finalize, first, ignoreElements, map, mergeMap, pluck, scan, skip, skipUntil, startWith, take, takeUntil, tap, withLatestFrom, } from 'rxjs/operators'; import { raidenConfigCaps, raidenShutdown } from './actions'; import { blockStale, blockTime, contractSettleTimeout } from './channels/actions'; import * as ChannelsEpics from './channels/epics'; import { Capabilities } from './constants'; import * as DatabaseEpics from './db/epics'; import { udcDeposit } from './services/actions'; import * as ServicesEpics from './services/epics'; import * as TransfersEpics from './transfers/epics'; import { matrixPresence, rtcChannel } from './transport/actions'; import * as TransportEpics from './transport/epics'; import { completeWith, pluckDistinct } from './utils/rx'; import { last } from './utils/types'; // default values for dynamic capabilities not specified on defaultConfig nor userConfig function dynamicCaps({ stale, udcDeposit, config: { monitoringReward }, }) { return { [Capabilities.RECEIVE]: !stale && monitoringReward?.gt(0) && monitoringReward.lte(udcDeposit.balance) ? 1 : 0, [Capabilities.WEBRTC]: 'RTCPeerConnection' in globalThis ? 1 : 0, }; } function mergeCaps(dynamicCaps, defaultCaps, userCaps) { // if userCaps is disabled, disables everything if (userCaps === null) return userCaps; // if userCaps is an object, merge all caps else if (userCaps !== undefined) return { ...dynamicCaps, ...defaultCaps, ...userCaps }; // if userCaps isn't set and defaultCaps is null, disables everything else if (defaultCaps === null) return defaultCaps; // if userCaps isn't set and defaultCaps is an object, merge it with dynamicCaps else return { ...dynamicCaps, ...defaultCaps }; } /** * Aggregate dynamic (runtime-values dependent), default and user capabilities and emit * raidenConfigCaps actions when it changes * * @param action$ - Observable of RaidenActions * @param state$ - Observable of RaidenStates * @param deps - Epics dependencies * @param deps.defaultConfig - Default config object * @param deps.latest$ - latest observable * @returns Observable of raidenConfigCaps actions */ function configCapsEpic({}, state$, { defaultConfig, latest$ }) { return combineLatest([state$.pipe(pluckDistinct('config', 'caps')), latest$]).pipe(map(([userCaps, latest]) => mergeCaps(dynamicCaps(latest), defaultConfig.caps, userCaps)), distinctUntilChanged(isEqual), map((caps) => raidenConfigCaps({ caps })), completeWith(state$)); } /** * React on certain config property changes and act accordingly: * Currently, reflect config.logger on deps.log's level, and config.pollingInterval on provider's * pollingInterval. * * @param action$ - Observable of RaidenActions * @param state$ - Observable of RaidenStates * @param deps - Epics dependencies * @param deps.config$ - Config observable * @param deps.log - Logger instance * @param deps.provider - Provider instance * @returns Observable which never emits */ function configReactEpic(action$, {}, { config$, log, provider }) { return merge(config$.pipe(pluckDistinct('logger'), tap((level) => log.setLevel(level || 'silent', false))), config$.pipe(pluckDistinct('pollingInterval'), tap((pollingInterval) => (provider.pollingInterval = pollingInterval)))).pipe(ignoreElements(), completeWith(action$)); } const ConfigEpics = { configCapsEpic, configReactEpic }; /** * This function maps cached/latest relevant values from action$ & state$ * * @param action$ - Observable of RaidenActions * @param state$ - Observable of RaidenStates * @param deps - Epics dependencies, minus 'latest$' & 'config$' (outputs) * @param deps.defaultConfig - defaultConfig mapping * @param deps.mediationFeeCalculator - Calculator used to decode/validate config.mediationFees * @returns latest$ observable */ export function getLatest$(action$, state$, // do not use latest$ or dependents (e.g. config$), as they're defined here { defaultConfig, mediationFeeCalculator, }) { const initialUdcDeposit = { balance: MaxUint256, totalDeposit: MaxUint256, }; const initialStale = false; const udcDeposit$ = action$.pipe(filter(udcDeposit.success.is), filter((action) => !('confirmed' in action.payload) || !!action.payload.confirmed), map((action) => ({ balance: action.payload.balance, totalDeposit: action.meta.totalDeposit })), // starts with max, to prevent receiving starting as disabled before actual balance is fetched startWith(initialUdcDeposit), distinctUntilChanged(({ balance: a }, { balance: b }) => a.eq(b))); const blockTime$ = action$.pipe(filter(blockTime.is), pluck('payload', 'blockTime'), startWith(15e3)); const stale$ = action$.pipe(filter(blockStale.is), pluck('payload', 'stale'), startWith(initialStale)); const settleTimeout$ = action$.pipe(filter(contractSettleTimeout.is), pluck('payload'), startWith(15000)); const caps$ = merge(state$.pipe(take(1), // initial caps depends on first state$ emit (initial) pluck('config'), map(({ caps: userCaps, monitoringReward }) => mergeCaps(dynamicCaps({ udcDeposit: initialUdcDeposit, stale: initialStale, config: { monitoringReward: monitoringReward ?? defaultConfig.monitoringReward }, }), defaultConfig.caps, userCaps))), // after that, pick from raidenConfigCaps actions action$.pipe(filter(raidenConfigCaps.is), pluck('payload', 'caps'))); const config$ = combineLatest([state$.pipe(pluckDistinct('config')), caps$]).pipe(map(([userConfig, caps]) => ({ ...defaultConfig, ...userConfig, caps, mediationFees: mediationFeeCalculator.decodeConfig(userConfig.mediationFees, defaultConfig.mediationFees), }))); const whitelisted$ = state$.pipe(take(1), mergeMap((initialState) => { const initialPartners = uniq(Object.values(initialState.channels).map(({ partner }) => partner.address)); return action$.pipe(filter(matrixPresence.request.is), scan((whitelist, request) => whitelist.includes(request.meta.address) ? whitelist : [...whitelist, request.meta.address], initialPartners), startWith(initialPartners), distinctUntilChanged()); })); const rtc$ = action$.pipe(filter(rtcChannel.is), // scan: if v.payload is defined, set it; else, unset scan((acc, v) => v.payload ? { ...acc, [v.meta.address]: v.payload } : unset(v.meta.address, acc), {}), startWith({})); return combineLatest([ action$, state$, config$, whitelisted$, rtc$, udcDeposit$, blockTime$, stale$, settleTimeout$, ]).pipe(map(([action, state, config, whitelisted, rtc, udcDeposit, blockTime, stale, settleTimeout,]) => ({ action, state, config, whitelisted, rtc, udcDeposit, blockTime, stale, settleTimeout, }))); } /** * Pipes getLatest$ to deps.latest$; this is a special epic, which should be the first subscribed, * in order to update deps.latest$ before all other epics receive new notifications, but last * unsubscribed, so any shutdown emitted value will update latest$ till the end; * ensure deps.latest$ is completed even on unsubscription. * * @param action$ - Observable of RaidenActions * @param state$ - Observable of RaidenStates * @param deps - Epics dependencies * @returns Observable of never */ function latestEpic(action$, state$, deps) { return getLatest$(action$, state$, deps).pipe(tap(deps.latest$), finalize(deps.latest$.complete.bind(deps.latest$)), ignoreElements()); } // Order matters! When shutting down, each epic's completion triggers the next to shut down, // meaning epics in this list should not assume previous epics are running, but can assume later // ones are (e.g. services epics may assume transport epics are still be subscribed, so messages // can be sent) const raidenEpics = { ...ConfigEpics, ...ChannelsEpics, ...TransfersEpics, ...ServicesEpics, ...TransportEpics, ...DatabaseEpics, }; /** * Consumes epics from epics$ and returns a root epic which properly wraps deps.latest$ and * limits action$ and state$ when raidenShutdown request goes through * * @param epics - Observable of raiden epics to compose the root epic * @returns The rootEpic which properly wires latest$ and limits action$ & state$ */ export function combineRaidenEpics(epics = Object.values(raidenEpics)) { /** * @param action$ - Observable of RaidenActions * @param state$ - Observable of RaidenStates * @param deps - Epics dependencies * @returns Raiden root epic observable */ return function raidenRootEpic(action$, state$, deps) { const shutdownNotification$ = action$.pipe(filter(raidenShutdown.is)); const subscribedChanged = new BehaviorSubject([]); // main epics output; like combineEpics, but completes action$, state$ & output$ when a // raidenShutdown goes through const output$ = from(epics).pipe(startWith(latestEpic), mergeMap((epic) => { // latestEpic must be first subscribed, last shut down if (epic === latestEpic) subscribedChanged.next(subscribedChanged.value.concat(epic)); // insert epic in the end, just before latestEpic, so latestEpic gets shut down last else subscribedChanged.next(subscribedChanged.value.slice(0, -1).concat(epic, last(subscribedChanged.value))); // trigger each epic's shutdown after system's shutdown and after previous epic // completed (serial completion) const epicShutdown$ = subscribedChanged.pipe(combineLatestWith(shutdownNotification$), // re-emit/evaluate when shutdown skipUntil(shutdownNotification$), // don't shutdown first epic if not yet shutting down filter(([subscribed]) => subscribed[0] === epic)); return epic( // we shut down an epic by completing its inputs, then it should gracefully complete // whenever it can action$.pipe(takeUntil(epicShutdown$)), state$.pipe(takeUntil(epicShutdown$)), deps).pipe(catchError((err) => { deps.log.error('Epic error', epic.name, epic, err); return of(raidenShutdown({ reason: err })); }), // but if an epic takes more than httpTimeout, forcefully completes it takeUntil(epicShutdown$.pipe(withLatestFrom(deps.config$), // give up to httpTimeout for the epics to complete on their own delayWhen(([_, { httpTimeout }]) => timer(httpTimeout)), tap(() => deps.log.warn('Epic stuck:', epic.name, epic)))), finalize(() => { subscribedChanged.next(subscribedChanged.value.filter((v) => v !== epic)); })); }), // if a second shutdownNotification$ fires, unsubscribe inconditionally takeUntil(shutdownNotification$.pipe(skip(1)))); // also concat db teardown tasks, to be done after main epic completes const teardown$ = deps.db.busy$.pipe(first((busy) => !busy), tap(() => deps.db.busy$.next(true)), // ignore db.busy$ errors, they're merged in the output by dbErrorsEpic catchError(() => of(null)), mergeMap(async () => deps.db.close()), ignoreElements(), finalize(() => { deps.db.busy$.next(false); deps.db.busy$.complete(); })); // subscribe to teardown$ only after output$ completes return concat(output$, teardown$); }; } //# sourceMappingURL=epics.js.map