UNPKG

raiden-ts

Version:

Raiden Light Client Typescript/Javascript SDK

861 lines 45.4 kB
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