diff --git a/raiden-cli/src/app.ts b/raiden-cli/src/app.ts index ce4632c926..482fc48c26 100644 --- a/raiden-cli/src/app.ts +++ b/raiden-cli/src/app.ts @@ -1,4 +1,4 @@ -import express, { Express, Request, Response, NextFunction, Errback } from 'express'; +import express, { Express, Request, Response, NextFunction } from 'express'; import createError from 'http-errors'; import logger from 'morgan'; import { Cli } from './types'; @@ -10,13 +10,12 @@ function notFoundHandler(_request: Request, _response: Response, next: NextFunct function internalErrorHandler( this: Cli, - error: Errback, + error: Error, _request: Request, _response: Response, next: NextFunction, ) { - this.log.error(error); - next(createError(500, 'Internal Raiden node error')); + next(createError(500, error.message)); } export function makeApp(this: Cli): Express { diff --git a/raiden-cli/src/index.ts b/raiden-cli/src/index.ts index c1dc4e8ba6..6b9d4cf8ca 100644 --- a/raiden-cli/src/index.ts +++ b/raiden-cli/src/index.ts @@ -71,12 +71,14 @@ function createLocalStorage(name: string): LocalStorage { return localStorage; } -function shutdown(this: Cli): void { +function shutdownServer(this: Cli): void { if (this.server?.listening) { this.log.info('Closing server...'); this.server.close(); } +} +function shutdownRaiden(this: Cli): void { if (this.raiden.started) { this.log.info('Stopping raiden...'); this.raiden.stop(); @@ -86,8 +88,10 @@ function shutdown(this: Cli): void { } function registerShutdownHooks(this: Cli): void { - process.on('SIGINT', shutdown.bind(this)); - process.on('SIGTERM', shutdown.bind(this)); + // raiden shutdown triggers server shutdown + this.raiden.state$.subscribe(undefined, shutdownServer.bind(this), shutdownServer.bind(this)); + process.on('SIGINT', shutdownRaiden.bind(this)); + process.on('SIGTERM', shutdownRaiden.bind(this)); } async function main() { diff --git a/raiden-cli/src/routes/api.v1.ts b/raiden-cli/src/routes/api.v1.ts index b73b7acca4..ab82a74052 100644 --- a/raiden-cli/src/routes/api.v1.ts +++ b/raiden-cli/src/routes/api.v1.ts @@ -46,5 +46,10 @@ export function makeApiV1Router(this: Cli): Router { }); }); + router.post('/shutdown', (_request: Request, response: Response) => { + this.raiden.stop(); + response.json({ status: 'shutdown' }); + }); + return router; } diff --git a/raiden-cli/src/routes/channels.ts b/raiden-cli/src/routes/channels.ts index 6c116df314..e6799601a6 100644 --- a/raiden-cli/src/routes/channels.ts +++ b/raiden-cli/src/routes/channels.ts @@ -1,69 +1,169 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { first } from 'rxjs/operators'; -import { ErrorCodes, RaidenError } from 'raiden-ts'; -import { Cli } from '../types'; +import { first, pluck } from 'rxjs/operators'; +import { ErrorCodes, isntNil, ChannelState, RaidenChannel } from 'raiden-ts'; +import { Cli, ApiChannelState } from '../types'; import { - validateAddressParameter, isInvalidParameterError, isTransactionWouldFailError, + validateOptionalAddressParameter, + validateAddressParameter, } from '../utils/validation'; -import { - flattenChannelDictionary, - transformSdkChannelFormatToApi, - filterChannels, -} from '../utils/channels'; +import { flattenChannelDictionary, transformChannelFormatForApi } from '../utils/channels'; -function isConflictError(error: RaidenError): boolean { +function isConflictError(error: Error): boolean { return ( [ErrorCodes.RDN_UNKNOWN_TOKEN_NETWORK, ErrorCodes.CNL_INVALID_STATE].includes(error.message) || isTransactionWouldFailError(error) ); } -async function getAllChannels(this: Cli, _request: Request, response: Response) { - const channelDictionary = await this.raiden.channels$.pipe(first()).toPromise(); - const channelList = flattenChannelDictionary(channelDictionary); - const formattedChannels = channelList.map((channel) => transformSdkChannelFormatToApi(channel)); - response.json(formattedChannels); -} - -async function getChannelsForToken(this: Cli, request: Request, response: Response) { - const channelDictionary = await this.raiden.channels$.pipe(first()).toPromise(); - const channelList = flattenChannelDictionary(channelDictionary); - const filteredChannels = filterChannels(channelList, request.params.tokenAddress); - const formattedChannels = filteredChannels.map((channel) => - transformSdkChannelFormatToApi(channel), +function isInsuficientFundsError(error: { message: string; code?: string | number }): boolean { + return ( + error.code === 'INSUFFICIENT_FUNDS' || + [ + ErrorCodes.RDN_INSUFFICIENT_BALANCE, + ErrorCodes.CNL_WITHDRAW_AMOUNT_TOO_LOW, + ErrorCodes.CNL_WITHDRAW_AMOUNT_TOO_HIGH, + ].includes(error.message) ); - response.json(formattedChannels); } -async function getChannelsForTokenAndPartner(this: Cli, request: Request, response: Response) { - const channelDictionary = await this.raiden.channels$.pipe(first()).toPromise(); - const channel = channelDictionary[request.params.tokenAddress]?.[request.params.partnerAddress]; - - if (channel) { - response.json(transformSdkChannelFormatToApi(channel)); +async function getChannels(this: Cli, request: Request, response: Response) { + const channelsDict = await this.raiden.channels$.pipe(first()).toPromise(); + const token: string | undefined = request.params.tokenAddress; + const partner: string | undefined = request.params.partnerAddress; + if (token && partner) { + const channel = channelsDict[token]?.[partner]; + if (channel) { + response.json(transformChannelFormatForApi(channel)); + } else { + response.status(404).send('The channel does not exist'); + } } else { - response.status(404).send('The channel does not exist'); + let channelsList = flattenChannelDictionary(channelsDict); + if (token) channelsList = channelsList.filter((channel) => channel.token === token); + if (partner) channelsList = channelsList.filter((channel) => channel.token === partner); + response.json(channelsList.map(transformChannelFormatForApi)); } } async function openChannel(this: Cli, request: Request, response: Response, next: NextFunction) { + const token: string = request.body.token_address; + const partner: string = request.body.partner_address; try { // TODO: We ignore the provided `reveal_timeout` until #1656 provides // a better solution. - await this.raiden.openChannel(request.body.token_address, request.body.partner_address, { + await this.raiden.openChannel(token, partner, { settleTimeout: request.body.settle_timeout, deposit: request.body.total_deposit, }); - const channelDictionary = await this.raiden.channels$.pipe(first()).toPromise(); - const newChannel = - channelDictionary[request.body.token_address]?.[request.body.partner_address]; - response.status(201).json(transformSdkChannelFormatToApi(newChannel)); + const channel = await this.raiden.channels$ + .pipe(pluck(token, partner), first(isntNil)) + .toPromise(); + response.status(201).json(transformChannelFormatForApi(channel)); + } catch (error) { + if (isInvalidParameterError(error)) { + response.status(400).send(error.message); + } else if (isInvalidParameterError(error)) { + response.status(402).send(error.message); + } else if (isConflictError(error)) { + response.status(409).send(error.message); + } else { + next(error); + } + } +} + +const allowedUpdateKeys = ['state', 'total_deposit', 'total_withdraw']; +const allowedUpdateStates = [ApiChannelState.closed, ApiChannelState.settled]; +function validateChannelUpdateBody(request: Request, response: Response, next: NextFunction) { + const intersec = Object.keys(request.body).filter((k) => allowedUpdateKeys.includes(k)); + if (intersec.length < 1) + return response.status(400).send(`one of [${allowedUpdateKeys}] operations required`); + else if (intersec.length > 1) + return response.status(409).send(`more than one of [${allowedUpdateKeys}] requested`); + if (request.body.state && !allowedUpdateStates.includes(request.body.state)) + return response + .status(400) + .send(`invalid "state" requested: must be one of [${allowedUpdateStates}]`); + return next(); +} + +async function updateChannelState( + this: Cli, + channel: RaidenChannel, + newState: ApiChannelState.closed | ApiChannelState.settled, +): Promise { + if (newState === ApiChannelState.closed) { + await this.raiden.closeChannel(channel.token, channel.partner); + const closedStates = [ChannelState.closed, ChannelState.settleable, ChannelState.settling]; + channel = await this.raiden.channels$ + .pipe( + pluck(channel.token, channel.partner), + first((channel) => closedStates.includes(channel.state)), + ) + .toPromise(); + } else if (newState === ApiChannelState.settled) { + const promise = this.raiden.settleChannel(channel.token, channel.partner); + const newChannel = await this.raiden.channels$ + .pipe(pluck(channel.token, channel.partner), first()) + .toPromise(); + await promise; + if (newChannel) channel = newChannel; // channel may have been cleared + } + return channel; +} + +async function updateChannelDeposit( + this: Cli, + channel: RaidenChannel, + totalDeposit: string, +): Promise { + // amount = new deposit - previous deposit == (previous deposit - new deposit) * -1 + const depositAmount = channel.ownDeposit.sub(totalDeposit).mul(-1); + await this.raiden.depositChannel(channel.token, channel.partner, depositAmount); + return await this.raiden.channels$ + .pipe( + pluck(channel.token, channel.partner), + first((channel) => channel.ownDeposit.gte(totalDeposit)), + ) + .toPromise(); +} + +async function updateChannelWithdraw( + this: Cli, + channel: RaidenChannel, + totalWithdraw: string, +): Promise { + // amount = new withdraw - previous withdraw == (previous withdraw - new withdraw) * -1 + const withdrawAmount = channel.ownWithdraw.sub(totalWithdraw).mul(-1); + await this.raiden.withdrawChannel(channel.token, channel.partner, withdrawAmount); + return await this.raiden.channels$ + .pipe( + pluck(channel.token, channel.partner), + first((channel) => channel.ownWithdraw.gte(totalWithdraw)), + ) + .toPromise(); +} + +async function updateChannel(this: Cli, request: Request, response: Response, next: NextFunction) { + const token: string = request.params.tokenAddress; + const partner: string = request.params.partnerAddress; + try { + let channel = await this.raiden.channels$.pipe(first(), pluck(token, partner)).toPromise(); + if (!channel) return response.status(404).send('channel not found'); + if (request.body.state) { + channel = await updateChannelState.call(this, channel, request.body.state); + } else if (request.body.total_deposit) { + channel = await updateChannelDeposit.call(this, channel, request.body.total_deposit); + } else if (request.body.total_withdraw) { + channel = await updateChannelWithdraw.call(this, channel, request.body.total_withdraw); + } + response.status(200).json(transformChannelFormatForApi(channel)); } catch (error) { if (isInvalidParameterError(error)) { response.status(400).send(error.message); - } else if (error.code === 'INSUFFICIENT_FUNDS') { + } else if (isInsuficientFundsError(error)) { response.status(402).send(error.message); } else if (isConflictError(error)) { response.status(409).send(error.message); @@ -76,26 +176,22 @@ async function openChannel(this: Cli, request: Request, response: Response, next export function makeChannelsRouter(this: Cli): Router { const router = Router(); - router.get('/', getAllChannels.bind(this)); - router.get( - '/:tokenAddress', - validateAddressParameter.bind('tokenAddress'), - getChannelsForToken.bind(this), + '/:tokenAddress?/:partnerAddress?', + validateOptionalAddressParameter.bind('tokenAddress'), + validateOptionalAddressParameter.bind('partnerAddress'), + getChannels.bind(this), ); - router.get( + router.put('/', openChannel.bind(this)); + + router.patch( '/:tokenAddress/:partnerAddress', validateAddressParameter.bind('tokenAddress'), validateAddressParameter.bind('partnerAddress'), - getChannelsForTokenAndPartner.bind(this), + validateChannelUpdateBody, + updateChannel.bind(this), ); - router.put('/', openChannel.bind(this)); - - router.patch('/:tokenAddress/:partnerAddress', (_request: Request, response: Response) => { - response.status(404).send('Not implemented yet'); - }); - return router; } diff --git a/raiden-cli/src/types.ts b/raiden-cli/src/types.ts index e27c287296..3452068211 100644 --- a/raiden-cli/src/types.ts +++ b/raiden-cli/src/types.ts @@ -19,17 +19,24 @@ export interface Cli { server?: Server; } +export enum ApiChannelState { + opened = 'opened', + closed = 'closed', + settled = 'settled', +} + // Data structures as exchanged over the API export interface ApiChannel { - channel_identifier: number; + channel_identifier: string; token_network_address: string; partner_address: string; token_address: string; balance: string; total_deposit: string; - state: string; - settle_timeout: number; - reveal_timeout: number; + total_withdraw: string; + state: ApiChannelState; + settle_timeout: string; + reveal_timeout: string; } export enum ApiPaymentEvents { diff --git a/raiden-cli/src/utils/channels.ts b/raiden-cli/src/utils/channels.ts index cd8d4778e4..ce2d0e2ac5 100644 --- a/raiden-cli/src/utils/channels.ts +++ b/raiden-cli/src/utils/channels.ts @@ -1,5 +1,5 @@ -import { RaidenChannel, RaidenChannels } from 'raiden-ts'; -import { ApiChannel } from '../types'; +import { RaidenChannel, RaidenChannels, ChannelState } from 'raiden-ts'; +import { ApiChannel, ApiChannelState } from '../types'; export function flattenChannelDictionary(channelDict: RaidenChannels): RaidenChannel[] { // To flatten structure {token: {partner: [channel..], partner:...}, token...} @@ -9,33 +9,36 @@ export function flattenChannelDictionary(channelDict: RaidenChannels): RaidenCha ); } -export function filterChannels( - channels: RaidenChannel[], - tokenAddress?: string, - partnerAddress?: string, -): RaidenChannel[] { - let filteredChannels = channels; - - if (tokenAddress) { - filteredChannels = filteredChannels.filter((channel) => channel.token === tokenAddress); - } - if (partnerAddress) { - filteredChannels = filteredChannels.filter((channel) => channel.token === partnerAddress); +function transformChannelStateForApi(state: ChannelState): ApiChannelState { + let apiState; + switch (state) { + case ChannelState.open: + case ChannelState.closing: + apiState = ApiChannelState.opened; + break; + case ChannelState.closed: + case ChannelState.settleable: + case ChannelState.settling: + apiState = ApiChannelState.closed; + break; + case ChannelState.settled: + apiState = ApiChannelState.settled; + break; } - - return filteredChannels; + return apiState; } -export function transformSdkChannelFormatToApi(channel: RaidenChannel): ApiChannel { +export function transformChannelFormatForApi(channel: RaidenChannel): ApiChannel { return { - channel_identifier: channel.id, + channel_identifier: channel.id.toString(), token_network_address: channel.tokenNetwork, partner_address: channel.partner, token_address: channel.token, - balance: channel.balance.toString(), + balance: channel.capacity.toString(), total_deposit: channel.ownDeposit.toString(), - state: channel.state, - settle_timeout: channel.settleTimeout, - reveal_timeout: 0, // FIXME: Not defined here. Python client handles reveal timeout differently, + total_withdraw: channel.ownWithdraw.toString(), + state: transformChannelStateForApi(channel.state), + settle_timeout: channel.settleTimeout.toString(), + reveal_timeout: '50', // FIXME: Not defined here. Python client handles reveal timeout differently, }; } diff --git a/raiden-cli/src/utils/validation.ts b/raiden-cli/src/utils/validation.ts index 80efacf076..645b4f2cc1 100644 --- a/raiden-cli/src/utils/validation.ts +++ b/raiden-cli/src/utils/validation.ts @@ -53,5 +53,5 @@ export function isInvalidParameterError(error: RaidenError): boolean { * insufficient tokens funds for depositing. */ export function isTransactionWouldFailError(error: Error): boolean { - return /gas required exceeds allowance .* or always failing transaction/.test(error.message); + return /always failing transaction/.test(error.message); } diff --git a/raiden-ts/CHANGELOG.md b/raiden-ts/CHANGELOG.md index 9d2c0e5c46..0c537d19f8 100644 --- a/raiden-ts/CHANGELOG.md +++ b/raiden-ts/CHANGELOG.md @@ -1,18 +1,18 @@ # Changelog ## [Unreleased] - ### Fixed ### Added - [#1913] Added `contractsInfo` getter holding current contracts info ### Changed +- [#1905] Fail early if not enough tokens to deposit [#1913]: https://github.com/raiden-network/light-client/pull/1913 +[#1905]: https://github.com/raiden-network/light-client/issues/1905 ## [0.10.0] - 2020-07-13 - ### Fixed - [#1514] Fix handling of expired LockedTransfer and WithdrawRequest - [#1607] Fix settling when one side closes/updates with outdated BalanceProof diff --git a/raiden-ts/src/channels/epics.ts b/raiden-ts/src/channels/epics.ts index df36b3ea8b..2f2cf6c20b 100644 --- a/raiden-ts/src/channels/epics.ts +++ b/raiden-ts/src/channels/epics.ts @@ -618,29 +618,36 @@ const makeDeposit$ = ( ): Observable => { if (!deposit?.gt(Zero)) return EMPTY; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function stopRetryIfRejected(err: any, count: number) { + function stopRetryIfRejected(err: { message: string; code?: string | number }, count: number) { // 4001 is Metamask's code for rejected/denied signature prompt const metamaskRejectedErrorCode = 4001; return count >= 10 || err?.code === metamaskRejectedErrorCode; } - return defer(() => tokenContract.functions.allowance(sender, tokenNetworkContract.address)).pipe( - mergeMap((allowance) => - allowance.gte(deposit) - ? of(true) - : retryAsync$( - () => - tokenContract.functions.approve(tokenNetworkContract.address, deposit, { - nonce: approveNonce, - }), - (tokenContract.provider as JsonRpcProvider).pollingInterval, - stopRetryIfRejected, - ).pipe( - // if needed, send approveTx and wait/assert it before proceeding - assertTx('approve', ErrorCodes.CNL_APPROVE_TRANSACTION_FAILED, { log }), - ), - ), + return defer(() => + Promise.all([ + tokenContract.functions.balanceOf(sender), + tokenContract.functions.allowance(sender, tokenNetworkContract.address), + ]), + ).pipe( + mergeMap(([balance, allowance]) => { + assert(balance.gte(deposit), [ + ErrorCodes.RDN_INSUFFICIENT_BALANCE, + { current: balance.toString(), required: deposit.toString() }, + ]); + if (allowance.gte(deposit)) return of(true); + return retryAsync$( + () => + tokenContract.functions.approve(tokenNetworkContract.address, deposit, { + nonce: approveNonce, + }), + (tokenContract.provider as JsonRpcProvider).pollingInterval, + stopRetryIfRejected, + ).pipe( + // if needed, send approveTx and wait/assert it before proceeding + assertTx('approve', ErrorCodes.CNL_APPROVE_TRANSACTION_FAILED, { log }), + ); + }), mergeMapTo(channelId$), take(1), mergeMap((id) => diff --git a/raiden-ts/src/raiden.ts b/raiden-ts/src/raiden.ts index 8c5a349dfb..16440772bc 100644 --- a/raiden-ts/src/raiden.ts +++ b/raiden-ts/src/raiden.ts @@ -679,7 +679,7 @@ export class Raiden { assert(tokenNetwork, ErrorCodes.RDN_UNKNOWN_TOKEN_NETWORK, this.log.info); assert(!subkey || this.deps.main, ErrorCodes.RDN_SUBKEY_NOT_SET, this.log.info); - const deposit = decode(UInt(32), amount); + const deposit = decode(UInt(32), amount, ErrorCodes.DTA_INVALID_DEPOSIT, this.log.info); const meta = { tokenNetwork, partner }; const promise = asyncActionToPromise(channelDeposit, meta, this.action$, true).then( ({ txHash }) => txHash, @@ -1295,7 +1295,9 @@ export class Raiden { } public async planUdcWithdraw(value: BigNumberish): Promise { - const meta = { amount: bigNumberify(value) as UInt<32> }; + const meta = { + amount: decode(UInt(32), value, ErrorCodes.DTA_INVALID_AMOUNT, this.log.error), + }; const promise = asyncActionToPromise(udcWithdraw, meta, this.action$, true).then( ({ txHash }) => txHash!, ); diff --git a/raiden-ts/tests/e2e/raiden.spec.ts b/raiden-ts/tests/e2e/raiden.spec.ts index 9946d9be03..d90ebd14e6 100644 --- a/raiden-ts/tests/e2e/raiden.spec.ts +++ b/raiden-ts/tests/e2e/raiden.spec.ts @@ -1352,7 +1352,7 @@ describe('Raiden', () => { await expect(sub.openChannel(token, partner, { subkey: true })).resolves.toMatch(/^0x/); // first deposit fails, as subkey has only 200 tokens settled from previous channel await expect(sub.depositChannel(token, partner, 300, { subkey: true })).rejects.toThrow( - 'revert', + ErrorCodes.RDN_INSUFFICIENT_BALANCE, ); await expect(sub.depositChannel(token, partner, 80, { subkey: true })).resolves.toMatch(/^0x/); diff --git a/raiden-ts/tests/unit/epics/channels.spec.ts b/raiden-ts/tests/unit/epics/channels.spec.ts index c6eb8633e9..90a5c7e508 100644 --- a/raiden-ts/tests/unit/epics/channels.spec.ts +++ b/raiden-ts/tests/unit/epics/channels.spec.ts @@ -51,6 +51,7 @@ import { channelUniqueKey } from 'raiden-ts/channels/utils'; import { ChannelState } from 'raiden-ts/channels'; import { createBalanceHash, getBalanceProofFromEnvelopeMessage } from 'raiden-ts/messages'; import { getLocksroot } from 'raiden-ts/transfers/utils'; +import { ErrorCodes } from 'raiden-ts/utils/error'; test('channelSettleableEpic', async () => { expect.assertions(3); @@ -392,6 +393,28 @@ describe('channelDepositEpic', () => { ); }); + test('fails if not enough balance', async () => { + expect.assertions(1); + + const [raiden, partner] = await makeRaidens(2); + await ensureChannelIsOpen([raiden, partner]); + + const tokenContract = raiden.deps.getTokenContract(token); + tokenContract.functions.balanceOf.mockResolvedValue(deposit.sub(1)); + + raiden.store.dispatch( + channelDeposit.request({ deposit }, { tokenNetwork, partner: partner.address }), + ); + await waitBlock(); + + expect(raiden.output).toContainEqual( + channelDeposit.failure( + expect.objectContaining({ message: ErrorCodes.RDN_INSUFFICIENT_BALANCE }), + { tokenNetwork, partner: partner.address }, + ), + ); + }); + test('approve tx fails', async () => { expect.assertions(3); diff --git a/raiden-ts/tests/unit/mocks.ts b/raiden-ts/tests/unit/mocks.ts index de3cf9c6cb..556c3c67a3 100644 --- a/raiden-ts/tests/unit/mocks.ts +++ b/raiden-ts/tests/unit/mocks.ts @@ -813,6 +813,7 @@ export async function makeRaiden( } tokenContract.functions.approve.mockResolvedValue(makeTransaction()); tokenContract.functions.allowance.mockResolvedValue(Zero); + tokenContract.functions.balanceOf.mockResolvedValue(parseEther('1000')); return tokenContract; }, );