raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
861 lines • 45.4 kB
JavaScript
import { BigNumber } from '@ethersproject/bignumber';
import { Zero } from '@ethersproject/constants';
import { combineLatest, defer, EMPTY, from, merge, of } from 'rxjs';
import { catchError, concatMap, filter, first, map, mergeMap, tap, withLatestFrom, } from 'rxjs/operators';
import { ChannelState } from '../../channels/state';
import { channelAmounts, channelKey, channelUniqueKey } from '../../channels/utils';
import { Capabilities } from '../../constants';
import { messageReceived, messageSend } from '../../messages/actions';
import { LockedTransfer, LockExpired, MessageType, Unlock, WithdrawConfirmation, WithdrawExpired, WithdrawRequest, } from '../../messages/types';
import { getBalanceProofFromEnvelopeMessage, isMessageReceivedOfType, signMessage, } from '../../messages/utils';
import { matrixPresence } from '../../transport/actions';
import { getCap } from '../../transport/utils';
import { assert } from '../../utils';
import { isActionOf } from '../../utils/actions';
import { ErrorCodes, RaidenError } from '../../utils/error';
import { LruCache } from '../../utils/lru';
import { completeWith, pluckDistinct, withMergeFrom } from '../../utils/rx';
import { decode, Secret, Signed, UInt, untime } from '../../utils/types';
import { transfer, transferExpire, transferExpireProcessed, transferProcessed, transferSecret, transferSecretRequest, transferSigned, transferUnlock, transferUnlockProcessed, withdraw, withdrawCompleted, withdrawExpire, withdrawMessage, } from '../actions';
import { Direction } from '../state';
import { decryptSecretFromMetadata, getLocksroot, getSecrethash, getTransfer, makeMessageId, searchValidViaAddress, transferKey, } from '../utils';
import { matchWithdraw } from './utils';
// calculate locks array for channel end without lock with given secrethash
function withoutLock(end, secrethash) {
const locks = end.locks.filter((l) => l.secrethash !== secrethash);
assert(locks.length === end.locks.length - 1, 'invalid locks size');
return locks;
}
// calculate locked amount for a given locks array
function totalLocked(locks) {
return locks.reduce((acc, { amount }) => acc.add(amount), Zero);
}
// gets and asserts channel is open and optionally matches chain_id and channel_identifier
function getOpenChannel(state, key, matches) {
const channel = state.channels[channelKey(key)];
assert(channel?.state === ChannelState.open, 'channel not open');
if (matches) {
assert(matches.chain_id.eq(state.chainId), 'chainId mismatch');
assert(matches.channel_identifier.eq(channel.id), 'channelId mismatch');
}
return channel;
}
function transferRequestIsResolved(action) {
return action.payload.resolved;
}
/**
* The core logic of {@link makeAndSignTransfer}.
*
* @param state - Contains The current state of the app
* @param action - transfer request action to be sent.
* @param latest - Latest object
* @param latest.config - Config object
* @param latest.settleTimeout - contract's settleTimeout
* @param deps - {@link RaidenEpicDeps}
* @param deps.log - Logger instance
* @param deps.address - Our address
* @param deps.network - Current Network
* @param deps.signer - Signer instance
* @returns Observable of {@link transferSecret} or {@link transferSigned} actions
*/
function makeAndSignTransfer$(state, action, { config, settleTimeout }, { log, address, network, signer }) {
const { revealTimeout, expiryFactor } = config;
const { tokenNetwork, fee, partner, userId } = action.payload;
const channel = getOpenChannel(state, { tokenNetwork, partner });
const now = Math.round(Date.now() / 1e3); // now() in seconds (for messages and contracts)
let expiration;
if ('expiration' in action.payload && action.payload.expiration) {
expiration = action.payload.expiration; // reuse passed as is, in seconds
}
else {
expiration = now;
if ('lockTimeout' in action.payload && action.payload.lockTimeout)
expiration += action.payload.lockTimeout;
else
expiration += Math.min(revealTimeout * expiryFactor, settleTimeout - 1);
}
expiration = decode(UInt(32), Math.ceil(expiration));
// revealTimeout < Δexpiration < settleTimeout
// to ensure we'll have enough time to tx in case it expires, but it can't outlive channel
assert(expiration.gt(now + revealTimeout), [
'expiration too soon',
{ expiration, blockNumber: state.blockNumber, settleTimeout, revealTimeout },
]);
assert(expiration.lt(now + settleTimeout), [
'expiration too far in the future',
{ expiration, blockNumber: state.blockNumber, settleTimeout, revealTimeout },
]);
const lock = {
// fee is added to the lock amount; overflow is checked on locksSum below
amount: action.payload.value.add(fee),
expiration,
secrethash: action.meta.secrethash,
};
const locks = [...channel.own.locks, lock];
const locksSum = totalLocked(locks);
assert(UInt(32).is(channel.own.balanceProof.transferredAmount.add(locksSum)), 'overflow on future transferredAmount');
const locksroot = getLocksroot(locks);
log.info('Signing transfer of value', action.payload.value.toString(), 'of token', channel.token, ', to', action.payload.target, ', through routes', action.payload.metadata, ', paying', fee.toString(), 'in fees.');
const message = {
type: MessageType.LOCKED_TRANSFER,
message_identifier: makeMessageId(),
chain_id: BigNumber.from(network.chainId),
token_network_address: action.payload.tokenNetwork,
channel_identifier: BigNumber.from(channel.id),
nonce: channel.own.nextNonce,
transferred_amount: channel.own.balanceProof.transferredAmount,
locked_amount: locksSum,
locksroot,
payment_identifier: action.payload.paymentId,
token: channel.token,
recipient: partner,
lock,
target: action.payload.target,
initiator: action.payload.initiator ?? address,
metadata: action.payload.metadata, // passthrough unchanged metadata
};
return from(signMessage(signer, message, { log })).pipe(mergeMap(function* (signed) {
// messageSend LockedTransfer handled by transferRetryMessageEpic
yield transferSigned({ message: signed, fee, partner, userId }, action.meta);
// besides transferSigned, also yield transferSecret (for registering) if we know it
if (action.payload.secret)
yield transferSecret({ secret: action.payload.secret }, action.meta);
}));
}
/**
* Create an observable to compose and sign a LockedTransfer message/transferSigned action
* As it's an async observable which depends on state and may return an action which changes it,
* the returned observable must be subscribed in a serialized context that ensures non-concurrent
* write access to the channel's balance proof (e.g. concatMap)
*
* @param state$ - Observable of current state
* @param action - transfer request action to be sent
* @param deps - RaidenEpicDeps
* @returns Observable of transferSigned|transferSecret|transfer.failure actions
*/
function sendTransferSigned(state$, action, deps) {
return combineLatest([state$, deps.latest$]).pipe(first(), mergeMap(([state, latest]) => {
if (deps.db.storageKeys.has(transferKey(action.meta))) {
// don't throw to avoid emitting transfer.failure, to just wait for already pending transfer
deps.log.warn('transfer already present', action.meta);
return EMPTY;
}
return makeAndSignTransfer$(state, action, latest, deps);
}), catchError((err) => of(transfer.failure(err, action.meta))));
}
/**
* Contains the core logic of {@link makeAndSignUnlock}.
*
* @param state$ - Observable of the latest app state.
* @param state - Contains The current state of the app
* @param action - The transfer unlock action that will generate the transferUnlock.success action.
* @param transferState - Transfer's state
* @param deps - Epics dependencies
* @param deps.signer - The signer that will sign the message
* @param deps.log - Logger instance
* @param deps.db - Database instance
* @returns Observable of {@link transferUnlock.success} action.
*/
function makeAndSignUnlock$(state$, state, action, transferState, { log, signer, db }) {
const secrethash = action.meta.secrethash;
const locked = transferState.transfer;
const tokenNetwork = locked.token_network_address;
const partner = transferState.partner;
const channel = getOpenChannel(state, { tokenNetwork, partner });
let signed$;
if (transferState.unlock) {
// unlock already signed, use cached
signed$ = of(transferState.unlock);
}
else {
assert(transferState.secret, 'unknown secret'); // never fails because we wait before
assert(channel.own.locks.find((lock) => lock.secrethash === secrethash), 'transfer already unlocked or expired');
// don't forget to check after signature too, may have expired by then
// allow unlocking past expiration if secret registered on-chain
assert(transferState.secretRegistered || transferState.expiration >= Date.now() / 1e3, 'lock expired');
const message = {
type: MessageType.UNLOCK,
message_identifier: makeMessageId(),
chain_id: locked.chain_id,
token_network_address: locked.token_network_address,
channel_identifier: locked.channel_identifier,
nonce: channel.own.nextNonce,
transferred_amount: decode(UInt(32), channel.own.balanceProof.transferredAmount.add(locked.lock.amount), 'overflow on transferredAmount'),
locked_amount: channel.own.balanceProof.lockedAmount.sub(locked.lock.amount),
locksroot: getLocksroot(withoutLock(channel.own, secrethash)),
payment_identifier: locked.payment_identifier,
secret: transferState.secret,
};
signed$ = from(signMessage(signer, message, { log }));
}
return signed$.pipe(withMergeFrom(async () => getTransfer(state$, db, action.meta)), withLatestFrom(state$), map(([[signed, transferState], state]) => {
const channel = getOpenChannel(state, { tokenNetwork, partner });
assert(transferState &&
(transferState.expiration > Date.now() / 1e3 ||
transferState.secretRegistered ||
channel.own.locks.find((lock) => lock.secrethash === secrethash && lock.registered)), 'lock expired');
return transferUnlock.success({ message: signed, partner, userId: action.payload?.userId }, action.meta);
// messageSend Unlock handled by transferRetryMessageEpic
// we don't check if transfer was refunded. If partner refunded the transfer but still
// forwarded the payment, we still act honestly and unlock if they revealed
}));
}
/**
* Create an observable to compose and sign a Unlock message/transferUnlock.success action
* As it's an async observable which depends on state and may return an action which changes it,
* the returned observable must be subscribed in a serialized context that ensures non-concurrent
* write access to the channel's balance proof (e.g. concatMap)
*
* @param state$ - Observable of current state
* @param action - transferUnlock.request request action to be sent
* @param deps - RaidenEpicDeps members
* @param deps.signer - Signer instance
* @param deps.log - Logger instance
* @param deps.db - Database instance
* @returns Observable of transferUnlock.success actions
*/
function sendTransferUnlocked(state$, action, deps) {
const { log, db } = deps;
return defer(async () => getTransfer(state$, db, action.meta)).pipe(withLatestFrom(state$), mergeMap(([transferState, state]) => makeAndSignUnlock$(state$, state, action, transferState, deps)), catchError((err) => {
log.warn('Error trying to unlock after SecretReveal', err);
return of(transferUnlock.failure(err, action.meta));
}));
}
/**
* Contains the core logic of {@link makeAndSignLockExpired}.
*
* @param state - Contains The current state of the app
* @param action - The transfer expire action.
* @param transferState - State of the transfer
* @param deps - RaidenEpicDeps members
* @param deps.signer - Signer instance
* @param deps.log - Logger instance
* @returns Observable of transferExpire.success actions
*/
function makeAndSignLockExpired$(state, action, transferState, { signer, log }) {
const secrethash = action.meta.secrethash;
const locked = transferState.transfer;
const tokenNetwork = locked.token_network_address;
const partner = transferState.partner;
const channel = getOpenChannel(state, { tokenNetwork, partner });
let signed$;
if (transferState.expired) {
// expired already signed, use cached
signed$ = of(transferState.expired);
}
else {
assert(locked.lock.expiration.lt(Math.floor(Date.now() / 1e3)), 'lock not yet expired');
assert(channel.own.locks.find((lock) => lock.secrethash === secrethash) && !transferState.unlock, 'transfer already unlocked or expired');
const locksroot = getLocksroot(withoutLock(channel.own, secrethash));
const message = {
type: MessageType.LOCK_EXPIRED,
message_identifier: makeMessageId(),
chain_id: locked.chain_id,
token_network_address: locked.token_network_address,
channel_identifier: locked.channel_identifier,
nonce: channel.own.nextNonce,
transferred_amount: channel.own.balanceProof.transferredAmount,
locked_amount: channel.own.balanceProof.lockedAmount.sub(locked.lock.amount),
locksroot,
recipient: partner,
secrethash,
};
signed$ = from(signMessage(signer, message, { log }));
}
return signed$.pipe(
// messageSend LockExpired handled by transferRetryMessageEpic
map((signed) => transferExpire.success({ message: signed, partner }, action.meta)));
}
/**
* Create an observable to compose and sign a LockExpired message/transferExpire.success action
* As it's an async observable which depends on state and may return an action which changes it,
* the returned observable must be subscribed in a serialized context that ensures non-concurrent
* write access to the channel's balance proof (e.g. concatMap)
*
* @param state$ - Observable of current state
* @param action - transfer request action to be sent
* @param deps - RaidenEpicDeps members
* @param deps.log - Logger instance
* @param deps.signer - Signer instance
* @param deps.db - Database instance
* @returns Observable of transferExpire.success|transferExpire.failure actions
*/
function sendTransferExpired(state$, action, { log, signer, db }) {
return defer(() => getTransfer(state$, db, action.meta)).pipe(withLatestFrom(state$), mergeMap(([transferState, state]) => makeAndSignLockExpired$(state, action, transferState, { signer, log })), catchError((err) => of(transferExpire.failure(err, action.meta))));
}
function receiveTransferSigned(state$, action, { address, log, signer, config$, db, latest$ }) {
const secrethash = action.payload.message.lock.secrethash;
const meta = { secrethash, direction: Direction.RECEIVED };
return combineLatest([state$, config$, latest$]).pipe(first(), mergeMap(([state, { revealTimeout, caps }, { settleTimeout }]) => {
const locked = action.payload.message;
if (db.storageKeys.has(transferKey(meta))) {
log.warn('transfer already present', meta);
const msgId = locked.message_identifier;
const transferState = state.transfers[transferKey(meta)];
// if transfer matches the stored one, re-send Processed once
if (transferState?.transferProcessed &&
transferState.partner === action.meta.address &&
msgId.eq(transferState.transfer.message_identifier)) {
// transferProcessed again will trigger messageSend.request
return of(transferProcessed({ message: untime(transferState.transferProcessed), userId: action.payload.userId }, meta));
}
return EMPTY;
}
// full balance proof validation
const tokenNetwork = locked.token_network_address;
const partner = action.meta.address;
const channel = getOpenChannel(state, { tokenNetwork, partner }, locked);
assert(locked.nonce.eq(channel.partner.nextNonce), [
'nonce mismatch',
{ expected: channel.partner.nextNonce.toNumber(), received: locked.nonce.toNumber() },
]);
const locks = [...channel.partner.locks, locked.lock];
const locksroot = getLocksroot(locks);
assert(locked.locksroot === locksroot, 'locksroot mismatch');
assert(locked.transferred_amount.eq(channel.partner.balanceProof.transferredAmount), 'transferredAmount mismatch');
const locksSum = totalLocked(locks);
assert(locked.locked_amount.eq(locksSum), 'lockedAmount mismatch');
assert(UInt(32).is(channel.partner.balanceProof.transferredAmount.add(locksSum)), 'overflow on future transferredAmount');
const { partnerCapacity } = channelAmounts(channel);
assert(locked.lock.amount.lte(partnerCapacity), 'balanceProof total amount bigger than capacity');
assert(locked.lock.expiration.lt(Math.ceil(Date.now() / 1e3 + settleTimeout)), [
'expiration too far in the future',
{
expiration: locked.lock.expiration,
blockNumber: state.blockNumber,
settleTimeout,
revealTimeout,
},
]);
// accept expired transfers, to apply state change and stay in sync
// with partner, so we can receive later LockExpired and transfers on top of it
assert(locked.recipient === address, "Received transfer isn't for us");
log.info('Receiving transfer of value', locked.lock.amount.toString(), 'of token', channel.token, ', from', locked.initiator, ', through partner', partner);
let request$ = of(undefined);
if (locked.target === address) {
let ignoredDetails;
// danger zone (our interval of confidence we can get secret register tx on-chain)
// starts revealTimeout millis before lock expiration (in secs)
const dangerZoneStart = locked.lock.expiration.sub(revealTimeout);
if (!getCap(caps, Capabilities.RECEIVE))
ignoredDetails = { reason: 'receiving disabled' };
else if (!dangerZoneStart.mul(1e3).gt(Date.now()))
ignoredDetails = {
reason: 'lock expired or expires too soon',
lockExpiration: locked.lock.expiration,
dangerZoneStart,
revealTimeout,
};
if (ignoredDetails) {
log.warn('Ignoring received transfer', ignoredDetails);
}
else {
request$ = defer(async () => {
const decryptedSecret = decryptSecretFromMetadata(locked.metadata, [secrethash, locked.lock.amount, locked.payment_identifier], signer);
if (decryptedSecret)
return decryptedSecret;
const request = {
type: MessageType.SECRET_REQUEST,
payment_identifier: locked.payment_identifier,
secrethash,
amount: locked.lock.amount,
expiration: locked.lock.expiration,
message_identifier: makeMessageId(),
};
return signMessage(signer, request, { log });
});
}
}
const processed$ = defer(async () => {
const processed = {
type: MessageType.PROCESSED,
message_identifier: locked.message_identifier,
};
return signMessage(signer, processed, { log });
});
// if any of these signature prompts fail, none of these actions will be emitted
return combineLatest([processed$, request$]).pipe(mergeMap(function* ([processed, requestOrSecret]) {
yield transferSigned({ message: locked, fee: Zero, partner }, meta);
// sets TransferState.transferProcessed
yield transferProcessed({ message: processed, userId: action.payload.userId }, meta);
if (Secret.is(requestOrSecret)) {
yield transferSecret({ secret: requestOrSecret }, meta);
}
else if (requestOrSecret) {
// request initiator's presence, to be able to request secret
yield matrixPresence.request(undefined, { address: locked.initiator });
// request secret iff we're the target and receiving is enabled
yield transferSecretRequest({
message: requestOrSecret,
...searchValidViaAddress(locked.metadata, locked.initiator),
}, meta);
}
}));
}), catchError((err) => of(transfer.failure(err, meta))));
}
function receiveTransferUnlocked(state$, action, { log, signer, db }) {
const secrethash = getSecrethash(action.payload.message.secret);
const meta = { secrethash, direction: Direction.RECEIVED };
// db.get will throw if not found, being handled on final catchError
return defer(() => getTransfer(state$, db, meta)).pipe(withLatestFrom(state$), mergeMap(([transferState, state]) => {
assert(transferState, 'unknown transfer');
const unlock = action.payload.message;
const partner = action.meta.address;
assert(partner === transferState.partner, 'wrong partner');
// may race on db.get, but will validate on synchronous channel state reducer
assert(!transferState.expired, 'already expired');
if (transferState.unlock) {
log.warn('transfer already unlocked', action.meta);
// if message matches the stored one, re-send Processed once
if (transferState.unlockProcessed &&
unlock.message_identifier.eq(transferState.unlockProcessed.message_identifier)) {
// transferProcessed again will trigger messageSend.request
return of(transferUnlockProcessed({ message: untime(transferState.unlockProcessed), userId: action.payload.userId }, meta));
}
else
return EMPTY;
}
const locked = transferState.transfer;
assert(unlock.token_network_address === locked.token_network_address, 'wrong tokenNetwork');
// unlock validation
const tokenNetwork = unlock.token_network_address;
const channel = getOpenChannel(state, { tokenNetwork, partner }, unlock);
assert(unlock.nonce.eq(channel.partner.nextNonce), [
'nonce mismatch',
{ expected: channel.partner.nextNonce.toNumber(), received: unlock.nonce.toNumber() },
]);
const amount = locked.lock.amount;
const locks = withoutLock(channel.partner, secrethash);
assert(unlock.locksroot === getLocksroot(locks), 'locksroot mismatch');
assert(unlock.transferred_amount.eq(channel.partner.balanceProof.transferredAmount.add(amount)), 'transferredAmount mismatch');
assert(unlock.locked_amount.eq(totalLocked(locks)), 'lockedAmount mismatch');
const processed = {
type: MessageType.PROCESSED,
message_identifier: unlock.message_identifier,
};
// if any of these signature prompts fail, none of these actions will be emitted
return from(signMessage(signer, processed, { log })).pipe(mergeMap(function* (processed) {
// we should already know the secret, but if not, persist again
if (!transferState.secret)
yield transferSecret({ secret: unlock.secret }, meta);
yield transferUnlock.success({ message: unlock, partner }, meta);
// sets TransferState.transferProcessed
yield transferUnlockProcessed({ message: processed, userId: action.payload.userId }, meta);
yield transfer.success({ balanceProof: getBalanceProofFromEnvelopeMessage(unlock) }, meta);
}));
}), catchError((err) => {
log.warn('Error trying to process received Unlock', err);
return of(transferUnlock.failure(err, meta));
}));
}
function receiveTransferExpired(state$, action, { log, signer, db }) {
const secrethash = action.payload.message.secrethash;
const meta = { secrethash, direction: Direction.RECEIVED };
// db.get will throw if not found, being handled on final catchError
return defer(() => getTransfer(state$, db, meta)).pipe(withLatestFrom(state$), mergeMap(([transferState, state]) => {
const expired = action.payload.message;
const partner = action.meta.address;
assert(partner === transferState.partner, 'wrong partner');
// may race on db.get, but will validate on synchronous channel state reducer
assert(!transferState.unlock, 'transfer unlocked');
if (transferState.expired) {
log.warn('transfer already expired', action.meta);
// if message matches the stored one, re-send Processed once
if (transferState.expiredProcessed &&
expired.message_identifier.eq(transferState.expiredProcessed.message_identifier)) {
// transferProcessed again will trigger messageSend.request
return of(transferExpireProcessed({ message: untime(transferState.expiredProcessed), userId: action.payload.userId }, meta));
}
else
return EMPTY;
}
const locked = transferState.transfer;
// expired validation
assert(locked.lock.expiration.lt(Math.ceil(Date.now() / 1e3)), 'not expired yet');
assert(!transferState.secretRegistered, 'secret registered onchain');
assert(expired.token_network_address === locked.token_network_address, 'wrong tokenNetwork');
const tokenNetwork = expired.token_network_address;
const channel = getOpenChannel(state, { tokenNetwork, partner }, expired);
assert(expired.nonce.eq(channel.partner.nextNonce), [
'nonce mismatch',
{ expected: channel.partner.nextNonce.toNumber(), received: expired.nonce.toNumber() },
]);
const locks = withoutLock(channel.partner, secrethash);
assert(expired.locksroot === getLocksroot(locks), 'locksroot mismatch');
assert(expired.locked_amount.eq(totalLocked(locks)), 'lockedAmount mismatch');
assert(expired.transferred_amount.eq(channel.partner.balanceProof.transferredAmount), 'transferredAmount mismatch');
const processed = {
type: MessageType.PROCESSED,
message_identifier: expired.message_identifier,
};
// if any of these signature prompts fail, none of these actions will be emitted
return from(signMessage(signer, processed, { log })).pipe(mergeMap(function* (processed) {
yield transferExpire.success({ message: expired, partner }, meta);
// sets TransferState.transferProcessed
yield transferExpireProcessed({ message: processed, userId: action.payload.userId }, meta);
yield transfer.failure(new RaidenError(ErrorCodes.XFER_EXPIRED, {
block: locked.lock.expiration.toString(),
}), meta);
}));
}), catchError((err) => {
log.warn('Error trying to process received LockExpired', err);
return of(transferExpire.failure(err, meta));
}));
}
function isWithdrawConfirmation(m) {
return m.type === MessageType.WITHDRAW_CONFIRMATION;
}
/**
* Handles a withdraw.request and send a WithdrawRequest to partner
*
* @param state$ - Observable of current state
* @param action - Withdraw request which caused this handling
* @param deps - RaidenEpicDeps members
* @param deps.address - Our address
* @param deps.log - Logger instance
* @param deps.signer - Signer instance
* @param deps.network - Current Network
* @param deps.config$ - Config observable
* @returns Observable of withdrawMessage.request|withdraw.failure actions
*/
function sendWithdrawRequest(state$, action, { log, address, signer, network, config$ }) {
if (action.meta.direction !== Direction.SENT)
return EMPTY;
return combineLatest([state$, config$]).pipe(first(), mergeMap(([state, { revealTimeout }]) => {
const channel = getOpenChannel(state, action.meta);
if (channel.own.pendingWithdraws.some(matchWithdraw(MessageType.WITHDRAW_REQUEST, action.meta)))
return EMPTY; // already requested, skip without failing
// next assert prevents parallel requests from being performed, but we special case here:
// in case there's an still valid confirmation which failed for any reason, we emit it again
// in order to retry sending the respective transaction
const oldConfirmation = channel.own.pendingWithdraws.find(isWithdrawConfirmation);
if (oldConfirmation) {
return [
withdrawMessage.success({ message: oldConfirmation }, {
direction: action.meta.direction,
tokenNetwork: channel.tokenNetwork,
partner: channel.partner.address,
totalWithdraw: oldConfirmation.total_withdraw,
expiration: oldConfirmation.expiration.toNumber(),
}),
withdraw.failure(new RaidenError(ErrorCodes.CNL_WITHDRAW_RETRY_CONFIRMATION), action.meta),
];
}
// although it'd be possible parallel withdraw requests per protocol, PC doesn't like it and
// will get out of sync if we try, so we must prevent it
assert(!channel.own.pendingWithdraws.length, [
ErrorCodes.CNL_WITHDRAW_PENDING,
{ pendingWithdraws: channel.own.pendingWithdraws },
]);
assert(action.meta.totalWithdraw.lte(channelAmounts(channel).ownTotalWithdrawable), ErrorCodes.CNL_WITHDRAW_AMOUNT_TOO_HIGH);
if (action.payload?.coopSettle !== undefined) {
// coopSettle reply has a more relaxed constraint on expiration
assert(action.meta.expiration > Date.now() / 1e3, ErrorCodes.CNL_WITHDRAW_EXPIRES_SOON);
const { ownLocked, partnerLocked, ownTotalWithdrawable, partnerCapacity } = channelAmounts(channel);
assert(!channel.own.locks.length &&
!channel.partner.locks.length &&
action.meta.totalWithdraw.eq(ownTotalWithdrawable) &&
(action.payload.coopSettle || partnerCapacity.isZero()), [ErrorCodes.CNL_COOP_SETTLE_NOT_POSSIBLE, { ownLocked, partnerLocked, partnerCapacity }]);
}
else {
assert(action.meta.totalWithdraw.gt(channel.own.withdraw), ErrorCodes.CNL_WITHDRAW_AMOUNT_TOO_LOW);
assert(action.meta.expiration >= Date.now() / 1e3 + revealTimeout, ErrorCodes.CNL_WITHDRAW_EXPIRES_SOON);
}
const request = {
type: MessageType.WITHDRAW_REQUEST,
message_identifier: makeMessageId(),
chain_id: BigNumber.from(network.chainId),
token_network_address: action.meta.tokenNetwork,
channel_identifier: BigNumber.from(channel.id),
participant: address,
total_withdraw: action.meta.totalWithdraw,
nonce: channel.own.nextNonce,
expiration: BigNumber.from(action.meta.expiration),
...(action.payload?.coopSettle !== undefined
? { coop_settle: action.payload.coopSettle }
: null),
};
return from(signMessage(signer, request, { log })).pipe(map((message) => withdrawMessage.request({ message }, action.meta)));
}), catchError((err) => of(withdraw.failure(err, action.meta))));
}
/**
* Validates a received WithdrawConfirmation message
*
* @param state$ - Observable of current state
* @param action - Withdraw request which caused this handling
* @param deps - RaidenEpicDeps members
* @param deps.address - Our address
* @returns Observable of withdrawMessage.success actions
*/
function receiveWithdrawConfirmation(state$, action, { address }) {
const confirmation = action.payload.message;
const tokenNetwork = confirmation.token_network_address;
const partner = action.meta.address;
let expiration;
try {
expiration = confirmation.expiration.toNumber(); // expiration could be too big
}
catch (e) {
return EMPTY;
}
const withdrawMeta = {
direction: Direction.SENT,
tokenNetwork,
partner,
totalWithdraw: confirmation.total_withdraw,
expiration,
};
return state$.pipe(first(), map((state) => {
getOpenChannel(state, { tokenNetwork, partner }, confirmation);
assert(confirmation.participant === address, 'participant mismatch');
// don't validate request presence here, to always update nonce, but do on tx send
return withdrawMessage.success({ message: confirmation }, withdrawMeta);
}), catchError((err) => of(withdrawMessage.failure(err, withdrawMeta))));
}
/**
* Handles a withdrawExpire.request, sign and send a WithdrawExpired message to partner
*
* @param state$ - Observable of current state
* @param action - WithdrawExpire request which caused this call
* @param deps - RaidenEpicDeps members
* @param deps.address - Our address
* @param deps.log - Logger instance
* @param deps.signer - Signer instance
* @returns Observable of withdrawMessage.request|withdraw.failure actions
*/
function sendWithdrawExpired(state$, action, { log, address, signer }) {
if (action.meta.direction !== Direction.SENT)
return EMPTY;
return state$.pipe(first(), mergeMap((state) => {
const channel = getOpenChannel(state, action.meta);
assert(!channel.own.pendingWithdraws.some(matchWithdraw(MessageType.WITHDRAW_EXPIRED, action.meta)), 'already expired');
const req = channel.own.pendingWithdraws.find(matchWithdraw(MessageType.WITHDRAW_REQUEST, action.meta));
assert(req, 'no matching WithdrawRequest found, maybe already confirmed');
assert(action.meta.expiration < Date.now() / 1e3, 'not expired yet');
const expired = {
type: MessageType.WITHDRAW_EXPIRED,
message_identifier: makeMessageId(),
chain_id: req.chain_id,
token_network_address: req.token_network_address,
channel_identifier: req.channel_identifier,
participant: address,
total_withdraw: action.meta.totalWithdraw,
nonce: channel.own.nextNonce,
expiration: req.expiration,
};
return from(signMessage(signer, expired, { log })).pipe(map((message) => withdrawExpire.success({ message }, action.meta)));
}), catchError((err) => of(withdrawExpire.failure(err, action.meta))));
}
/**
* Validate a [[WithdrawRequest]], compose and send a [[WithdrawConfirmation]]
*
* Validate channel exists and is open, and that total_withdraw is less than or equal withdrawable
* amount. We need it inside [[transferGenerateAndSignEnvelopeMessageEpic]] concatMap/lock because
* we read and change 'nextNonce', even though WithdrawConfirmation doesn't carry a full
* balanceProof. If request's nonce is valid but it already expired (old replied message), we still
* accept the state change, but don't compose/send the confirmation, and let it expire.
* Instead of storing confirmation in state and retrying, we just cache it and send the cached
* signed message on each retried request received.
*
* @param state$ - Observable of current state
* @param action - Withdraw request which caused this handling
* @param signer - RaidenEpicDeps members
* @param signer.signer - Signer instance
* @param signer.log - Logger instance
* @returns Observable of transferExpire.success|transferExpire.failure actions
*/
function receiveWithdrawRequest(state$, action, { signer, log }) {
const request = action.payload.message;
const tokenNetwork = request.token_network_address;
const partner = request.participant;
let expiration;
try {
expiration = request.expiration.toNumber(); // expiration could be too big
}
catch (e) {
return EMPTY;
}
const withdrawMeta = {
direction: Direction.RECEIVED,
tokenNetwork,
partner,
totalWithdraw: request.total_withdraw,
expiration,
};
return state$.pipe(first(), mergeMap((state) => {
assert(request.participant === action.meta.address, 'participant mismatch');
const channel = getOpenChannel(state, { tokenNetwork, partner }, request);
let confirmation$;
const persistedConfirmation = channel.partner.pendingWithdraws.find(matchWithdraw(MessageType.WITHDRAW_CONFIRMATION, request));
if (persistedConfirmation) {
confirmation$ = of(persistedConfirmation);
}
else {
assert(request.nonce.eq(channel.partner.nextNonce), [
'nonce mismatch',
{ expected: channel.partner.nextNonce.toNumber(), received: request.nonce.toNumber() },
]);
assert(request.total_withdraw.lte(channelAmounts(channel).partnerTotalWithdrawable), 'invalid total_withdraw');
// don't mind expiration, and always reply with confirmation
// expired confirmations are useless on-chain, but confirms message
const confirmation = {
type: MessageType.WITHDRAW_CONFIRMATION,
message_identifier: request.message_identifier,
chain_id: request.chain_id,
token_network_address: request.token_network_address,
channel_identifier: request.channel_identifier,
participant: request.participant,
total_withdraw: request.total_withdraw,
nonce: channel.own.nextNonce,
expiration: request.expiration,
};
confirmation$ = from(signMessage(signer, confirmation, { log }));
}
return confirmation$.pipe(mergeMap(function* (message) {
// first, emit 'WithdrawRequest', to increase partner's nonce in state
yield withdrawMessage.request({ message: request }, withdrawMeta);
// emit our composed 'WithdrawConfirmation' to increase our nonce in state
yield withdrawMessage.success({ message }, withdrawMeta);
// send once per received request; confirmation signature is cached above
yield messageSend.request({ message }, {
address: partner,
msgId: action.payload.message.message_identifier.toString(),
});
}));
}), catchError((err) => of(withdrawMessage.failure(err, withdrawMeta))));
}
/**
* Validate a received [[WithdrawExpired]] message, emit withdrawExpire.success and send Processed
*
* @param state$ - Observable of current state
* @param action - Withdraw request which caused this handling
* @param signer - RaidenEpicDeps members
* @param signer.signer - Signer instance
* @param signer.log - Logger instance
* @param cache - A Map to store and reuse previously Signed<WithdrawConfirmation>
* @returns Observable of withdrawExpire.success|withdrawExpireProcessed|messageSend.request actions
*/
function receiveWithdrawExpired(state$, action, { signer, log }, cache) {
const expired = action.payload.message;
const tokenNetwork = expired.token_network_address;
const partner = expired.participant;
let expiration;
try {
expiration = expired.expiration.toNumber(); // expiration could be too big
}
catch (e) {
return EMPTY;
}
const withdrawMeta = {
direction: Direction.RECEIVED,
tokenNetwork,
partner,
totalWithdraw: expired.total_withdraw,
expiration,
};
return state$.pipe(first(), mergeMap((state) => {
assert(partner === action.meta.address, 'participant mismatch');
const channel = getOpenChannel(state, { tokenNetwork, partner }, expired);
let processed$;
const cacheKey = `${channelUniqueKey(channel)}+${expired.message_identifier.toString()}`;
const cached = cache.get(cacheKey);
if (cached)
processed$ = of(cached);
else {
assert(expired.nonce.lte(channel.partner.nextNonce), [
'nonce mismatch',
{ expected: channel.partner.nextNonce.toNumber(), received: expired.nonce.toNumber() },
]);
assert(withdrawMeta.expiration <= Date.now() / 1e3, 'not expired yet');
const processed = {
type: MessageType.PROCESSED,
message_identifier: expired.message_identifier,
};
processed$ = from(signMessage(signer, processed, { log })).pipe(tap((signed) => cache.set(cacheKey, signed)));
}
return processed$.pipe(mergeMap(function* (processed) {
// as we've received and validated this message, emit failure to increment nextNonce
yield withdrawExpire.success({ message: expired }, withdrawMeta);
// emits withdrawCompleted to clear messages from partner's pendingWithdraws array
yield messageSend.request({ message: processed }, { address: partner, msgId: processed.message_identifier.toString() });
yield withdrawCompleted(undefined, withdrawMeta);
}));
}), catchError((err) => of(withdrawExpire.failure(err, withdrawMeta))));
}
/**
* Serialize creation and signing of BalanceProof-changing messages or actions
* Actions which change any data in any channel balance proof must only ever be created reading
* state inside the serialization flow provided by the concatMap, and also be composed and produced
* inside it (inner, subscribed observable)
*
* @param action$ - Observable of RaidenActions
* @param state$ - Observable of RaidenStates
* @param deps - RaidenEpicDeps
* @returns Observable of output actions for this epic
*/
export function transferGenerateAndSignEnvelopeMessageEpic(action$, {}, deps) {
const processedCache = new LruCache(32);
const state$ = deps.latest$.pipe(pluckDistinct('state'), completeWith(action$)); // replayed(1)' state$
return merge(action$.pipe(filter(isActionOf([
transfer.request,
transferUnlock.request,
transferExpire.request,
withdraw.request,
withdrawExpire.request,
]))),
// merge separatedly, to filter per message type before concat
action$.pipe(filter(isMessageReceivedOfType([
Signed(LockedTransfer),
Signed(Unlock),
Signed(LockExpired),
Signed(WithdrawRequest),
Signed(WithdrawConfirmation),
Signed(WithdrawExpired),
])))).pipe(concatMap((action) => {
let output$;
switch (action.type) {
case transfer.request.type:
if (transferRequestIsResolved(action))
output$ = sendTransferSigned(state$, action, deps);
else
output$ = EMPTY;
break;
case transferUnlock.request.type:
output$ = sendTransferUnlocked(state$, action, deps);
break;
case transferExpire.request.type:
output$ = sendTransferExpired(state$, action, deps);
break;
case withdraw.request.type:
output$ = sendWithdrawRequest(state$, action, deps);
break;
case withdrawExpire.request.type:
output$ = sendWithdrawExpired(state$, action, deps);
break;
case messageReceived.type:
switch (action.payload.message.type) {
case MessageType.LOCKED_TRANSFER:
output$ = receiveTransferSigned(state$, action, deps);
break;
case MessageType.UNLOCK:
output$ = receiveTransferUnlocked(state$, action, deps);
break;
case MessageType.LOCK_EXPIRED:
output$ = receiveTransferExpired(state$, action, deps);
break;
case MessageType.WITHDRAW_REQUEST:
output$ = receiveWithdrawRequest(state$, action, deps);
break;
case MessageType.WITHDRAW_CONFIRMATION:
output$ = receiveWithdrawConfirmation(state$, action, deps);
break;
case MessageType.WITHDRAW_EXPIRED:
output$ = receiveWithdrawExpired(state$, action, deps, processedCache);
break;
}
break;
}
return output$;
}));
}
//# sourceMappingURL=locked.js.map