raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
239 lines • 14.6 kB
JavaScript
import { combineLatest, defer, EMPTY, from, merge, of, timer } from 'rxjs';
import { catchError, concatMap, distinct, filter, first, groupBy, ignoreElements, map, mapTo, mergeMap, mergeMapTo, mergeWith, scan, share, startWith, take, takeUntil, tap, withLatestFrom, } from 'rxjs/operators';
import { ChannelState } from '../../channels';
import { channelKey, transact } from '../../channels/utils';
import { intervalFromConfig } from '../../config';
import { isMessageReceivedOfType, MessageType, Processed, signMessage } from '../../messages';
import { messageSend } from '../../messages/actions';
import { matrixPresence } from '../../transport/actions';
import { getPresencesByAddress, peerIsOnlineLC } from '../../transport/utils';
import { isActionOf, isConfirmationResponseOf } from '../../utils/actions';
import { assert, commonTxErrors, ErrorCodes, RaidenError } from '../../utils/error';
import { checkContractHasMethod$ } from '../../utils/ethers';
import { LruCache } from '../../utils/lru';
import { dispatchRequestAndGetResponse, retryWhile } from '../../utils/rx';
import { Signed } from '../../utils/types';
import { withdraw, withdrawBusy, withdrawCompleted, withdrawExpire, withdrawMessage, withdrawResolve, } from '../actions';
import { Direction } from '../state';
import { matchWithdraw, retrySendUntil$ } from './utils';
/**
* Emits withdraw action once for each own non-confirmed message at startup
*
* @param state$ - Observable of RaidenStates
* @returns Observable of withdrawMessage.request|withdrawExpire.success actions
*/
export function initWithdrawMessagesEpic({}, state$) {
return state$.pipe(first(), mergeMap(function* (state) {
for (const channel of Object.values(state.channels)) {
if (channel.state !== ChannelState.open)
continue;
for (const message of channel.own.pendingWithdraws) {
if (message.type === MessageType.WITHDRAW_REQUEST &&
!channel.own.pendingWithdraws.some(matchWithdraw(MessageType.WITHDRAW_CONFIRMATION, message)))
yield withdrawMessage.request({ message }, {
direction: Direction.SENT,
tokenNetwork: channel.tokenNetwork,
partner: channel.partner.address,
totalWithdraw: message.total_withdraw,
expiration: message.expiration.toNumber(),
});
else if (message.type === MessageType.WITHDRAW_EXPIRED)
yield withdrawExpire.success({ message }, {
direction: Direction.SENT,
tokenNetwork: channel.tokenNetwork,
partner: channel.partner.address,
totalWithdraw: message.total_withdraw,
expiration: message.expiration.toNumber(),
});
// WithdrawConfirmations are sent only as response of requests
}
}
}));
}
/**
* Resolve a withdrawResolve action and emit withdraw.request
* Resolving withdraws require that partner is online and contracts support `cooperativeSettle`.
*
* @param action$ - Observable of withdrawResolve actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.getTokenNetworkContract - TokenNetwork contract getter
* @returns Observable of withdraw.request|withdraw.failure actions
*/
export function withdrawResolveEpic(action$, {}, { getTokenNetworkContract }) {
return action$.pipe(dispatchRequestAndGetResponse(matrixPresence, (requestPresence$) => action$.pipe(filter(withdrawResolve.is), mergeMap((action) => {
let preCheck$ = of(true);
if (action.payload?.coopSettle) {
const tokenNetworkContract = getTokenNetworkContract(action.meta.tokenNetwork);
preCheck$ = checkContractHasMethod$(tokenNetworkContract, 'cooperativeSettle');
}
return preCheck$.pipe(mergeMapTo(requestPresence$(matrixPresence.request(undefined, { address: action.meta.partner }))), map((presence) => {
// assert shouldn't fail, because presence request would, but just in case
assert(presence.payload.available, 'partner offline');
return withdraw.request(action.payload, action.meta);
}), catchError((err) => of(withdraw.failure(err, action.meta))));
}))));
}
/**
* Retry sending 'WithdrawRequest' messages to partner until WithdrawConfirmation is received
*
* @param action$ - Observable of withdrawRequest.request actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.config$ - Config observable
* @returns Observable of messageSend.request actions
*/
export function withdrawSendRequestMessageEpic(action$, state$, { config$ }) {
return action$.pipe(filter(withdrawMessage.request.is), filter((action) => action.meta.direction === Direction.SENT), mergeMap((action) => {
const message = action.payload.message;
const send = messageSend.request({ message }, { address: action.meta.partner, msgId: message.message_identifier.toString() });
// emits to stop retry loop when channel isn't open anymore, a confirmation came or request
// got cleared from state (e.g. by effective withdraw tx)
const notifier = state$.pipe(filter((state) => {
const channel = state.channels[channelKey(action.meta)];
return (channel?.state !== ChannelState.open ||
channel.own.pendingWithdraws.some(matchWithdraw(MessageType.WITHDRAW_CONFIRMATION, action.meta)) ||
!channel.own.pendingWithdraws.some(matchWithdraw(MessageType.WITHDRAW_REQUEST, action.meta)));
}));
// emit request once immediatelly, then wait until success, then retry every 30s
return retrySendUntil$(send, action$, notifier, intervalFromConfig(config$));
}));
}
/**
* Upon valid [[WithdrawConfirmation]], send the on-chain withdraw transaction
*
* @param action$ - Observable of withdrawMessage.success actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @returns Observable of withdraw.success|withdraw.failure actions
*/
export function withdrawSendTxEpic(action$, {}, deps) {
const { address, log, getTokenNetworkContract, latest$, config$ } = deps;
return action$.pipe(filter(withdrawMessage.success.is), filter((action) => action.meta.direction === Direction.SENT), groupBy((action) => channelKey(action.meta)), mergeMap((grouped$) => grouped$.pipe(
// concatMap handles only one withdraw tx per channel at a time
concatMap((action) => combineLatest([latest$, config$]).pipe(first(), mergeMap(([{ state }, { revealTimeout }]) => {
const channel = state.channels[grouped$.key];
assert(channel?.state === ChannelState.open, 'channel not open');
const req = channel.own.pendingWithdraws.find(matchWithdraw(MessageType.WITHDRAW_REQUEST, action.payload.message));
assert(req, 'no matching WithdrawRequest found');
// don't send withdraw tx if this is a coop_settle request (back or forth)
if ('coop_settle' in req)
return EMPTY;
assert(action.meta.totalWithdraw.gt(channel.own.withdraw), 'withdraw already performed');
// don't send on-chain tx if we're 'revealTimeout' seconds from expiration
// this is our confidence threshold when we can get a tx inside timeout
assert(req.expiration.gte(Math.floor(Date.now() / 1e3 + revealTimeout)), ErrorCodes.CNL_WITHDRAW_EXPIRES_SOON);
const { tokenNetwork } = action.meta;
return transact(getTokenNetworkContract(tokenNetwork), 'setTotalWithdraw', [
channel.id,
address,
action.meta.totalWithdraw,
action.meta.expiration,
req.signature,
action.payload.message.signature,
], deps, { error: ErrorCodes.CNL_WITHDRAW_TRANSACTION_FAILED }).pipe(retryWhile(intervalFromConfig(config$), {
maxRetries: 5,
onErrors: commonTxErrors,
log: log.info,
}), mergeMap(([, receipt]) => action$.pipe(filter(isConfirmationResponseOf(withdraw, action.meta)), take(1), ignoreElements(),
// startWith unconfirmed success, but complete only on confirmation/failure
startWith(withdraw.success({
txHash: receipt.transactionHash,
txBlock: receipt.blockNumber,
// no sensitive value in payload, let confirmationEpic confirm it
confirmed: undefined,
}, action.meta)))), startWith(withdrawBusy(undefined, action.meta)));
}), catchError((err) => of(withdraw.failure(err, action.meta))))))));
}
/**
* Upon receiving withdraw request or confirmation, send partner the respective Processed message
*
* SDK-based clients (with caps.Delivery set) don't need it, so skip. They, instead, confirm
* the request with the confirmation, and re-sends confirmation on request retries
*
* @param action$ - Observable of withdrawMessage.request|withdrawMessage.success actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.signer - Signer instance
* @param deps.log - Logger instance
* @returns Observable of messageSend.request actions
*/
export function withdrawMessageProcessedEpic(action$, {}, { signer, log }) {
const cache = new LruCache(32);
return action$.pipe(filter(isActionOf([withdrawMessage.request, withdrawMessage.success])), filter((action) => (action.meta.direction === Direction.RECEIVED) !== withdrawMessage.success.is(action)), withLatestFrom(action$.pipe(getPresencesByAddress())), concatMap(([action, presences]) => defer(() => {
// light-clients don't need Processed messages, only PCs
if (peerIsOnlineLC(presences[action.meta.partner]))
return EMPTY;
const message = action.payload.message;
let processed$;
const cacheKey = message.message_identifier.toString();
const cached = cache.get(cacheKey);
if (cached)
processed$ = of(cached);
else {
const processed = {
type: MessageType.PROCESSED,
message_identifier: message.message_identifier,
};
processed$ = from(signMessage(signer, processed, { log })).pipe(tap((signed) => cache.set(cacheKey, signed)));
}
return processed$.pipe(map((processed) => messageSend.request({ message: processed }, { address: action.meta.partner, msgId: processed.message_identifier.toString() })));
}).pipe(catchError((err) => (log.info('Signing Processed message for Withdraw message failed, ignoring', err), EMPTY)))));
}
function withdrawKey(meta) {
return `${channelKey(meta)}|${meta.expiration}|${meta.totalWithdraw.toString()}`;
}
/**
* Dispatch withdrawExpire.request when one of our sent WithdrawRequests expired
*
* @param action$ - Observable of newBlock actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.config$ - Config observable
* @param deps.latest$ - Latest observable
* @returns Observable of withdrawExpired actions
*/
export function withdrawAutoExpireEpic(action$, {}, { config$, latest$ }) {
const busyWithdraws = new Set();
return action$.pipe(filter(withdrawMessage.request.is), filter(({ meta }) => meta.direction === Direction.SENT), distinct(({ payload }) => payload.message.message_identifier.toHexString()), withLatestFrom(config$, action$.pipe(filter(isActionOf([withdrawBusy, withdraw.success, withdraw.failure])), scan((acc, action) => {
// a string representing uniquely this withdraw.meta
const key = withdrawKey(action.meta);
// 'busy' adds withdraw to set, preventing these withdraws from failing due to expiration
// too soon; withdraw.success|failure clears it
if (withdrawBusy.is(action))
acc.add(key);
else
acc.delete(key);
return acc;
}, busyWithdraws), startWith(busyWithdraws))), mergeMap(([action, { httpTimeout, revealTimeout }, busyWithdraws]) => {
const key = withdrawKey(action.meta);
return merge(timer(new Date((action.meta.expiration - revealTimeout) * 1e3), httpTimeout).pipe(filter(() => !busyWithdraws.has(key)), take(1), mapTo(withdraw.failure(new RaidenError(ErrorCodes.CNL_WITHDRAW_EXPIRED), action.meta))), timer(new Date(action.meta.expiration * 1e3 + httpTimeout), httpTimeout).pipe(filter(() => !busyWithdraws.has(withdrawKey(action.meta))), mapTo(withdrawExpire.request(undefined, action.meta)))).pipe(takeUntil(latest$.pipe(filter(({ state }) => {
const channel = state.channels[channelKey(action.meta)];
return (channel?.state !== ChannelState.open ||
channel.own.pendingWithdraws.some(matchWithdraw(MessageType.WITHDRAW_EXPIRED, action.meta)));
}),
// give up expireRequest & failed timers in case of success
mergeWith(action$.pipe(filter(withdraw.success.is), filter((a) => withdrawKey(a.meta) === key))))));
}));
}
/**
* Retry sending own WithdrawExpired messages until Processed, completes withdraw then
*
* @param action$ - Observable of withdrawExpire.success actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.config$ - Config observable
* @returns Observable of messageSend.request actions
*/
export function withdrawSendExpireMessageEpic(action$, {}, { config$ }) {
return action$.pipe(filter(withdrawExpire.success.is), filter((action) => action.meta.direction === Direction.SENT), mergeMap((action) => {
const message = action.payload.message;
const send = messageSend.request({ message }, { address: action.meta.partner, msgId: message.message_identifier.toString() });
// emits to stop retry when a Processed for this WithdrawExpired comes
const notifier = action$.pipe(filter(isMessageReceivedOfType(Signed(Processed))), filter((a) => a.meta.address === action.meta.partner &&
a.payload.message.message_identifier.eq(message.message_identifier)), take(1), mapTo(withdrawCompleted(undefined, action.meta)), share());
// besides using notifier to stop retry, also merge the withdrawCompleted output action
return merge(retrySendUntil$(send, action$, notifier, intervalFromConfig(config$)), notifier);
}));
}
//# sourceMappingURL=withdraw.js.map