UNPKG

raiden-ts

Version:

Raiden Light Client Typescript/Javascript SDK

963 lines 59.1 kB
import './polyfills'; import { BigNumber } from '@ethersproject/bignumber'; import { AddressZero, MaxUint256, Zero } from '@ethersproject/constants'; import { JsonRpcProvider, Web3Provider } from '@ethersproject/providers'; import isUndefined from 'lodash/isUndefined'; import memoize from 'lodash/memoize'; import omitBy from 'lodash/omitBy'; import logging from 'loglevel'; import { applyMiddleware, createStore } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; import { createLogger } from 'redux-logger'; import { createEpicMiddleware } from 'redux-observable'; import { EMPTY, firstValueFrom, from, lastValueFrom, of } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { catchError, concatMap, filter, first, map, mergeMap, pluck, skip, timeout, toArray, } from 'rxjs/operators'; import { raidenConfigUpdate, RaidenEvents, raidenShutdown, raidenStarted } from './actions'; import { channelClose, channelDeposit, channelOpen, channelSettle, tokenMonitored, } from './channels/actions'; import { ChannelState } from './channels/state'; import { channelAmounts, channelKey, transact } from './channels/utils'; import { intervalFromConfig, PartialRaidenConfig } from './config'; import { ShutdownReason } from './constants'; import { CustomToken__factory } from './contracts'; import { dumpDatabase, getTransfers } from './db/utils'; import { combineRaidenEpics, getLatest$ } from './epics'; import { chooseOnchainAccount, getContractWithSigner, getSigner, getState, getUdcBalance, initTransfers$, makeDependencies, makeSyncedPromise, makeTokenInfoGetter, mapRaidenChannels, waitChannelSettleable$, waitConfirmation, } from './helpers'; import { createPersisterMiddleware } from './persister'; import { raidenReducer } from './reducer'; import { pathFind, udcDeposit, udcWithdraw, udcWithdrawPlan } from './services/actions'; import { InputPaths, PFS, PfsMode, SuggestedPartners } from './services/types'; import { pfsListInfo } from './services/utils'; import { transfer, transferSigned, withdraw, withdrawResolve } from './transfers/actions'; import { Direction, TransferState } from './transfers/state'; import { getSecrethash, makePaymentId, makeSecret, raidenTransfer, transferKey, transferKeyToMeta, } from './transfers/utils'; import { matrixPresence } from './transport/actions'; import { EventTypes } from './types'; import { assert } from './utils'; import { asyncActionToPromise, isActionOf } from './utils/actions'; import { jsonParse } from './utils/data'; import { commonTxErrors, ErrorCodes, RaidenError } from './utils/error'; import { getLogsByChunk$ } from './utils/ethers'; import { pluckDistinct, retryWhile } from './utils/rx'; import { Address, decode, Hash, Secret, UInt } from './utils/types'; import versions from './versions.json'; export class Raiden { /** * Constructs a Raiden instance from state machine parameters * * It expects ready Redux and Epics params, with some async members already resolved and set in * place, therefore this constructor is expected to be used only for tests and advancecd usage * where finer control is needed to tweak how some of these members are initialized; * Most users should usually prefer the [[create]] async factory, which already takes care of * these async initialization steps and accepts more common parameters. * * @param state - Validated and decoded initial/rehydrated RaidenState * @param deps - Constructed epics dependencies object, including signer, provider, fetched * network and contracts information. * @param epic - State machine root epic * @param reducer - State machine root reducer */ constructor(state, deps, epic = combineRaidenEpics(), reducer = raidenReducer) { this.deps = deps; this.epic = epic; /** * action$ exposes the internal events pipeline. It's intended for debugging, and its interface * must not be relied on, as its actions interfaces and structures can change without warning. */ this.action$ = this.deps.latest$.pipe(pluckDistinct('action'), skip(1)); /** * state$ is exposed only so user can listen to state changes and persist them somewhere else. * Format/content of the emitted objects are subject to changes and not part of the public API */ this.state$ = this.deps.latest$.pipe(pluckDistinct('state')); /** * channels$ is public interface, exposing a view of the currently known channels * Its format is expected to be kept backwards-compatible, and may be relied on */ this.channels$ = this.state$.pipe(pluckDistinct('channels'), map(mapRaidenChannels)); /** * A subset ot RaidenActions exposed as public events. * The interface of the objects emitted by this Observable are expected not to change internally, * but more/new events may be added over time. */ this.events$ = this.action$.pipe(filter(isActionOf(RaidenEvents))); /** * Observable of completed and pending transfers * Every time a transfer state is updated, it's emitted here. 'key' property is unique and * may be used as identifier to know which transfer got updated. */ this.transfers$ = initTransfers$(this.deps.db); /** RaidenConfig observable (for reactive use) */ this.config$ = this.deps.config$; /** Observable of latest average (10) block times */ this.blockTime$ = this.deps.latest$.pipe(pluckDistinct('blockTime')); /** When started, is set to a promise which resolves when node finishes syncing */ this.synced = makeSyncedPromise(this.action$); /** * Get constant token details from token contract, caches it. * Rejects only if 'token' contract doesn't define totalSupply and decimals methods. * name and symbol may be undefined, as they aren't actually part of ERC20 standard, although * very common and defined on most token contracts. * * @param token - address to fetch info from * @returns token info */ this.getTokenInfo = makeTokenInfoGetter(this.deps); /** Expose ether's Provider.resolveName for ENS support */ this.resolveName = this.deps.provider.resolveName.bind(this.deps.provider); /** The address of the token that is used to pay the services (SVT/RDN). */ this.userDepositTokenAddress = memoize(async () => this.deps.userDepositContract.callStatic.token()); // use next from latest known blockNumber as start block when polling deps.provider.resetEventsBlock(state.blockNumber + 1); const isBrowser = !!globalThis?.location; const loggerMiddleware = createLogger({ predicate: () => this.log.getLevel() <= logging.levels.INFO, logger: this.log, level: { prevState: false, action: 'info', error: 'error', nextState: 'debug', }, ...(isBrowser ? {} : { colors: false }), }); // minimum blockNumber of contracts deployment as start scan block this.epicMiddleware = createEpicMiddleware({ dependencies: deps }); const persisterMiddleware = createPersisterMiddleware(deps.db); this.store = createStore(reducer, // workaround for redux's PreloadedState issues with branded values state, // eslint-disable-line @typescript-eslint/no-explicit-any composeWithDevTools(applyMiddleware(loggerMiddleware, persisterMiddleware, this.epicMiddleware))); deps.config$.subscribe((config) => (this.config = config)); // populate deps.latest$, to ensure config, logger && pollingInterval are setup before start getLatest$(of(raidenConfigUpdate({})), of(this.store.getState()), deps).subscribe((latest) => deps.latest$.next(latest)); } /** * Async helper factory to make a Raiden instance from more common parameters. * * An async factory is needed so we can do the needed async requests to construct the required * parameters ahead of construction time, and avoid partial initialization then * * @param this - Raiden class or subclass * @param connection - A URL or provider to connect to, one of: * <ul> * <li>JsonRpcProvider instance,</li> * <li>a Metamask's web3.currentProvider object or,</li> * <li>a hostname or remote json-rpc connection string</li> * </ul> * @param account - An account to use as main account, one of: * <ul> * <li>Signer instance (e.g. Wallet) loaded with account/private key or</li> * <li>hex-encoded string address of a remote account in provider or</li> * <li>hex-encoded string local private key or</li> * <li>number index of a remote account loaded in provider * (e.g. 0 for Metamask's loaded account)</li> * </ul> * @param storage - diverse storage related parameters to load from and save to * @param storage.state - State uploaded by user; should be decodable by RaidenState; * it is auto-migrated * @param storage.adapter - PouchDB adapter; default to 'indexeddb' on browsers and 'leveldb' on * node. If you provide a custom one, ensure you call PouchDB.plugin on it. * @param storage.prefix - Database name prefix; use to set a directory to store leveldown db; * @param contractsOrUDCAddress - Contracts deployment info, or UserDeposit contract address * @param config - Raiden configuration * @param subkey - Whether to use a derived subkey or not * @param subkeyOriginUrl - URL of origin to generate a subkey for (defaults * to global context) * @returns Promise to Raiden SDK client instance */ static async create(connection, account, // eslint-disable-next-line @typescript-eslint/no-explicit-any storage, contractsOrUDCAddress, config, subkey, subkeyOriginUrl) { let provider; if (typeof connection === 'string') { provider = new JsonRpcProvider(connection); } else if (connection instanceof JsonRpcProvider) { provider = connection; } else { provider = new Web3Provider(connection); } const network = await provider.getNetwork(); const { signer, address, main } = await getSigner(account, provider, subkey, subkeyOriginUrl); // Build initial state or parse from database const { state, db } = await getState({ provider, network, address, log: logging.getLogger(`raiden:${address}`) }, contractsOrUDCAddress, storage); const contractsInfo = state.contracts; assert(address === state.address, [ ErrorCodes.RDN_STATE_ADDRESS_MISMATCH, { account: address, state: state.address, }, ]); assert(network.chainId === state.chainId, [ ErrorCodes.RDN_STATE_NETWORK_MISMATCH, { network: network.chainId, contracts: contractsInfo, stateNetwork: state.chainId, }, ]); const cleanConfig = config && decode(PartialRaidenConfig, omitBy(config, isUndefined)); const deps = makeDependencies(state, cleanConfig, { signer, contractsInfo, db, main }); return new this(state, deps); } /** * Starts redux/observables by subscribing to all epics and emitting initial state and action * * No event should be emitted before start is called */ async start() { assert(this.epicMiddleware, ErrorCodes.RDN_ALREADY_STARTED, this.log.info); this.log.info('Starting Raiden Light-Client', { prevBlockNumber: this.state.blockNumber, address: this.address, contracts: this.deps.contractsInfo, network: this.deps.network, 'raiden-ts': Raiden.version, 'raiden-contracts': Raiden.contractVersion, config: this.config, versions: process?.versions, }); // Set `epicMiddleware` to `null`, this indicates the instance is not running. this.deps.latest$.subscribe({ complete: () => (this.epicMiddleware = null), }); this.epicMiddleware.run(this.epic); // prevent start from being called again, turns this.started to true this.epicMiddleware = undefined; // dispatch a first, noop action, to next first state$ as current/initial state this.store.dispatch(raidenStarted()); await this.synced; } /** * Gets the running state of the instance * * @returns undefined if not yet started, true if running, false if already stopped */ get started() { // !epicMiddleware -> undefined | null -> undefined ? true/started : null/stopped; if (!this.epicMiddleware) return this.epicMiddleware === undefined; // else -> !!epicMiddleware -> not yet started -> returns undefined } /** * Triggers all epics to be unsubscribed */ async stop() { // start still can't be called again, but turns this.started to false // this.epicMiddleware is set to null by latest$'s complete callback if (this.started) this.store.dispatch(raidenShutdown({ reason: ShutdownReason.STOP })); if (this.started !== undefined) await lastValueFrom(this.deps.db.busy$, { defaultValue: undefined }); } /** * Instance's Logger, compatible with console's API * * @returns Logger object */ get log() { return this.deps.log; } /** * Get current RaidenState object. Can be serialized safely with [[encodeRaidenState]] * * @returns Current Raiden state */ get state() { return this.store.getState(); } /** * Current provider getter * * @returns ether's provider instance */ get provider() { return this.deps.provider; } /** * Get current account address (subkey's address, if subkey is being used) * * @returns Instance address */ get address() { return this.deps.address; } /** * Get main account address (if subkey is being used, undefined otherwise) * * @returns Main account address */ get mainAddress() { return this.deps.main?.address; } /** * Get current network from provider * * @returns Network object containing blockchain's name & chainId */ get network() { return this.deps.network; } /** * Returns a promise to current block number, as seen in provider and state * * @returns Promise to current block number */ async getBlockNumber() { const lastBlockNumber = this.deps.provider.blockNumber; if (lastBlockNumber && lastBlockNumber >= this.deps.contractsInfo.TokenNetworkRegistry.block_number) return lastBlockNumber; else return await this.deps.provider.getBlockNumber(); } /** * Returns the currently used SDK version. * * @returns SDK version */ static get version() { return versions.sdk; } /** * Returns the version of the used Smart Contracts. * * @returns Smart Contract version */ static get contractVersion() { return versions.contracts; } /** * Returns the Smart Contracts addresses and deployment blocks * * @returns Smart Contracts info */ get contractsInfo() { return this.deps.contractsInfo; } /** * Update Raiden Config with a partial (shallow) object * * @param config - Partial object containing keys and values to update in config */ updateConfig(config) { if ('mediationFees' in config) // just validate, it's set in getLatest$ this.deps.mediationFeeCalculator.decodeConfig(config.mediationFees, this.deps.defaultConfig.mediationFees); this.store.dispatch(raidenConfigUpdate(decode(PartialRaidenConfig, config))); } /** * Dumps database content for backup * * @yields Rows of objects */ async *dumpDatabase() { yield* dumpDatabase(this.deps.db); } /** * Get ETH balance for given address or self * * @param address - Optional target address. If omitted, gets own balance * @returns BigNumber of ETH balance */ getBalance(address) { address = address ?? chooseOnchainAccount(this.deps, this.config.subkey).address; assert(Address.is(address), [ErrorCodes.DTA_INVALID_ADDRESS, { address }], this.log.info); return this.deps.provider.getBalance(address); } /** * Get token balance and token decimals for given address or self * * @param token - Token address to fetch balance. Must be one of the monitored tokens. * @param address - Optional target address. If omitted, gets own balance * @returns BigNumber containing address's token balance */ async getTokenBalance(token, address) { address = address ?? chooseOnchainAccount(this.deps, this.config.subkey).address; assert(Address.is(address), [ErrorCodes.DTA_INVALID_ADDRESS, { address }], this.log.info); assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); const tokenContract = this.deps.getTokenContract(token); return tokenContract.callStatic.balanceOf(address); } /** * Returns a list of all token addresses registered as token networks in registry * * @param rescan - Whether to rescan events from scratch * @returns Promise to list of token addresses */ async getTokenList(rescan = false) { if (!rescan) return Object.keys(this.state.tokens); return await lastValueFrom(getLogsByChunk$(this.deps.provider, { ...this.deps.registryContract.filters.TokenNetworkCreated(), fromBlock: this.deps.contractsInfo.TokenNetworkRegistry.block_number, toBlock: await this.getBlockNumber(), }).pipe(map((log) => this.deps.registryContract.interface.parseLog(log)), filter((parsed) => !!parsed.args['token_address']), map((parsed) => parsed.args['token_address']), toArray())); } /** * Scans initially and start monitoring a token for channels with us, returning its Tokennetwork * address * * Throws an exception if token isn't registered in current registry * * @param token - token address to monitor, must be registered in current token network registry * @returns Address of TokenNetwork contract */ async monitorToken(token) { assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); let tokenNetwork = this.state.tokens[token]; if (tokenNetwork) return tokenNetwork; tokenNetwork = (await this.deps.registryContract.token_to_token_networks(token)); assert(tokenNetwork && tokenNetwork !== AddressZero, ErrorCodes.RDN_UNKNOWN_TOKEN_NETWORK, this.log.info); this.store.dispatch(tokenMonitored({ token, tokenNetwork, fromBlock: this.deps.contractsInfo.TokenNetworkRegistry.block_number, })); return tokenNetwork; } /** * Open a channel on the tokenNetwork for given token address with partner * * If token isn't yet monitored, starts monitoring it * * @param token - Token address on currently configured token network registry * @param partner - Partner address * @param options - (optional) option parameter * @param options.deposit - Deposit to perform in parallel with channel opening * @param options.confirmConfirmation - Whether to wait `confirmationBlocks` after last * transaction confirmation; default=true if confirmationBlocks * @param onChange - Optional callback for status change notification * @returns txHash of channelOpen call, iff it succeeded */ async openChannel(token, partner, options = {}, onChange) { assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); assert(Address.is(partner), [ErrorCodes.DTA_INVALID_ADDRESS, { partner }], this.log.info); const tokenNetwork = await this.monitorToken(token); const deposit = !options.deposit ? undefined : decode(UInt(32), options.deposit, ErrorCodes.DTA_INVALID_DEPOSIT, this.log.info); const confirmConfirmation = options.confirmConfirmation ?? !!this.config.confirmationBlocks; const meta = { tokenNetwork, partner }; // wait for confirmation const openPromise = asyncActionToPromise(channelOpen, meta, this.action$).then(({ txHash }) => { onChange?.({ type: EventTypes.OPENED, payload: { txHash } }); return txHash; // pluck txHash }); const openConfirmedPromise = asyncActionToPromise(channelOpen, meta, this.action$, true).then(({ txHash }) => { onChange?.({ type: EventTypes.CONFIRMED, payload: { txHash } }); return txHash; // pluck txHash }); let depositPromise; if (deposit?.gt(0)) { depositPromise = asyncActionToPromise(channelDeposit, meta, this.action$.pipe( // ensure we only react on own deposit's responses filter((action) => !channelDeposit.success.is(action) || action.payload.participant === this.address)), true).then(({ txHash }) => { onChange?.({ type: EventTypes.DEPOSITED, payload: { txHash } }); return txHash; // pluck txHash }); } this.store.dispatch(channelOpen.request({ ...options, deposit }, meta)); const [, openTxHash, depositTxHash] = await Promise.all([ openPromise, openConfirmedPromise, depositPromise, ]); if (confirmConfirmation) { // wait twice confirmationBlocks for deposit or open tx await waitConfirmation(await this.deps.provider.getTransactionReceipt(depositTxHash ?? openTxHash), this.deps, this.config.confirmationBlocks * 2); } return openTxHash; } /** * Deposit tokens on channel between us and partner on tokenNetwork for token * * @param token - Token address on currently configured token network registry * @param partner - Partner address * @param amount - Number of tokens to deposit on channel * @param options - tx options * @param options.confirmConfirmation - Whether to wait `confirmationBlocks` after last * transaction confirmation; default=true if config.confirmationBlocks * @returns txHash of setTotalDeposit call, iff it succeeded */ async depositChannel(token, partner, amount, { confirmConfirmation = !!this.config.confirmationBlocks, } = {}) { assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); assert(Address.is(partner), [ErrorCodes.DTA_INVALID_ADDRESS, { partner }], this.log.info); const state = this.state; const tokenNetwork = state.tokens[token]; assert(tokenNetwork, ErrorCodes.RDN_UNKNOWN_TOKEN_NETWORK, this.log.info); const deposit = decode(UInt(32), amount, ErrorCodes.DTA_INVALID_DEPOSIT, this.log.info); const meta = { tokenNetwork, partner }; const promise = asyncActionToPromise(channelDeposit, meta, this.action$.pipe( // ensure we only react on own deposit's responses filter((action) => !channelDeposit.success.is(action) || action.payload.participant === this.address)), true).then(({ txHash }) => txHash); this.store.dispatch(channelDeposit.request({ deposit }, meta)); const depositTxHash = await promise; if (confirmConfirmation) { // wait twice confirmationBlocks for deposit or open tx await waitConfirmation(await this.deps.provider.getTransactionReceipt(depositTxHash), this.deps, this.config.confirmationBlocks * 2); } return depositTxHash; } /** * Close channel between us and partner on tokenNetwork for token * This method will fail if called on a channel not in 'opened' or 'closing' state. * When calling this method on an 'opened' channel, its state becomes 'closing', and from there * on, no payments can be performed on the channel. If for any reason the closeChannel * transaction fails, channel's state stays as 'closing', and this method can be called again * to retry sending 'closeChannel' transaction. After it's successful, channel becomes 'closed', * and can be settled after 'settleTimeout' seconds (when it then becomes 'settleable'). * * @param token - Token address on currently configured token network registry * @param partner - Partner address * @returns txHash of closeChannel call, iff it succeeded */ async closeChannel(token, partner) { assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); assert(Address.is(partner), [ErrorCodes.DTA_INVALID_ADDRESS, { partner }], this.log.info); const state = this.state; const tokenNetwork = state.tokens[token]; assert(tokenNetwork, ErrorCodes.RDN_UNKNOWN_TOKEN_NETWORK, this.log.info); // try coop-settle first try { const channel = this.state.channels[channelKey({ tokenNetwork, partner })]; assert(channel, 'channel not found'); const { ownTotalWithdrawable: totalWithdraw } = channelAmounts(channel); const expiration = Math.ceil(Date.now() / 1e3 + this.config.revealTimeout * this.config.expiryFactor); const coopMeta = { direction: Direction.SENT, tokenNetwork, partner, totalWithdraw, expiration, }; const coopPromise = asyncActionToPromise(withdraw, coopMeta, this.action$, true).then(({ txHash }) => txHash); this.store.dispatch(withdrawResolve({ coopSettle: true }, coopMeta)); return await coopPromise; } catch (err) { this.log.info('Could not settle cooperatively, performing uncooperative close', err); } const meta = { tokenNetwork, partner }; const promise = asyncActionToPromise(channelClose, meta, this.action$, true).then(({ txHash }) => txHash); this.store.dispatch(channelClose.request(undefined, meta)); return promise; } /** * Settle channel between us and partner on tokenNetwork for token * This method will fail if called on a channel not in 'settleable' or 'settling' state. * Channel becomes 'settleable' settleTimeout seconds after closed (detected automatically * while Raiden Light Client is running or later on restart). When calling it, channel state * becomes 'settling'. If for any reason transaction fails, it'll stay on this state, and this * method can be called again to re-send a settleChannel transaction. * * @param token - Token address on currently configured token network registry * @param partner - Partner address * @returns txHash of settleChannel call, iff it succeeded */ async settleChannel(token, partner) { assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); assert(Address.is(partner), [ErrorCodes.DTA_INVALID_ADDRESS, { partner }], this.log.info); const state = this.state; const tokenNetwork = state.tokens[token]; assert(tokenNetwork, ErrorCodes.RDN_UNKNOWN_TOKEN_NETWORK, this.log.info); assert(!this.config.autoSettle, ErrorCodes.CNL_SETTLE_AUTO_ENABLED, this.log.info); const meta = { tokenNetwork, partner }; // wait for channel to become settleable await lastValueFrom(waitChannelSettleable$(this.state$, meta)); // wait for the corresponding success or error action const promise = asyncActionToPromise(channelSettle, meta, this.action$, true).then(({ txHash }) => txHash); this.store.dispatch(channelSettle.request(undefined, meta)); return promise; } /** * Returns object describing address's users availability on transport * After calling this method, any further presence update to valid transport peers of this * address will trigger a corresponding MatrixPresenceUpdateAction on events$ * * @param address - checksummed address to be monitored * @returns Promise to object describing availability and last event timestamp */ async getAvailability(address) { assert(Address.is(address), [ErrorCodes.DTA_INVALID_ADDRESS, { address }], this.log.info); const meta = { address }; const promise = asyncActionToPromise(matrixPresence, meta, this.action$); this.store.dispatch(matrixPresence.request(undefined, meta)); return promise; } /** * Get list of past and pending transfers * * @param filter - Filter options * @param filter.pending - true: only pending; false: only completed; undefined: all * @param filter.token - filter by token address * @param filter.partner - filter by partner address * @param filter.end - filter by initiator or target address * @param options - PouchDB.ChangesOptions object * @param options.offset - Offset to skip entries * @param options.limit - Limit number of entries * @param options.desc - Set to true to get newer transfers first * @returns promise to array of all transfers */ async getTransfers(filter, options) { return getTransfers(this.deps.db, filter, options); } /** * Send a Locked Transfer! * This will reject if LockedTransfer signature prompt is canceled/signature fails, or be * resolved to the transfer unique identifier (secrethash) otherwise, and transfer status can be * queried with this id on this.transfers$ observable, which will just have emitted the 'pending' * transfer. Any following transfer state change will be notified through this observable. * * @param token - Token address on currently configured token network registry * @param target - Target address * @param value - Amount to try to transfer * @param options - Optional parameters for transfer: * @param options.paymentId - payment identifier, a random one will be generated if missing * @param options.secret - Secret to register, a random one will be generated if missing * @param options.secrethash - Must match secret, if both provided, or else, secret must be * informed to target by other means, and reveal can't be performed * @param options.paths - Used to specify possible routes & fees instead of querying PFS. * Should receive a decodable super-set of the public RaidenPaths interface * @param options.pfs - Use this PFS instead of configured or automatically choosen ones. * Is ignored if paths were already provided. If neither are set and config.pfs is not * disabled (null), use it if set or if undefined (auto mode), fetches the best * PFS from ServiceRegistry and automatically fetch routes from it. * @param options.lockTimeout - Specify a lock timeout for transfer; * default is expiryFactor * revealTimeout * @param options.encryptSecret - Whether to force encrypting the secret or not, * if target supports it * @returns A promise to transfer's unique key (id) when it's accepted */ async transfer(token, target, value, options = {}) { assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); assert(Address.is(target), [ErrorCodes.DTA_INVALID_ADDRESS, { target }], this.log.info); const tokenNetwork = this.state.tokens[token]; assert(tokenNetwork, ErrorCodes.RDN_UNKNOWN_TOKEN_NETWORK, this.log.info); const decodedValue = decode(UInt(32), value, ErrorCodes.DTA_INVALID_AMOUNT, this.log.info); const paymentId = options.paymentId !== undefined ? decode(UInt(8), options.paymentId, ErrorCodes.DTA_INVALID_PAYMENT_ID, this.log.info) : makePaymentId(); const paths = options.paths && decode(InputPaths, options.paths, ErrorCodes.DTA_INVALID_PATH, this.log.info); const pfs = options.pfs && decode(PFS, options.pfs, ErrorCodes.DTA_INVALID_PFS, this.log.info); assert(options.secret === undefined || Secret.is(options.secret), ErrorCodes.RDN_INVALID_SECRET, this.log.info); assert(options.secrethash === undefined || Hash.is(options.secrethash), ErrorCodes.RDN_INVALID_SECRETHASH, this.log.info); // use provided secret or create one if no secrethash was provided const secret = options.secret || (options.secrethash ? undefined : makeSecret()); const secrethash = options.secrethash || getSecrethash(secret); assert(!secret || getSecrethash(secret) === secrethash, ErrorCodes.RDN_SECRET_SECRETHASH_MISMATCH, this.log.info); const promise = firstValueFrom(this.action$.pipe(filter(isActionOf([transferSigned, transfer.failure])), filter(({ meta }) => meta.direction === Direction.SENT && meta.secrethash === secrethash), map((action) => { if (transfer.failure.is(action)) throw action.payload; return transferKey(action.meta); }))); this.store.dispatch(transfer.request({ tokenNetwork, target, value: decodedValue, paymentId, secret, resolved: false, paths, pfs, lockTimeout: options.lockTimeout, encryptSecret: options.encryptSecret, }, { secrethash, direction: Direction.SENT })); return promise; } /** * Waits for the transfer identified by a secrethash to fail or complete * The returned promise will resolve with the final transfer state, or reject if anything fails * * @param transferKey - Transfer identifier as returned by [[transfer]] * @returns Promise to final RaidenTransfer */ async waitTransfer(transferKey) { const { direction, secrethash } = transferKeyToMeta(transferKey); let transferState = this.state.transfers[transferKey]; if (!transferState) try { transferState = decode(TransferState, await this.deps.db.get(transferKey)); } catch (e) { } assert(transferState, ErrorCodes.RDN_UNKNOWN_TRANSFER, this.log.info); const raidenTransf = raidenTransfer(transferState); // already completed/past transfer if (raidenTransf.completed) { if (raidenTransf.success) return raidenTransf; else throw new RaidenError(ErrorCodes.XFER_ALREADY_COMPLETED, { status: raidenTransf.status }); } // throws/rejects if a failure occurs await asyncActionToPromise(transfer, { secrethash, direction }, this.action$); const finalState = await firstValueFrom(this.state$.pipe(pluck('transfers', transferKey), filter((transferState) => !!transferState.unlockProcessed))); this.log.info('Transfer successful', { key: transferKey, partner: finalState.partner, initiator: finalState.transfer.initiator, target: finalState.transfer.target, fee: finalState.fee.toString(), lockAmount: finalState.transfer.lock.amount.toString(), targetReceived: finalState.secretRequest?.amount.toString(), transferTime: finalState.unlockProcessed.ts - finalState.transfer.ts, }); return raidenTransfer(finalState); } /** * Request a path from PFS * * If a direct route is possible, it'll be returned. Else if PFS is set up, a request will be * performed and the cleaned/validated path results will be resolved. * Else, if no route can be found, promise is rejected with respective error. * * @param token - Token address on currently configured token network registry * @param target - Target address * @param value - Minimum capacity required on routes * @param options - Optional parameters * @param options.pfs - Use this PFS instead of configured or automatically choosen ones * @returns A promise to returned routes/paths result */ async findRoutes(token, target, value, options = {}) { assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); assert(Address.is(target), [ErrorCodes.DTA_INVALID_ADDRESS, { target }], this.log.info); const tokenNetwork = this.state.tokens[token]; assert(tokenNetwork, ErrorCodes.RDN_UNKNOWN_TOKEN_NETWORK, this.log.info); const decodedValue = decode(UInt(32), value); const pfs = options.pfs ? decode(PFS, options.pfs) : undefined; const meta = { tokenNetwork, target, value: decodedValue }; const promise = asyncActionToPromise(pathFind, meta, this.action$).then(({ paths }) => paths); this.store.dispatch(pathFind.request({ pfs }, meta)); return promise; } /** * Checks if a direct transfer of token to target could be performed and returns it on a * single-element array of Paths * * @param token - Token address on currently configured token network registry * @param target - Target address * @param value - Minimum capacity required on route * @returns Promise to a [Raiden]Paths array containing the single, direct route, or undefined */ async directRoute(token, target, value) { assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); assert(Address.is(target), [ErrorCodes.DTA_INVALID_ADDRESS, { target }], this.log.info); const tokenNetwork = this.state.tokens[token]; assert(tokenNetwork, ErrorCodes.RDN_UNKNOWN_TOKEN_NETWORK, this.log.info); const decodedValue = decode(UInt(32), value); const meta = { tokenNetwork, target, value: decodedValue }; const promise = asyncActionToPromise(pathFind, meta, this.action$).then(({ paths }) => paths, // pluck paths () => undefined); // dispatch a pathFind with pfs disabled, to force checking for a direct route this.store.dispatch(pathFind.request({ pfs: null }, meta)); return promise; } /** * Returns a sorted array of info of available PFS * * It uses data polled from ServiceRegistry, which is available only when config.pfs is * undefined, instead of set or disabled (null), and will reject if not. * It can reject if the validated list is empty, meaning we can be out-of-sync (we're outdated or * they are) with PFSs deployment, or no PFS is available on this TokenNetwork/blockchain. * * @returns Promise to array of PFS, which is the interface which describes a PFS */ async findPFS() { assert(this.config.pfsMode !== PfsMode.disabled, ErrorCodes.PFS_DISABLED, this.log.info); await this.synced; const services = [...this.config.additionalServices]; if (this.config.pfsMode === PfsMode.auto) services.push(...Object.keys(this.state.services)); return lastValueFrom(pfsListInfo(services, this.deps)); } /** * Mints the amount of tokens of the provided token address. * Throws an error, if * <ol> * <li>Executed on main net</li> * <li>`token` or `options.to` is not a valid address</li> * <li>Token could not be minted</li> * </ol> * * @param token - Address of the token to be minted * @param amount - Amount to be minted * @param options - tx options * @param options.to - Beneficiary, defaults to mainAddress or address * @returns transaction */ async mint(token, amount, { to } = {}) { // Check whether address is valid assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); // Check whether we are on a test network assert(this.deps.network.chainId !== 1, ErrorCodes.RDN_MINT_MAINNET, this.log.info); const { signer, address } = chooseOnchainAccount(this.deps, this.config.subkey); // Mint token const customTokenContract = CustomToken__factory.connect(token, signer); const beneficiary = to ?? address; assert(Address.is(beneficiary), [ErrorCodes.DTA_INVALID_ADDRESS, { beneficiary }], this.log.info); const value = decode(UInt(32), amount, ErrorCodes.DTA_INVALID_AMOUNT); const [, receipt] = await lastValueFrom(transact(customTokenContract, 'mintFor', [value, beneficiary], this.deps, { error: ErrorCodes.RDN_MINT_FAILED, })); // wait for a single block, so future calls will correctly pick value await waitConfirmation(receipt, this.deps, 1); return receipt.transactionHash; } /** * Registers and creates a new token network for the provided token address. * Throws an error, if * <ol> * <li>Executed on main net</li> * <li>`token` is not a valid address</li> * <li>Token is already registered</li> * <li>Token could not be registered</li> * </ol> * * @param token - Address of the token to be registered * @param channelParticipantDepositLimit - The deposit limit per channel participant * @param tokenNetworkDepositLimit - The deposit limit of the whole token network * @returns Address of new token network */ async registerToken(token, channelParticipantDepositLimit = MaxUint256, tokenNetworkDepositLimit = MaxUint256) { // Check whether address is valid assert(Address.is(token), [ErrorCodes.DTA_INVALID_ADDRESS, { token }], this.log.info); // Check whether we are on a test network assert(this.deps.network.chainId !== 1, ErrorCodes.RDN_REGISTER_TOKEN_MAINNET, this.log.info); const { signer } = chooseOnchainAccount(this.deps, this.config.subkey); const tokenNetworkRegistry = getContractWithSigner(this.deps.registryContract, signer); // Check whether token is already registered await this.monitorToken(token).then((tokenNetwork) => { throw new RaidenError(ErrorCodes.RDN_REGISTER_TOKEN_REGISTERED, { tokenNetwork }); }, () => undefined); const [, receipt] = await lastValueFrom(transact(tokenNetworkRegistry, 'createERC20TokenNetwork', [token, channelParticipantDepositLimit, tokenNetworkDepositLimit], this.deps, { error: ErrorCodes.RDN_REGISTER_TOKEN_FAILED })); await waitConfirmation(receipt, this.deps); return await this.monitorToken(token); } /** * Fetches balance of UserDeposit Contract for SDK's account minus cached spent IOUs * * @returns Promise to UDC remaining capacity */ async getUDCCapacity() { const balance = await getUdcBalance(this.deps.latest$); const now = Math.round(Date.now() / 1e3); // in seconds const owedAmount = Object.values(this.state.iou) .reduce((acc, value) => [ ...acc, ...Object.values(value).filter((value) => value.claimable_until.gte(now)), ], []) .reduce((acc, iou) => acc.add(iou.amount), Zero); return balance.sub(owedAmount); } /** * Fetches total_deposit of UserDeposit Contract for SDK's account * * The usable part of the deposit should be fetched with [[getUDCCapacity]], but this function * is useful when trying to deposit based on the absolute value of totalDeposit. * * @returns Promise to UDC total deposit */ async getUDCTotalDeposit() { return firstValueFrom(this.deps.latest$.pipe(pluck('udcDeposit', 'totalDeposit'), filter((deposit) => !!deposit && deposit.lt(MaxUint256)))); } /** * Deposits the amount to the UserDeposit contract with the target/signer as a beneficiary. * The deposited amount can be used as a collateral in order to sign valid IOUs that will * be accepted by the Services. * * Throws an error, in the following cases: * <ol> * <li>The amount specified equals to zero</li> * <li>The target has an insufficient token balance</li> * <li>The "approve" transaction fails with an error</li> * <li>The "deposit" transaction fails with an error</li> * </ol> * * @param amount - The amount to deposit on behalf of the target/beneficiary. * @param onChange - callback providing notifications about state changes * @returns transaction hash */ async depositToUDC(amount, onChange) { const deposit = decode(UInt(32), amount, ErrorCodes.DTA_INVALID_DEPOSIT, this.log.info); assert(deposit.gt(Zero), ErrorCodes.DTA_NON_POSITIVE_NUMBER, this.log.info); const deposited = await this.deps.userDepositContract.callStatic.total_deposit(this.address); const meta = { totalDeposit: deposited.add(deposit) }; const mined = asyncActionToPromise(udcDeposit, meta, this.action$, false).then(({ txHash }) => onChange?.({ type: EventTypes.DEPOSITED, payload: { txHash } })); this.store.dispatch(udcDeposit.request({ deposit }, meta)); const confirmed = asyncActionToPromise(udcDeposit, meta, this.action$, true).then(({ txHash }) => { onChange?.({ type: EventTypes.CONFIRMED, payload: { txHash } }); return txHash; }); const [, txHash] = await Promise.all([mined, confirmed]); return txHash; } /** * Transfer value ETH on-chain to address. * If subkey is being used, use main account by default, or subkey account if 'subkey' is true * Example: * // transfer 0.1 ETH from main account to subkey account, when subkey is used * await raiden.transferOnchainBalance(raiden.address, parseEther('0.1')); * // transfer entire balance from subkey account back to main account * await raiden.transferOnchainBalance(raiden.mainAddress, undefined, { subkey: true }); * * @param to - Recipient address * @param value - Amount of ETH (in Wei) to transfer. Use ethers/utils::parseEther if needed * Defaults to a very big number, which will cause all entire balance to be transfered * @param options - tx options * @param options.subkey - By default, if using subkey, main account is used to send transactions * Set this to true if one wants to force sending the transaction with the subkey * @param options.gasPrice - Set to force a specific gasPrice; used to calculate transferable * amount when transfering entire balance. If left unset, uses average network gasPrice * @returns transaction hash */ async transferOnchainBalance(to, value = MaxUint256, { subkey, gasPrice: price } = {}) { assert(Address.is(to), [ErrorCodes.DTA_INVALID_ADDRESS, { to }], this.log.info); const { signer, address } = chooseOnchainAccount(this.deps, subkey ?? this.config.subkey); // we use provider.getGasPrice directly in order to use the old gasPrice for txs, which // allows us to predict exactly the final gasPrice and deplet balance const gasPrice = price ? BigNumber.from(price) : await this.deps.provider.getGasPrice(); let curBalance, gasLimit; for (let try_ = 0; !curBalance || !gasLimit; try_++) { // curBalance may take some tries to be updated right after a tx try { curBalance = await this.getBalance(address); gasLimit = await this.deps.provider.estimateGas({ from: address, to, value: curBalance, }); } catch (e) { if (try_ >= 5) throw e; } } // transferableBalance is current balance minus the cost of a single transfer as per gasPrice const transferableBalance = curBalance.sub(gasPrice.mul(gasLimit)); assert(transferableBalance.gt(Zero), [ErrorCodes.RDN_INSUFFICIENT_BALANCE, { transferableBalance }], this.log.warn); // caps value to transferableBalance, so if it's too big, transfer all const amount = transferableBalance.lte(value) ? transferableBalance : BigNumber.from(value); const tx = await signer.sendTransaction({ to, value: amount, gasPrice, gasLimit }); const receipt = await tx.wait(); assert(receipt.status, ErrorCodes.RDN_TRANSFER_ONCHAIN_BALANCE_FAILED, this.log.info); return tx.hash; } /** * Transfer value tokens on-chain to address. * If subkey is being used, use main account by default, or subkey account if 'subkey' is true * * @param token - Token address * @param to - Recipient address * @param value - Amount of tokens (in Wei) to transfer. Use ethers/utils::parseUnits if needed * Defaults to a very big number, which will cause all entire balance to be transfered * @param options - tx options * @param options.subkey - By default, if using subkey, main account is used to send transactions * Set this to true if one wants to force sending the transaction with the subkey * @returns transaction hash */ asyn