Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support openChannelWithDeposit from new contracts #2919

Merged
merged 5 commits into from
Sep 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions raiden-ts/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
- [#2798] Delay non-closing auto-settle to prevent wasted gas on channelSettle race; closing side is given priority on auto-settling
- [#2889] Ensure capabilities are updated when they change even if RTC channels are established by reconnecting them.

### Added
- [#2891] Use `TokenNetwork.openChannelWithDeposit` on new contracts for faster open+deposit in a single transaction.

[#2798]: https://github.com/raiden-network/light-client/issues/2798
[#2889]: https://github.com/raiden-network/light-client/issues/2889
[#2891]: https://github.com/raiden-network/light-client/issues/2891

## [2.0.0-rc.1] - 2021-08-13
### Added
Expand Down
53 changes: 18 additions & 35 deletions raiden-ts/src/channels/epics/deposit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import findKey from 'lodash/findKey';
import type { Observable } from 'rxjs';
import { combineLatest, defer, merge, of, ReplaySubject } from 'rxjs';
import { AsyncSubject, combineLatest, merge, of } from 'rxjs';
import {
catchError,
concatMap,
Expand All @@ -13,7 +13,6 @@ import {
mergeMap,
mergeMapTo,
pluck,
take,
withLatestFrom,
} from 'rxjs/operators';

Expand All @@ -25,48 +24,33 @@ import type { RaidenState } from '../../state';
import type { RaidenEpicDeps } from '../../types';
import { isActionOf } from '../../utils/actions';
import { assert, commonAndFailTxErrors, ErrorCodes, RaidenError } from '../../utils/error';
import { retryWhile } from '../../utils/rx';
import { mergeWith, retryWhile } from '../../utils/rx';
import type { Address, UInt } from '../../utils/types';
import { isntNil } from '../../utils/types';
import { channelDeposit, channelOpen } from '../actions';
import { ChannelState } from '../state';
import { approveIfNeeded$, assertTx, channelKey } from '../utils';
import { assertTx, channelKey, ensureApprovedBalance$ } from '../utils';

function makeDeposit$(
[tokenContract, tokenNetworkContract]: [HumanStandardToken, TokenNetwork],
[sender, address, partner]: [Address, Address, Address],
[partner, channelId$]: readonly [Address, Observable<number>],
deposit: UInt<32>,
channelId$: Observable<number>,
deps: Pick<RaidenEpicDeps, 'log' | 'provider' | 'config$' | 'latest$'>,
deps: Pick<RaidenEpicDeps, 'address' | 'log' | 'config$' | 'latest$'>,
) {
const { log, provider, config$, latest$ } = deps;
const { address, log, config$, latest$ } = deps;
const provider = tokenContract.provider as RaidenEpicDeps['provider'];
// retryWhile from here
return defer(() =>
Promise.all([
tokenContract.callStatic.balanceOf(sender) as Promise<UInt<32>>,
tokenContract.callStatic.allowance(sender, tokenNetworkContract.address) as Promise<
UInt<32>
>,
]),
return ensureApprovedBalance$(
tokenContract,
tokenNetworkContract.address as Address,
deposit,
deps,
).pipe(
withLatestFrom(config$, latest$),
mergeMap(([[balance, allowance], { minimumAllowance }, { gasPrice }]) =>
approveIfNeeded$(
[balance, allowance, deposit],
tokenContract,
tokenNetworkContract.address as Address,
ErrorCodes.CNL_APPROVE_TRANSACTION_FAILED,
deps,
{ minimumAllowance, gasPrice },
),
),
mergeMapTo(channelId$),
take(1),
// get current 'view' of own/'address' deposit, despite any other pending deposits
mergeMap(async (id) =>
tokenNetworkContract.callStatic
.getChannelParticipantInfo(id, address, partner)
.then(({ 0: totalDeposit }) => [id, totalDeposit] as const),
mergeWith(
async (id) =>
(await tokenNetworkContract.callStatic.getChannelParticipantInfo(id, address, partner))[0],
),
withLatestFrom(latest$),
// send setTotalDeposit transaction
Expand Down Expand Up @@ -151,7 +135,7 @@ export function channelDepositEpic(
else if (channel?.state === ChannelState.open) channel$ = of(channel);
else throw new RaidenError(ErrorCodes.CNL_NO_OPEN_CHANNEL_FOUND);

const { signer: onchainSigner, address: onchainAddress } = chooseOnchainAccount(
const { signer: onchainSigner } = chooseOnchainAccount(
{ signer, address, main },
action.payload.subkey ?? configSubkey,
);
Expand All @@ -170,12 +154,11 @@ export function channelDepositEpic(
// already start 'approve' even while waiting for 'channel$'
makeDeposit$(
[tokenContract, tokenNetworkContract],
[onchainAddress, address, partner],
[partner, channelId$],
action.payload.deposit,
channelId$,
deps,
),
{ connector: () => new ReplaySubject(1) },
{ connector: () => new AsyncSubject() },
),
// ignore success tx so it's picked by channelEventsEpic
ignoreElements(),
Expand Down
192 changes: 134 additions & 58 deletions raiden-ts/src/channels/epics/open.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,146 @@
import constant from 'lodash/constant';
import findKey from 'lodash/findKey';
import type { Observable } from 'rxjs';
import { concat, defer, EMPTY, of } from 'rxjs';
import { EMPTY, merge, of } from 'rxjs';
import {
catchError,
filter,
first,
ignoreElements,
mapTo,
mergeMap,
raceWith,
take,
takeUntil,
withLatestFrom,
} from 'rxjs/operators';

import type { RaidenAction } from '../../actions';
import { intervalFromConfig } from '../../config';
import type { HumanStandardToken, TokenNetwork } from '../../contracts';
import { chooseOnchainAccount, getContractWithSigner } from '../../helpers';
import type { RaidenState } from '../../state';
import type { RaidenEpicDeps } from '../../types';
import { isActionOf } from '../../utils/actions';
import { commonAndFailTxErrors, ErrorCodes, RaidenError } from '../../utils/error';
import { checkContractHasMethod$ } from '../../utils/ethers';
import { retryWhile } from '../../utils/rx';
import type { Address, UInt } from '../../utils/types';
import { channelDeposit, channelOpen } from '../actions';
import { assertTx, channelKey } from '../utils';
import { assertTx, channelKey, ensureApprovedBalance$ } from '../utils';

// if contract supports `openChannelWithDeposit`
function openWithDeposit$(
[tokenNetworkContract, tokenContract]: readonly [TokenNetwork, HumanStandardToken],
[partner, deposit, settleTimeout, raced$]: readonly [
Address,
UInt<32>,
number,
Observable<unknown>,
],
deps: RaidenEpicDeps,
): Observable<boolean> {
const { address, latest$, config$, log } = deps;
const tokenNetwork = tokenNetworkContract.address as Address;
// if we need to deposit and contract supports 'openChannelWithDeposit' (0.39+),
// we must ensureApprovedBalance$ ourselves and then call the method to open+deposit
return ensureApprovedBalance$(tokenContract, tokenNetwork, deposit, deps).pipe(
withLatestFrom(latest$),
mergeMap(async ([, { gasPrice }]) =>
tokenNetworkContract.openChannelWithDeposit(address, partner, settleTimeout, deposit, {
gasPrice,
}),
),
assertTx('openChannelWithDeposit', ErrorCodes.CNL_OPENCHANNEL_FAILED, deps),
retryWhile(intervalFromConfig(config$), {
onErrors: commonAndFailTxErrors,
log: log.info,
}),
mapTo(false), // should not deposit, as tx succeeded
// raceWith acts like takeUntil, but besides unsubscribing from retryWhile if channel
// gets opened by partner, also requests channelDeposit then
raceWith(raced$.pipe(take(1), mapTo(true))), // should deposit, as open raced
);
}

// if contract doesn't support `openChannelWithDeposit` or deposit isn't needed
function openAndThenDeposit$(
tokenNetworkContract: TokenNetwork,
[partner, settleTimeout, raced$]: readonly [Address, number, Observable<unknown>],
deps: RaidenEpicDeps,
) {
const { address, log, latest$, config$ } = deps;
return merge(
of(true), // should deposit in parallel (approve + deposit[waitOpen])
latest$.pipe(
first(),
mergeMap(async ({ gasPrice }) =>
tokenNetworkContract.openChannel(address, partner, settleTimeout, { gasPrice }),
),
assertTx('openChannel', ErrorCodes.CNL_OPENCHANNEL_FAILED, deps),
// also retry txFailErrors on open$ only; deposit$ (if not EMPTY) is handled by
// channelDepositEpic
retryWhile(intervalFromConfig(config$), {
onErrors: commonAndFailTxErrors,
log: log.info,
}),
ignoreElements(), // ignore success so it's picked by channelEventsEpic
// if channel gets opened while retrying (e.g. by partner), give up retry
takeUntil(raced$),
),
);
}

// check if contract supports `openChannelWithDeposit`, emit actions and catch error if needed
function openAndDeposit$(
action$: Observable<RaidenAction>,
request: channelOpen.request,
[tokenNetworkContract, tokenContract]: [TokenNetwork, HumanStandardToken],
deps: RaidenEpicDeps,
) {
const deposit = request.payload.deposit;
const { tokenNetwork, partner } = request.meta;
const { config$ } = deps;
return checkContractHasMethod$(tokenNetworkContract, 'openChannelWithDeposit').pipe(
catchError(constant(of(false))),
withLatestFrom(config$),
mergeMap(([hasMethod, { settleTimeout: configSettleTimeout }]) => {
const openedByPartner$ = action$.pipe(
filter(channelOpen.success.is),
filter((a) => a.meta.tokenNetwork === tokenNetwork && a.meta.partner === partner),
);
const settleTimeout = request.payload.settleTimeout ?? configSettleTimeout;

let open$: Observable<boolean>;
if (deposit?.gt(0) && hasMethod) {
open$ = openWithDeposit$(
[tokenNetworkContract, tokenContract],
[partner, deposit, settleTimeout, openedByPartner$],
deps,
);
} else {
open$ = openAndThenDeposit$(
tokenNetworkContract,
[partner, settleTimeout, openedByPartner$],
deps,
);
}
return open$.pipe(
mergeMap((shouldDeposit) =>
shouldDeposit && deposit?.gt(0)
? of(
channelDeposit.request(
{ deposit, subkey: request.payload.subkey, waitOpen: true },
request.meta,
),
)
: EMPTY,
),
catchError((error) => of(channelOpen.failure(error, request.meta))),
);
}),
);
}

/**
* A channelOpen action requested by user
Expand All @@ -35,6 +157,7 @@ import { assertTx, channelKey } from '../utils';
* @param deps.address - Our address
* @param deps.main - Main signer/address
* @param deps.provider - Provider instance
* @param deps.getTokenContract - Token contract instance getter
* @param deps.getTokenNetworkContract - TokenNetwork contract instance getter
* @param deps.config$ - Config observable
* @param deps.latest$ - Latest observable
Expand All @@ -43,22 +166,14 @@ import { assertTx, channelKey } from '../utils';
export function channelOpenEpic(
action$: Observable<RaidenAction>,
state$: Observable<RaidenState>,
{
log,
signer,
address,
main,
provider,
getTokenNetworkContract,
config$,
latest$,
}: RaidenEpicDeps,
deps: RaidenEpicDeps,
): Observable<channelOpen.failure | channelDeposit.request> {
const { getTokenNetworkContract, getTokenContract, config$ } = deps;
return action$.pipe(
filter(isActionOf(channelOpen.request)),
withLatestFrom(state$, config$, latest$),
mergeMap(([action, state, { settleTimeout, subkey: configSubkey }, { gasPrice }]) => {
const { tokenNetwork, partner } = action.meta;
withLatestFrom(state$, config$),
mergeMap(([action, state, { subkey: configSubkey }]) => {
const { tokenNetwork } = action.meta;
const channelState = state.channels[channelKey(action.meta)]?.state;
// fails if channel already exist
if (channelState)
Expand All @@ -69,56 +184,17 @@ export function channelOpenEpic(
),
);
const { signer: onchainSigner } = chooseOnchainAccount(
{ signer, address, main },
deps,
action.payload.subkey ?? configSubkey,
);
const tokenNetworkContract = getContractWithSigner(
getTokenNetworkContract(tokenNetwork),
onchainSigner,
);
const token = findKey(state.tokens, (tn) => tn === tokenNetwork)! as Address;
const tokenContract = getContractWithSigner(getTokenContract(token), onchainSigner);

let deposit$: Observable<channelDeposit.request> = EMPTY;
if (action.payload.deposit?.gt(0))
// if it didn't fail so far, emit a channelDeposit.request in parallel with waitOpen=true
// to send 'approve' tx meanwhile we open the channel
deposit$ = of(
channelDeposit.request(
{ deposit: action.payload.deposit, subkey: action.payload.subkey, waitOpen: true },
action.meta,
),
);

return concat(
deposit$,
defer(async () =>
tokenNetworkContract.openChannel(
address,
partner,
action.payload.settleTimeout ?? settleTimeout,
{ gasPrice },
),
).pipe(
assertTx('openChannel', ErrorCodes.CNL_OPENCHANNEL_FAILED, { log, provider }),
// also retry txFailErrors: if it's caused by partner having opened, takeUntil will see
retryWhile(intervalFromConfig(config$), {
onErrors: commonAndFailTxErrors,
log: log.info,
}),
// if channel gets opened while retrying (e.g. by partner), give up to avoid erroring
takeUntil(
action$.pipe(
filter(channelOpen.success.is),
filter(
(action_) =>
action_.meta.tokenNetwork === tokenNetwork && action_.meta.partner === partner,
),
),
),
// ignore success so it's picked by channelEventsEpic
ignoreElements(),
catchError((error) => of(channelOpen.failure(error, action.meta))),
),
);
return openAndDeposit$(action$, action, [tokenNetworkContract, tokenContract], deps);
}),
);
}
Loading