raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
281 lines • 12.2 kB
JavaScript
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