raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
290 lines • 16.4 kB
JavaScript
import { AsyncSubject, EMPTY, from, of, timer } from 'rxjs';
import { catchError, concatMap, delayWhen, exhaustMap, filter, first, groupBy, ignoreElements, map, mergeMap, mergeWith, pluck, takeUntil, withLatestFrom, } from 'rxjs/operators';
import { transact } from '../../channels/utils';
import { intervalFromConfig } from '../../config';
import { Capabilities } from '../../constants';
import { messageSend } from '../../messages/actions';
import { MessageType, SecretRequest, SecretReveal } from '../../messages/types';
import { isMessageReceivedOfType, signMessage } from '../../messages/utils';
import { getCap } from '../../transport/utils';
import { isActionOf, isConfirmationResponseOf } from '../../utils/actions';
import { assert, commonTxErrors, ErrorCodes } from '../../utils/error';
import { fromEthersEvent, logToContractEvent } from '../../utils/ethers';
import { completeWith, pluckDistinct, retryWhile, withMergeFrom } from '../../utils/rx';
import { isntNil, Signed, untime } from '../../utils/types';
import { transfer, transferSecret, transferSecretRegister, transferSecretRequest, transferSecretReveal, transferUnlock, } from '../actions';
import { Direction } from '../state';
import { getSecrethash, makeMessageId, searchValidViaAddress, transferKey } from '../utils';
import { dispatchAndWait$ } from './utils';
/**
* Handles receiving a signed SecretRequest from target for some sent LockedTransfer
* Emits a transferSecretRequest action only if all conditions are met
*
* @param action$ - Observable of messageReceived actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.address - Our address
* @param deps.log - Logger instance
* @returns Observable of transferSecretRequest actions
*/
export function transferSecretRequestedEpic(action$, state$, { address, log }) {
return action$.pipe(filter(isMessageReceivedOfType(Signed(SecretRequest))), withLatestFrom(state$), mergeMap(function* ([action, state]) {
const message = action.payload.message;
// proceed only if we know the secret and the SENT transfer
const key = transferKey({ secrethash: message.secrethash, direction: Direction.SENT });
const transferState = state.transfers[key];
if (!transferState)
return;
const locked = transferState.transfer;
// we do only some basic verification here, as most of it is done upon SecretReveal,
// to persist the request in most cases in TransferState.secretRequest
if (locked.initiator !== address || // only the initiator may reply a SecretRequest
locked.target !== action.meta.address || // reveal only to target
!locked.payment_identifier.eq(message.payment_identifier)) {
log.warn('Invalid SecretRequest for transfer', message, locked);
return;
}
yield transferSecretRequest({ message, userId: action.payload.userId }, { secrethash: message.secrethash, direction: Direction.SENT });
}));
}
/**
* Contains the core logic of {@link transferSecretRevealEpic}.
*
* @param action - The {@link transferSecretRequest} action that
* @param deps - Epics dependencies
* @param deps.signer - Signer instance
* @param deps.log - Logger instance
* @param deps.latest$ - Latest observable
* @returns Observable of {@link transfer.failure}, {@link transferSecretReveal} or
* {@link messageSend.request} actions
*/
const secretReveal$ = (action, { signer, log, latest$ }) => {
const request = action.payload.message;
return latest$.pipe(first(), mergeMap(({ state }) => {
const transferState = state.transfers[transferKey(action.meta)];
// shouldn't happen, as we're the initiator (for now), and always know the secret
assert(transferState?.secret, ['SecretRequest for unknown secret', request]);
const locked = transferState.transfer;
const target = locked.target;
const fee = transferState.fee;
const value = locked.lock.amount.sub(fee);
assert(request.expiration.lte(locked.lock.expiration) &&
request.expiration.gt(Math.floor(Date.now() / 1e3)), ['SecretRequest for expired transfer', { request, lock: locked.lock }]);
assert(request.amount.gte(value), [
'SecretRequest for amount too small!',
{ request, value },
]);
if (!request.amount.eq(value)) {
// accept request
log.info('Accepted SecretRequest for amount greater than sent', request, locked);
}
let reveal$;
if (transferState.secretReveal)
reveal$ = of(transferState.secretReveal);
else {
const message = {
type: MessageType.SECRET_REVEAL,
message_identifier: makeMessageId(),
secret: transferState.secret,
};
reveal$ = from(signMessage(signer, message, { log }));
}
return reveal$.pipe(mergeMap(function* (message) {
yield transferSecretReveal({ message }, action.meta);
yield messageSend.request({ message, userId: action.payload.userId }, { address: target, msgId: message.message_identifier.toString() });
}));
}), catchError((err) => of(transfer.failure(err, action.meta))));
};
/**
* Handles a transferSecretRequest action to send the respective secret to target
* It both emits transferSecretReveal (to persist sent SecretReveal in state and indicate that
* the secret was revealed and thus the transfer should be assumed as succeeded) as well as
* triggers sending the message once. New SecretRequests will cause a new transferSecretRequest,
* which will re-send the cached SecretReveal.
* transfer.failure is emitted in case invalid secretRequest comes, as no valid one will come as
* per current implementation, so we fail early to notify users about it.
*
* @param action$ - Observable of transferSecretRequest actions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps
* @param deps.signer - Signer instance
* @param deps.latest$ - Latest observable
* @param deps.log - Logger instance
* @returns Observable of transfer.failure|transferSecretReveal|messageSend.request actions
*/
export function transferSecretRevealEpic(action$, {}, deps) {
return action$.pipe(filter(isActionOf(transferSecretRequest)), filter((action) => action.meta.direction === Direction.SENT), concatMap((action) => secretReveal$(action, deps)));
}
/**
* Handles receiving a valid SecretReveal from recipient (neighbor/partner)
* This indicates that the partner knowws the secret, and we should Unlock to avoid going on-chain.
* The transferUnlock.request action is a request for the unlocking to be generated and sent.
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.config$ - Config observable
* @returns Observable of output actions for this epic
*/
export function transferSecretRevealedEpic(action$, state$, { config$ }) {
return action$.pipe(
// we don't require Signed SecretReveal, nor even check sender for persisting the secret
filter(isMessageReceivedOfType(SecretReveal)), withLatestFrom(state$, config$), mergeMap(function* ([action, state, { caps }]) {
const secrethash = getSecrethash(action.payload.message.secret);
const results = Object.values(Direction)
.map((direction) => state.transfers[transferKey({ secrethash, direction })])
.filter(isntNil);
const message = action.payload.message;
for (const sent of results.filter((doc) => doc.direction === Direction.SENT)) {
const meta = { secrethash, direction: Direction.SENT };
// if secrethash matches, we're good for persisting, don't care for sender/signature
yield transferSecret({ secret: message.secret }, meta);
// but are stricter for unlocking to next hop only
if (action.meta.address === sent.partner &&
// don't unlock if channel closed: balanceProofs already registered on-chain
!sent.channelClosed
// accepts secretReveal/unlock request even if registered on-chain
) {
let viaPayload;
// unlock _through_ sender iff message is signed
if ('signature' in message)
viaPayload = { userId: action.payload.userId };
// request unlock to be composed, signed & sent to partner
yield transferUnlock.request(viaPayload, meta);
}
}
// avoid unlocking received transfers if receiving is disabled
if (!getCap(caps, Capabilities.RECEIVE))
return;
// we're mediator or target, and received reveal from next hop or initiator, respectively
for (const _received of results.filter((doc) => doc.direction === Direction.RECEIVED)) {
// if secrethash matches, we're good for persisting, which also triggers Reveal back
yield transferSecret({ secret: message.secret }, { secrethash, direction: Direction.RECEIVED });
}
}));
}
/**
* For a received transfer, when we know the secret, sign & send a SecretReveal to previous hop
*
* @param action$ - Observable of transferSecret|transferSecretReveal actions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps
* @param deps.log - Logger instance
* @param deps.signer - Signer instance
* @param deps.latest$ - Latest observable
* @returns Observable of transferSecretReveal actions
*/
export function transferRequestUnlockEpic(action$, {}, { log, signer, latest$ }) {
return action$.pipe(filter(isActionOf([transferSecret, transferSecretRegister.success])), filter((action) => action.meta.direction === Direction.RECEIVED), filter((action) => transferSecret.is(action) || !!action.payload.confirmed), concatMap((action) => latest$.pipe(pluckDistinct('state'), first(), filter(({ transfers }) => transferKey(action.meta) in transfers), mergeMap(({ transfers }) => {
const transferState = transfers[transferKey(action.meta)];
const cached = transferState.secretReveal;
let signed$;
if (cached) {
signed$ = of(untime(cached));
}
else {
const message = {
type: MessageType.SECRET_REVEAL,
message_identifier: makeMessageId(),
secret: action.payload.secret,
};
signed$ = from(signMessage(signer, message, { log }));
}
const via = searchValidViaAddress(transferState.transfer.metadata, transferState.partner);
return signed$.pipe(map((message) => transferSecretReveal({ message, ...via }, action.meta)));
}), catchError((err) => {
log.warn('Error trying to sign SecretReveal - ignoring', err, action.meta);
return EMPTY;
}))));
}
/**
* Monitors SecretRegistry and emits when a relevant secret gets registered
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.provider - Provider instance
* @param deps.secretRegistryContract - SecretRegistry contract instance
* @param deps.config$ - Config observable
* @param deps.init$ - Init$ tasks subject
* @param deps.getBlockTimestamp - block timestamp getter function
* @returns Observable of transferSecretRegister.success actions
*/
export function monitorSecretRegistryEpic({}, state$, { provider, secretRegistryContract, config$, init$, getBlockTimestamp }) {
const initSub = new AsyncSubject();
init$.next(initSub);
return fromEthersEvent(provider, secretRegistryContract.filters.SecretRevealed(null, null), {
confirmations: config$.pipe(pluck('confirmationBlocks')),
blockNumber$: state$.pipe(pluckDistinct('blockNumber')),
onPastCompleted: () => {
initSub.next(null);
initSub.complete();
},
}).pipe(completeWith(state$), map(logToContractEvent(secretRegistryContract)), withMergeFrom(([, , event]) => getBlockTimestamp(event.blockNumber)), withLatestFrom(state$, config$), mergeMap(function* ([[[secrethash, secret, event], txTimestamp], { transfers, blockNumber }, { confirmationBlocks },]) {
// find sent|received transfers matching secrethash and secret registered before expiration
for (const direction of Object.values(Direction)) {
const key = transferKey({ secrethash: secrethash, direction });
const transferState = transfers[key];
if (!transferState || transferState.expiration <= txTimestamp)
continue;
yield transferSecretRegister.success({
secret: secret,
txTimestamp,
txHash: event.transactionHash,
txBlock: event.blockNumber,
confirmed: event.blockNumber + confirmationBlocks <= blockNumber ? !event.removed : undefined,
}, { secrethash: secrethash, direction });
}
}));
}
/**
* A simple epic to emit transfer.success when secret register is confirmed
*
* @param action$ - Observable of transferSecretRegister.success actions
* @returns Observable of transfer.success actions
*/
export function transferSuccessOnSecretRegisteredEpic(action$) {
return action$.pipe(filter(transferSecretRegister.success.is), filter((action) => !!action.payload.confirmed), map((action) => transfer.success({}, action.meta)));
}
/**
* Process newBlocks and pending received transfers. If we know the secret, and transfer doesn't
* get unlocked before revealTimeout seconds are left to lock expiration, request to register secret
* TODO: check economic viability (and define what that means) of registering lock on-chain
*
* @param action$ - Observable of newBlock actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @param deps.config$ - Config observable
* @returns Observable of transferSecretRegister.request actions
*/
export function transferAutoRegisterEpic(action$, state$, { config$ }) {
const transferIsGone$ = ({ key }) => state$.pipe(filter(({ transfers }) => !(key in transfers)));
return state$.pipe(pluck('transfers'), mergeMap((transfers) => Object.values(transfers)), filter(({ direction }) => direction === Direction.RECEIVED), groupBy(({ _id }) => _id, { duration: transferIsGone$ }), mergeMap((grouped$) => grouped$.pipe(filter(({ secret }) => !!secret), withLatestFrom(config$), delayWhen(([{ expiration }, { revealTimeout }]) =>
// danger zone!
timer(new Date((expiration - revealTimeout) * 1e3))), exhaustMap(([{ secret, transfer: locked }]) => {
const meta = { secrethash: locked.lock.secrethash, direction: Direction.RECEIVED };
return dispatchAndWait$(action$, transferSecretRegister.request({ secret: secret }, meta), isConfirmationResponseOf(transferSecretRegister, meta));
}), takeUntil(grouped$.pipe(filter((t) => !!(t.unlock ||
t.expired ||
t.secretRegistered ||
t.channelClosed ||
// gives up immediatelly if already over expiration
t.expiration <= Date.now() / 1e3)), mergeWith(transferIsGone$(grouped$)))), completeWith(state$))));
}
/**
* Registers secret on-chain. Success is detected by monitorSecretRegistryEpic
*
* @param action$ - Observable of transferSecretRegister.request actions
* @param state$ - Observable of RaidenStates
* @param deps - Epics dependencies
* @returns Observable of transferSecretRegister.failure actions
*/
export function transferSecretRegisterEpic(action$, {}, deps) {
const { log, secretRegistryContract, config$ } = deps;
return action$.pipe(filter(transferSecretRegister.request.is), mergeMap((action) => transact(secretRegistryContract, 'registerSecret', [action.payload.secret], deps, {
error: ErrorCodes.XFER_REGISTERSECRET_TX_FAILED,
}).pipe(retryWhile(intervalFromConfig(config$), { onErrors: commonTxErrors, log: log.debug }),
// transferSecretRegister.success handled by monitorSecretRegistryEpic
ignoreElements(), catchError((err) => of(transferSecretRegister.failure(err, action.meta))))));
}
//# sourceMappingURL=secret.js.map