UNPKG

raiden-ts

Version:

Raiden Light Client Typescript/Javascript SDK

281 lines 12.2 kB
import { arrayify, concat as concatBytes } from '@ethersproject/bytes'; import { hashMessage } from '@ethersproject/hash'; import { recoverPublicKey } from '@ethersproject/signing-key'; import { computeAddress } from '@ethersproject/transactions'; import * as t from 'io-ts'; import constant from 'lodash/constant'; import memoize from 'lodash/memoize'; import uniqBy from 'lodash/uniqBy'; import { defer, EMPTY, firstValueFrom, from, of } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { catchError, concatMap, delay, first, last, map, mergeAll, mergeMap, takeUntil, tap, throwIfEmpty, toArray, } from 'rxjs/operators'; import { MessageTypeId } from '../messages/utils'; import { matrixPresence } from '../transport/actions'; import { parseCaps } from '../transport/utils'; import { encode, jsonParse } from '../utils/data'; import { assert, ErrorCodes, networkErrors, RaidenError } from '../utils/error'; import { LruCache } from '../utils/lru'; import { pluckDistinct, retryAsync$, withMergeFrom } from '../utils/rx'; import { Address, decode, UInt } from '../utils/types'; import { AddressMetadata, PfsError, PfsMode } from './types'; const serviceRegistryToken = memoize(async (serviceRegistryContract, pollingInterval) => firstValueFrom(retryAsync$(async () => serviceRegistryContract.callStatic.token(), pollingInterval, { onErrors: networkErrors }))); /** * Fetch, validate and cache the service URL for a given URL or service address * (if registered on ServiceRegistry) * * @param pfsAddressUrl - service Address or URL * @returns Promise to validated URL */ const pfsAddressUrl = memoize(async function pfsAddressUrl_(pfsAddrOrUrl, { serviceRegistryContract }) { let url = pfsAddrOrUrl; if (Address.is(pfsAddrOrUrl)) url = await serviceRegistryContract.callStatic.urls(pfsAddrOrUrl); return validatePfsUrl(url); }); const urlRegex = process.env['NODE_ENV'] === 'production' ? /^(?:https:\/\/)?[^\s\/$.?#&"']+\.[^\s\/$?#&"']+$/ : /^(?:(http|https):\/\/)?([^\s\/$.?#&"']+\.)*[^\s\/$?#&"']+(?:(\d+))*$/; function validatePfsUrl(url) { assert(url, ErrorCodes.PFS_EMPTY_URL); assert(urlRegex.test(url), [ErrorCodes.PFS_INVALID_URL, { url }]); // default to https for schema-less urls if (!url.match(/^https?:\/\//)) url = `https://${url}`; return url; } const pfsAddressCache_ = new LruCache(32); /** * Fetch PFS info & validate for a given server address or URL * * This is a memoized function which caches by url or address, network and registry used. * * @param pfsAddrOrUrl - PFS account/address or URL * @param deps - RaidenEpicDeps needed for various parameters * @param deps.log - Logger instance * @param deps.serviceRegistryContract - ServiceRegistry contract instance * @param deps.network - Current Network * @param deps.contractsInfo - ContractsInfo mapping * @param deps.provider - Eth provider * @param deps.config$ - Config observable * @param deps.latest$ - Latest observable * @returns Promise containing PFS server info */ export async function pfsInfo(pfsAddrOrUrl, { log, serviceRegistryContract, network, contractsInfo, provider, config$, latest$, }) { const { pfsMaxFee } = await firstValueFrom(config$); const { state } = await firstValueFrom(latest$); const { services } = state; /** Codec for PFS /api/v1/info result schema */ const PfsInfo = t.type({ matrix_server: t.string, network_info: t.type({ // literals will fail if trying to decode anything different from these constants chain_id: t.literal(network.chainId), token_network_registry_address: t.literal(contractsInfo.TokenNetworkRegistry.address), }, 'NetworkInfo'), payment_address: Address, price_info: UInt(32), }, 'PfsInfo'); try { // if it's an address, fetch url from ServiceRegistry, else it's already the URL const url = await pfsAddressUrl(pfsAddrOrUrl, { serviceRegistryContract }); const start = Date.now(); const res = await fetch(url + '/api/v1/info', { mode: 'cors' }); const rtt = Date.now() - start; const text = await res.text(); assert(res.ok, [ErrorCodes.PFS_ERROR_RESPONSE, { text }]); const info = decode(PfsInfo, jsonParse(text)); const { payment_address: address, price_info: price } = info; assert(price.lte(pfsMaxFee), [ErrorCodes.PFS_TOO_EXPENSIVE, { price }]); pfsAddressCache_.set(url, Promise.resolve(address)); const validTill = services[address] ?? (await serviceRegistryContract.callStatic.service_valid_till(address)).toNumber() * 1e3; return { address, url, matrixServer: info.matrix_server, rtt, price, token: await serviceRegistryToken(serviceRegistryContract, provider.pollingInterval), validTill, }; } catch (err) { log.warn('Error fetching PFS info:', pfsAddrOrUrl, err); throw err; } } /** * Returns the address for the PFS/service with the given URL. * Result is cached and this cache is shared with [[pfsInfo]] calls. * * @param url - Url of the PFS to retrieve address for * @param deps - Epics dependencies (for pfsInfo) * @returns Promise to Address of PFS on given URL */ export const pfsInfoAddress = Object.assign(async function pfsInfoAddress(url, deps) { url = validatePfsUrl(url); let addrPromise = pfsAddressCache_.get(url); if (!addrPromise) { // since the url is already validated, this will always set the cache, even if to the promise // which will be rejected on '/info' request/validation addrPromise = pfsInfo(url, deps).then(({ address }) => address); pfsAddressCache_.set(url, addrPromise); } return addrPromise; }, { cache: pfsAddressCache_ }); /** * Retrieve pfsInfo for these servers & return sorted PFS info * * Sort order is price then response time (rtt). * Throws if no server can be validated, meaning either there's none in the current network or * we're out-of-sync (outdated or ahead of PFS's deployment network version). * * @param pfsList - Array of PFS addresses or URLs * @param deps - RaidenEpicDeps array * @returns Observable of online, validated & sorted PFS info array */ export function pfsListInfo(pfsList, deps) { return from(pfsList).pipe(mergeMap((addrOrUrl) => defer(async () => pfsInfo(addrOrUrl, deps)).pipe(catchError(constant(EMPTY))), 5), toArray(), map((list) => { assert(list.length || !pfsList.length, ErrorCodes.PFS_INVALID_INFO); return uniqBy(list, 'url').sort((a, b) => { const dif = a.price.sub(b.price); // first, sort by price if (dif.lt(0)) return -1; else if (dif.gt(0)) return 1; // if it's equal, tiebreak on rtt else return a.rtt - b.rtt; }); })); } /** * @param metadata - to convert to presence * @returns presence for metadata, assuming node is available */ function metadataToPresence(metadata) { const pubkey = recoverPublicKey(arrayify(hashMessage(metadata.user_id)), metadata.displayname); const address = computeAddress(pubkey); return matrixPresence.success({ userId: metadata.user_id, available: true, ts: Date.now(), caps: parseCaps(metadata.capabilities), pubkey, }, { address }); } /** * Validates metadata was signed by address * * @param metadata - Peer's metadata * @param address - Peer's address * @param opts - Options * @param opts.log - Logger instance * @returns presence iff metadata is valid and was signed by address */ export function validateAddressMetadata(metadata, address, { log } = {}) { if (!metadata) return; try { const presence = metadataToPresence(metadata); assert(presence.meta.address === address, [ 'Wrong signature', { expected: address, recovered: presence.meta.address }, ]); return presence; } catch (error) { log?.warn('Invalid address metadata', { address, metadata, error }); } } /** * @param address - Peer address to fetch presence for * @param pfsAddrOrUrl - PFS/service address to fetch presence from * @param deps - Epics dependencies subset * @param deps.serviceRegistryContract - Contract instance * @returns Observable to peer's presence or error */ export function getPresenceFromService$(address, pfsAddrOrUrl, deps) { return defer(async () => pfsAddressUrl(pfsAddrOrUrl, deps)).pipe(withMergeFrom((url) => fromFetch(`${url}/api/v1/address/${address}/metadata`).pipe(mergeMap(async (res) => res.json()))), map(([url, json]) => { try { const metadata = decode(AddressMetadata, json); const presence = validateAddressMetadata(metadata, address, deps); assert(presence, ['Invalid metadata signature', { peer: address, presence: metadata }]); assert(presence.payload.caps, ['Invalid capabilities format', metadata.capabilities]); return presence; } catch (err) { try { const { errors: msg, ...details } = decode(PfsError, json); err = new RaidenError(msg, { ...details, pfs: url }); } catch (e) { } throw err; } })); } /** * Pack an IOU for signing or verification * * @param iou - IOU to be packed * @returns Packed IOU as a UInt8Array */ export function packIOU(iou) { return concatBytes([ encode(iou.one_to_n_address, 20), encode(iou.chain_id, 32), encode(MessageTypeId.IOU, 32), encode(iou.sender, 20), encode(iou.receiver, 20), encode(iou.amount, 32), encode(iou.claimable_until, 32), ]); } /** * Sign an IOU with signer * * @param signer - Signer instance * @param iou - IOU to be signed * @returns Signed IOU */ export async function signIOU(signer, iou) { return signer .signMessage(packIOU(iou)) .then((signature) => ({ ...iou, signature: signature })); } /** * Choose best PFS and fetch info from it * * @param pfsByAction - Override config for this call: explicit PFS, disabled or undefined * @param deps - Epics dependencies * @param sortByRtt - Whether to sort PFSs by rtt, instead of price (default) * @returns Observable to choosen PFS */ export function choosePfs$(pfsByAction, deps, sortByRtt = false) { const { log, config$, latest$, init$ } = deps; return config$.pipe(first(), mergeMap(({ pfsMode, additionalServices }) => { if (pfsByAction) return of(pfsByAction); assert(pfsByAction !== null && pfsMode !== PfsMode.disabled, ErrorCodes.PFS_DISABLED); if (pfsMode === PfsMode.onlyAdditional) { let firstError; return from(additionalServices).pipe(concatMap((service) => defer(async () => pfsInfo(service, deps)).pipe(catchError((e) => ((firstError ?? (firstError = e)), EMPTY)))), throwIfEmpty(() => firstError)); } else { return latest$.pipe(pluckDistinct('state', 'services'), map((services) => [...additionalServices, ...Object.keys(services)]), takeUntil(init$.pipe(last(), delay(10))), first((services) => services.length > 0), // fetch pfsInfo from whole list & sort it mergeMap((services) => { if (sortByRtt) return from(services).pipe(mergeMap((addrOrUrl) => defer(async () => pfsInfo(addrOrUrl, deps)).pipe(catchError(constant(EMPTY))))); else return pfsListInfo(services, deps).pipe(mergeAll()); // sort by price })); } }), throwIfEmpty(constant(new RaidenError(ErrorCodes.PFS_INVALID_INFO))), tap((pfs) => { if (pfs.validTill < Date.now()) { log.warn('WARNING: PFS registration not valid! This service deposit may have expired and it may not receive network updates anymore.', pfs); } })); } //# sourceMappingURL=utils.js.map