UNPKG

raiden-ts

Version:

Raiden Light Client Typescript/Javascript SDK

325 lines 15.3 kB
import { BigNumber } from '@ethersproject/bignumber'; import { concat as concatBytes } from '@ethersproject/bytes'; import { Zero } from '@ethersproject/constants'; import { toUtf8Bytes } from '@ethersproject/strings'; import { verifyMessage } from '@ethersproject/wallet'; import { Decimal } from 'decimal.js'; import isEmpty from 'lodash/isEmpty'; import omit from 'lodash/omit'; import { concat, defer, EMPTY, from, of, throwError } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { catchError, concatMap, first, map, mergeMap, tap, timeout, toArray, withLatestFrom, } from 'rxjs/operators'; import { ChannelState } from '../../channels/state'; import { channelAmounts, channelKey } from '../../channels/utils'; import { intervalFromConfig } from '../../config'; import { Capabilities } from '../../constants'; import { searchValidMetadata } from '../../transfers/utils'; import { getCap, stringifyCaps } from '../../transport/utils'; import { jsonParse, jsonStringify } from '../../utils/data'; import { assert, ErrorCodes, networkErrors, RaidenError } from '../../utils/error'; import { retryWhile, withMergeFrom } from '../../utils/rx'; import { decode, Signed, UInt } from '../../utils/types'; import { iouClear, iouPersist, pathFind } from '../actions'; import { Fee, IOU, LastIOUResults, PfsError, PfsMode, PfsResult } from '../types'; import { choosePfs$, packIOU, signIOU } from '../utils'; /** * Returns a ISO string with millisecond resolution (same as PC) * * @param time - Date instance * @returns string representing time in the format expected by PFS */ export function makeTimestamp(time = new Date()) { return time.toISOString().substring(0, 23) + '000'; } function fetchLastIou$(pfs, tokenNetwork, { address, log, signer, network, contractsInfo, config$ }) { return defer(() => { const timestamp = makeTimestamp(), message = concatBytes([address, pfs.address, toUtf8Bytes(timestamp)]); return from(signer.signMessage(message)).pipe(map((signature) => ({ sender: address, receiver: pfs.address, timestamp, signature }))); }).pipe(withLatestFrom(config$), mergeMap(([payload, { httpTimeout }]) => fromFetch(`${pfs.url}/api/v1/${tokenNetwork}/payment/iou?${new URLSearchParams(payload).toString()}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }).pipe(timeout(httpTimeout), retryWhile(intervalFromConfig(config$), { onErrors: [...networkErrors, 'TimeoutError'] }))), withLatestFrom(config$), mergeMap(async ([response, { pfsIouTimeout }]) => { if (response.status === 404) { const iou = { sender: address, receiver: pfs.address, chain_id: BigNumber.from(network.chainId), amount: Zero, one_to_n_address: contractsInfo.OneToN.address, claimable_until: BigNumber.from(Math.round(Date.now() / 1e3 + pfsIouTimeout)), }; log.warn('PFS: new IOU created', iou); return iou; // return empty/zeroed IOU, but with valid new expiration } const text = await response.text(); assert(response.ok, [ ErrorCodes.PFS_LAST_IOU_REQUEST_FAILED, { responseStatus: response.status, responseText: text }, ]); const { last_iou: lastIou } = decode(LastIOUResults, jsonParse(text)); // accept last IOU only if it was signed by us const signer = verifyMessage(packIOU(lastIou), lastIou.signature); assert(signer === address, [ ErrorCodes.PFS_IOU_SIGNATURE_MISMATCH, { signer, address, lastIou }, ]); log.warn('PFS: fetched non-cached signed IOU from server', lastIou); return lastIou; })); } function prepareNextIOU$(pfs, tokenNetwork, deps) { if (pfs.price.isZero()) return of(undefined); return deps.latest$.pipe(first(), mergeMap(({ state }) => { const cachedIOU = state.iou[tokenNetwork]?.[pfs.address]; return cachedIOU ? of(cachedIOU) : fetchLastIou$(pfs, tokenNetwork, deps); }), // increment lastIou by pfs.price; don't touch expiration, PFS doesn't like it getting // updated and will give an error asking to update previous IOU instead of creating a new one map((iou) => ({ ...iou, amount: iou.amount.add(pfs.price) })), mergeMap((iou) => signIOU(deps.signer, iou))); } /** * Either returns true if given channel can route a payment, or a reason as string if not * * @param channel - Channel to check * @param value - amount of tokens to check if channel can route * @returns true if channel can route, string containing reason if not */ function channelCanRoute(channel, value) { const partner = channel.partner.address; if (channel.state !== ChannelState.open) return `path: channel with "${partner}" in state "${channel.state}" instead of "${ChannelState.open}"`; const { ownCapacity: capacity } = channelAmounts(channel); if (capacity.lt(value)) return `path: channel with "${partner}" doesn't have enough capacity=${capacity.toString()}`; return true; } /** * Either returns true if partner can route a payment, or a reason as string if not * * @param channel - current Channel state, or undefined * @param partnerPresence - possibly a partner on given tokenNetwork * @param direct - Whether this is a direct transfer or mediated one * @param value - amount of tokens to check if channel can route * @returns true if channel can route, string containing reason if not */ function partnerCanRoute(channel, partnerPresence, direct, value) { if (!channel) return `path: there's no direct channel with partner`; if (partnerPresence?.payload.available === false) return `path: partner not available in transport`; if (!direct && !getCap(partnerPresence?.payload.caps, Capabilities.MEDIATE)) return `path: partner doesn't mediate transfers`; return channelCanRoute(channel, value); } function validateRouteTarget(action, state, targetPresence) { const { tokenNetwork, target, value } = action.meta; assert(Object.values(state.tokens).includes(tokenNetwork), [ ErrorCodes.PFS_UNKNOWN_TOKEN_NETWORK, { tokenNetwork }, ]); assert(targetPresence.payload.available, [ErrorCodes.PFS_TARGET_OFFLINE, { target }]); assert(getCap(targetPresence.payload.caps, Capabilities.RECEIVE), [ ErrorCodes.PFS_TARGET_NO_RECEIVE, { target }, ]); assert(Object.values(state.channels).some((channel) => channelCanRoute(channel, value) === true), ErrorCodes.PFS_NO_ROUTES_BETWEEN_NODES); } function pfsIsDisabled(action, config) { const disabledByAction = action.payload.pfs === null; const disabledByConfig = !action.payload.pfs && config.pfsMode === PfsMode.disabled; return disabledByAction || disabledByConfig; } function getRouteFromPfs$(action, deps) { return choosePfs$(action.payload.pfs, deps).pipe(first(), // pop first/best PFS withMergeFrom((pfs) => prepareNextIOU$(pfs, action.meta.tokenNetwork, deps)), withLatestFrom(deps.config$), withMergeFrom(([[pfs, iou], config]) => requestPfs$(pfs, iou, action.meta, { address: deps.address, config })), map(([[[, iou], config], { response, text }]) => { // any decode error here will throw early and end up in catchError const data = jsonParse(text); if (!response.ok) { const error = decode(PfsError, data, undefined, deps.log.warn); return { iou, error }; } return { iou, paths: parsePfsResponse(data, action.meta.value, config) }; })); } function filterPaths$([action, paths], state, { address, log }) { let firstPath; let firstError; const invalidatedRecipients = new Set(); const { tokenNetwork, value, target } = action.meta; return from(paths).pipe(concatMap((path) => { let recipient; return defer(() => { assert(path.path.length >= 2 && path.path[0] === address, 'we are not the first address in path'); recipient = path.path[1]; assert(!invalidatedRecipients.has(recipient), 'already invalidated recipient'); if (firstPath) { assert(firstPath.path[1] === recipient, 'already selected another recipient'); assert(firstPath.fee.gte(path.fee), 'already selected a smaller fee'); } else { const channel = state.channels[channelKey({ tokenNetwork, partner: recipient })]; const partnerPresence = searchValidMetadata(path.address_metadata, recipient); const partnerCanRoutePossible = partnerCanRoute(channel, partnerPresence, target === recipient, value); if (partnerCanRoutePossible !== true) throw new RaidenError(partnerCanRoutePossible, { partnerPresence, channel }); for (const [idx, hop] of Object.entries(path.path.slice(2, -1))) { const presence = searchValidMetadata(path.address_metadata, hop); assert(getCap(presence?.payload.caps, Capabilities.MEDIATE), [ "path: hop doesn't mediate transfers", { hopIndex: idx + 2, hop, hopPresence: presence }, ]); } } return of(path); }).pipe(tap((path) => (firstPath ?? (firstPath = path))), catchError((error) => { firstError ?? (firstError = error); log.warn('Invalidated received route', { error, path }); if (recipient) invalidatedRecipients.add(recipient); return EMPTY; })); }), toArray(), map((paths) => { if (!paths.length) throw firstError ?? new RaidenError(ErrorCodes.PFS_NO_ROUTES_FOUND); return paths; })); } function requestPfs$(pfs, iou, { tokenNetwork, target, value }, { address, config }) { const { httpTimeout, pfsMaxPaths } = config; const body = jsonStringify({ from: address, to: target, value: UInt(32).encode(value), max_paths: pfsMaxPaths, iou: iou ? Signed(IOU).encode(iou) : undefined, }); return fromFetch(`${pfs.url}/api/v1/${tokenNetwork}/paths`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }).pipe(timeout(httpTimeout), retryWhile(intervalFromConfig(of(config)), { onErrors: [...networkErrors, 'TimeoutError'], }), mergeMap(async (response) => ({ response, text: await response.text() }))); } function addFeeSafetyMargin(fee, amount, { pfsSafetyMargin }) { let feeMarginMultiplier = 0.0; let amountMultiplier = 0.0; if (typeof pfsSafetyMargin === 'number') { // legacy: receive feeMultiplier directly, e.g. 1.1 = +10% feeMarginMultiplier = pfsSafetyMargin - 1.0; } else { feeMarginMultiplier = pfsSafetyMargin[0]; amountMultiplier = pfsSafetyMargin[1]; } const feeMargin = new Decimal(fee.toHexString()) .abs() .mul(feeMarginMultiplier) .add(new Decimal(amount.toHexString()).mul(amountMultiplier)); return decode(Fee, // fee += abs(estimatedFee) * feeMarginMultiplier + amount * amountMultiplier fee.add(feeMargin.toFixed(0, Decimal.ROUND_CEIL))); } // map a generic InputPaths to a specific Paths (see docstring of the types) function inputToPaths(inputPaths, amount, config) { return inputPaths.map((input) => { const path = 'path' in input ? input.path : input.route; const rest1 = omit(input, ['path', 'route']); const fee = 'fee' in input ? input.fee : input.estimated_fee.isZero() ? input.estimated_fee : addFeeSafetyMargin(input.estimated_fee, amount, config); const rest2 = omit(rest1, ['fee', 'estimated_fee']); return { path, fee, ...rest2 }; }); } function parsePfsResponse(data, amount, config) { // decode results and cap also client-side for pfsMaxPaths return inputToPaths(decode(PfsResult, data).result, amount, config).slice(0, config.pfsMaxPaths); } function shouldPersistIou(route) { return 'paths' in route || isNoRouteFoundError(route.error); } function isNoRouteFoundError(error) { return error?.error_code === 2201; } /** * @param action - pfs request action * @param deps - Epics dependencies * @param latest - Latest object * @param latest.state - Latest state * @param latest.config - Latest config * @param targetPresence - Current presence of target * @returns Observable containing paths, new iou or error */ export function getRoute$(action, deps, { state, config }, targetPresence) { validateRouteTarget(action, state, targetPresence); const { tokenNetwork, target, value } = action.meta; if (action.payload.paths) { return of({ paths: inputToPaths(action.payload.paths, value, config), iou: undefined }); } else if (partnerCanRoute(state.channels[channelKey({ tokenNetwork, partner: target })], targetPresence, true, value) === true) { const addressMetadata = {}; if (state.transport.setup) { addressMetadata[deps.address] = { user_id: state.transport.setup.userId, displayname: state.transport.setup.displayName, capabilities: stringifyCaps(config.caps ?? {}), }; } return of({ paths: [ { path: [deps.address, target], fee: Zero, ...(!isEmpty(addressMetadata) ? { address_metadata: addressMetadata } : {}), }, ], iou: undefined, }); } else if (pfsIsDisabled(action, config)) { throw new RaidenError(ErrorCodes.PFS_DISABLED); } else { return getRouteFromPfs$(action, deps); } } /** * @param opts - validation options * @param opts."0" - pfs request action * @param opts."1" - Received route to validate * @param state - Latest RaidenState * @param deps - Epics dependencies * @returns Observable of results actions after route is validated */ export function validateRoute$([action, route], state, deps) { const { tokenNetwork } = action.meta; const { iou } = route; let iou$ = EMPTY; if (iou) { iou$ = of(shouldPersistIou(route) ? iouPersist({ iou }, { tokenNetwork: tokenNetwork, serviceAddress: iou.receiver }) : iouClear(undefined, { tokenNetwork: tokenNetwork, serviceAddress: iou.receiver, })); } let result$; if ('error' in route) { const { error } = route; result$ = throwError(() => isNoRouteFoundError(error) ? new RaidenError(ErrorCodes.PFS_NO_ROUTES_BETWEEN_NODES) : new RaidenError(ErrorCodes.PFS_ERROR_RESPONSE, { errorCode: error.error_code, errors: error.errors, })); } else { result$ = filterPaths$([action, route.paths], state, deps).pipe(map((paths) => pathFind.success({ paths }, action.meta))); } return concat(iou$, result$); } //# sourceMappingURL=helpers.js.map