raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
193 lines • 10.4 kB
JavaScript
import { AsyncSubject, combineLatest, concat, defer, from, of, timer } from 'rxjs';
import { concatMap, debounceTime, distinctUntilChanged, endWith, exhaustMap, filter, finalize, first, ignoreElements, last, map, mapTo, mergeMap, pluck, scan, skipUntil, startWith, switchMap, takeUntil, tap, withLatestFrom, } from 'rxjs/operators';
import { raidenConfigUpdate, raidenSynced } from '../../actions';
import { intervalFromConfig } from '../../config';
import { fromEthersEvent } from '../../utils/ethers';
import { catchAndLog, completeWith, lastMap, pluckDistinct, retryAsync$, retryWhile, } from '../../utils/rx';
import { isntNil } from '../../utils/types';
import { blockStale, blockTime, contractSettleTimeout, newBlock } from '../actions';
/**
* Emits raidenSynced when all init$ tasks got completed
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.init$ - Init$ subject
* @returns Observable of raidenSynced actions
*/
export function initEpic({}, state$, { init$ }) {
return state$.pipe(first(), mergeMap(({ blockNumber: initialBlock }) => {
const startTime = Date.now();
return init$.pipe(mergeMap((subject) => concat(of(1), subject.pipe(ignoreElements(), endWith(-1)))), scan((acc, v) => acc + v, 0), // scan doesn't emit initial value
debounceTime(10), // should be just enough for some sync action
first((acc) => acc === 0), withLatestFrom(state$), map(([, { blockNumber }]) => raidenSynced({
tookMs: Date.now() - startTime,
initialBlock,
currentBlock: blockNumber,
})));
}), completeWith(state$), finalize(() => init$.complete()));
}
/**
* Fetch current blockNumber, register for new block events and emit newBlock actions
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps members
* @param deps.provider - Eth provider
* @param deps.init$ - Observable which completes when initial sync is done
* @returns Observable of newBlock actions
*/
export function initNewBlockEpic(action$, {}, { provider, init$ }) {
return retryAsync$(() => provider.getBlockNumber(), provider.pollingInterval).pipe(
// emits fetched block first, then subscribes to provider's block after synced
mergeMap((blockNumber) => init$.pipe(lastMap(() => fromEthersEvent(provider, 'block')), startWith(blockNumber))), map((blockNumber) => newBlock({ blockNumber })), completeWith(action$));
}
/**
* Fetch and calculate average blockTime every fetchEach=20, across maxSize=5 requests,
* i.e. moving average of 20*5=100 last blocks
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps members
* @param deps.getBlockTimestamp - Block timestamp (cached) getter function
* @param deps.log - Logger instance
* @returns Observable of blockTime actions
*/
export function blockTimeEpic(action$, {}, { log, getBlockTimestamp }) {
const fetchEach = 20; // how often to reevaluate
const maxSize = 5; // max queue size
const queue = []; // queue of past fetched (cached) block timestamps to reuse
return action$.pipe(filter(newBlock.is), pluck('payload', 'blockNumber'), filter((blockNumber) => !queue.length || queue[queue.length - 1] + fetchEach <= blockNumber), exhaustMap((blockNumber) => {
let pastBlock;
if (queue.length < maxSize)
pastBlock = Math.max(1, blockNumber - fetchEach * maxSize);
else
pastBlock = queue[0]; // use front, but pop only if successfully fetched
return combineLatest([getBlockTimestamp(blockNumber), getBlockTimestamp(pastBlock)]).pipe(filter(([curTs, pastTs]) => pastTs < curTs), map(([curTs, pastTs]) => {
// in case of success and queue is full, pop_front pastNumber
if (queue.length >= maxSize)
queue.splice(0, 1);
queue.push(blockNumber); // then push_back new blockNumber
return ((curTs - pastTs) * 1e3) / (blockNumber - pastBlock);
}), catchAndLog({ log: log.warn }));
}), distinctUntilChanged(), map((avgBlockTime) => blockTime({ blockTime: avgBlockTime })));
}
/**
* Monitors provider for staleness. A provider is considered stale when it doesn't emit new blocks
* on either 2 * httpTimeout or the average time for 3 blocks.
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps members
* @param deps.config$ - Config observable
* @param deps.latest$ - Latest observable
* @param deps.init$ - Init observable
* @returns Observable of blockStale actions
*/
export function blockStaleEpic({}, state$, { latest$, config$, init$ }) {
return state$.pipe(skipUntil(init$.pipe(last())), pluckDistinct('blockNumber'), withLatestFrom(latest$, config$),
// forEach block
map(([, { blockTime }, { httpTimeout }]) => Math.max(3 * blockTime, 2 * httpTimeout)),
// switchMap will "reset" timer every block, restarting the timeout
switchMap((staleTimeout) => concat(of(false), timer(staleTimeout).pipe(mapTo(true),
// ensure timer completes output if input completes,
// but first element of concat ensures it'll emit at least once (true) when subscribed
completeWith(state$)))), distinctUntilChanged(), map((stale) => blockStale({ stale })));
}
/**
* Fetch settleTimeout from contract once
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps members
* @param deps.log - Logger instance
* @param deps.registryContract - TokenNetworkRegistry instance
* @param deps.config$ - Config observable
* @param deps.init$ - Observable of sync tasks
* @returns Observable of contractSettleTimeout actions
*/
export function contractSettleTimeoutEpic({}, state$, { log, registryContract, config$, init$ }) {
let done$;
return defer(async () => {
done$ = new AsyncSubject();
init$.next(done$);
return registryContract.callStatic.settle_timeout();
}).pipe(map((settleTimeout) => settleTimeout.toNumber()), retryWhile(intervalFromConfig(config$)), withLatestFrom(state$, config$), mergeMap(function* ([settleTimeout, { config: userConfig }, { revealTimeout }]) {
yield contractSettleTimeout(settleTimeout);
if (revealTimeout <= settleTimeout / 2)
return;
if (!('revealTimeout' in userConfig)) {
// in case user hasn't explicitly set config.revealTimeout, choose a sane default
yield raidenConfigUpdate({ revealTimeout: Math.floor(settleTimeout / 2) });
}
else {
// otherwise, warn but don't error to allow them to reset it
log.warn('Invalid `config.revealTimeout` - transfers may fail', {
revealTimeout,
maxRevealTimeout: settleTimeout / 2,
});
}
}), tap(() => {
done$.next(null);
done$.complete();
}));
}
function checkPendingAction(action, provider, blockNumber, confirmationBlocks) {
return retryAsync$(() => provider.getTransactionReceipt(action.payload.txHash), provider.pollingInterval).pipe(map((receipt) => {
if (receipt?.confirmations !== undefined &&
receipt.confirmations >= confirmationBlocks &&
receipt.status // reorgs can make txs fail
) {
return {
...action,
// beyond setting confirmed, also re-set blockNumber,
// which may have changed on a reorg
payload: {
...action.payload,
txBlock: receipt.blockNumber ?? action.payload.txBlock,
confirmed: true,
},
};
}
else if (action.payload.txBlock + 2 * confirmationBlocks < blockNumber) {
// if this txs didn't get confirmed for more than 2*confirmationBlocks, it was removed
return {
...action,
payload: { ...action.payload, confirmed: false },
};
} // else, it seems removed, but give it twice confirmationBlocks to be picked up again
}), filter(isntNil));
}
/**
* Process new blocks and re-emit confirmed or removed actions
*
* Events can also be confirmed by `fromEthersEvent + map(logToContractEvent)` combination.
* Notice that this epic does not know how to parse a tx log to update an action which payload was
* composed of values which can change upon reorgs. It only checks if given txHash is still present
* on the blockchain. `fromEthersEvent` can usually emit unconfirmed events multiple times to
* update/replace the pendingTxs action if needed, and also should emit the confirmed action with
* proper values; therefore, one should only relay on this epic to confirm an action if there's
* nothing critical depending on values in it's payload which can change upon reorgs.
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps members
* @param deps.config$ - Config observable
* @param deps.provider - Eth provider
* @param deps.latest$ - Latest observable
* @returns Observable of confirmed or removed actions
*/
export function confirmationEpic({}, state$, { config$, provider, latest$ }) {
return combineLatest([
state$.pipe(pluckDistinct('blockNumber')),
state$.pipe(pluck('pendingTxs')),
config$.pipe(pluckDistinct('confirmationBlocks'), completeWith(state$)),
]).pipe(filter(([, pendingTxs]) => pendingTxs.length > 0),
// exhaust will ignore blocks while concat$ is busy
exhaustMap(([blockNumber, pendingTxs, confirmationBlocks]) => from(pendingTxs).pipe(
// only txs/confirmable actions which are more than confirmationBlocks in the past
filter((a) => a.payload.txBlock + confirmationBlocks < blockNumber), concatMap((action) => checkPendingAction(action, provider, blockNumber, confirmationBlocks).pipe(
// unsubscribe if it gets cleared from 'pendingTxs' while checking, to avoid duplicate
takeUntil(latest$.pipe(filter(({ state }) => !state.pendingTxs.some((a) => a.type === action.type && a.payload.txHash === action.payload.txHash)))))))));
}
//# sourceMappingURL=block.js.map