Skip to content

Commit 7153df9

Browse files
committed
sdk: add changelog entry and split withdraw into coopsettle epic
codeclimate was complaining about the side of withdraw with the added coopsettle epics, so we split into its own epic. Also, coopSettleEpic had too many returns due to the skipped states, so we replace it with asserts and a swallowing catchError in the end. No logic change here, just moving things around to satisfy codeclimate.
1 parent 13829b7 commit 7153df9

File tree

5 files changed

+260
-203
lines changed

5 files changed

+260
-203
lines changed

raiden-ts/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Changelog
22

33
## [Unreleased]
4+
### Added
5+
- [#2839] Cooperative settle - allow users to exchange withdraw signatures enabling settling a channel instantly. This is the new default behavior on `Raiden.closeChannel`, falling back to default uncooperative close if needed.
6+
7+
[#2839]: https://github.com/raiden-network/light-client/issues/2839
48

59
## [1.1.0] - 2021-08-09
610
### Added
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import pick from 'lodash/pick';
2+
import type { Observable } from 'rxjs';
3+
import { combineLatest, EMPTY, of } from 'rxjs';
4+
import {
5+
catchError,
6+
concatMap,
7+
filter,
8+
first,
9+
groupBy,
10+
map,
11+
mergeMap,
12+
startWith,
13+
withLatestFrom,
14+
} from 'rxjs/operators';
15+
16+
import type { RaidenAction } from '../../actions';
17+
import { ChannelState } from '../../channels';
18+
import { channelSettle } from '../../channels/actions';
19+
import { channelAmounts, channelKey } from '../../channels/utils';
20+
import type { WithdrawRequest } from '../../messages';
21+
import { MessageType } from '../../messages';
22+
import type { RaidenState } from '../../state';
23+
import type { RaidenEpicDeps } from '../../types';
24+
import { assert } from '../../utils/error';
25+
import { dispatchRequestAndGetResponse } from '../../utils/rx';
26+
import type { Signed } from '../../utils/types';
27+
import { withdraw, withdrawBusy, withdrawMessage } from '../actions';
28+
import { Direction } from '../state';
29+
import { checkContractHasMethod$, matchWithdraw, withdrawMetaFromRequest } from './utils';
30+
31+
/**
32+
* Upon valid [[WithdrawConfirmation]] for a [[WithdrawRequest]].coop_settle=true from partner,
33+
* also send a [[WithdrawRequest]] with whole balance
34+
*
35+
* @param action$ - Observable of withdrawMessage.success actions
36+
* @param state$ - Observable of RaidenStates
37+
* @param deps - Epics dependencies
38+
* @param deps.log - Logger instance
39+
* @param deps.getTokenNetworkContract - TokenNetwork contract getter
40+
* @returns Observable of withdraw.request(coop_settle=false) actions
41+
*/
42+
export function coopSettleWithdrawReplyEpic(
43+
action$: Observable<RaidenAction>,
44+
state$: Observable<RaidenState>,
45+
{ log, getTokenNetworkContract }: RaidenEpicDeps,
46+
): Observable<withdraw.request> {
47+
return action$.pipe(
48+
filter(withdrawMessage.success.is),
49+
filter((action) => action.meta.direction === Direction.RECEIVED),
50+
withLatestFrom(state$),
51+
mergeMap(([action, state]) => {
52+
const tokenNetworkContract = getTokenNetworkContract(action.meta.tokenNetwork);
53+
return checkContractHasMethod$(tokenNetworkContract, 'cooperativeSettle').pipe(
54+
mergeMap(() => {
55+
const channel = state.channels[channelKey(action.meta)];
56+
assert(channel?.state === ChannelState.open, 'channel not open');
57+
const req = channel.partner.pendingWithdraws.find(
58+
matchWithdraw(MessageType.WITHDRAW_REQUEST, action.payload.message),
59+
);
60+
assert(req, 'no matching WithdrawRequest found'); // shouldn't happen
61+
62+
// only reply if this is a coop settle request from partner
63+
if (!req.coop_settle) return EMPTY;
64+
65+
const { ownTotalWithdrawable } = channelAmounts(channel);
66+
return of(
67+
withdraw.request(
68+
{ coopSettle: false },
69+
{
70+
...action.meta,
71+
direction: Direction.SENT,
72+
totalWithdraw: ownTotalWithdrawable,
73+
},
74+
),
75+
);
76+
}),
77+
catchError((error) => {
78+
log.warn('Could not reply to CoopSettle request, ignoring', { action, error });
79+
return EMPTY;
80+
}),
81+
);
82+
}),
83+
);
84+
}
85+
86+
/**
87+
* When both valid [[WithdrawConfirmation]] for a [[WithdrawRequest]].coop_settle=true from us,
88+
* send a channelSettle.request
89+
*
90+
* @param action$ - Observable of withdrawMessage.success actions
91+
* @param state$ - Observable of RaidenStates
92+
* @param deps - Epics dependencies
93+
* @param deps.latest$ - Latest observable
94+
* @param deps.config$ - Config observable
95+
* @param deps.log - Logger instance
96+
* @returns Observable of channelSettle.request|withdraw.failure|success|withdrawBusy actions
97+
*/
98+
export function coopSettleEpic(
99+
action$: Observable<RaidenAction>,
100+
{}: Observable<RaidenState>,
101+
{ latest$, config$, log }: RaidenEpicDeps,
102+
): Observable<channelSettle.request | withdraw.failure | withdraw.success | withdrawBusy> {
103+
return action$.pipe(
104+
dispatchRequestAndGetResponse(channelSettle, (requestSettle$) =>
105+
action$.pipe(
106+
filter(withdrawMessage.success.is),
107+
groupBy((action) => channelKey(action.meta)),
108+
mergeMap((grouped$) =>
109+
grouped$.pipe(
110+
concatMap((action) =>
111+
// observable inside concatMap ensures the body is evaluated at subscription time
112+
combineLatest([latest$, config$]).pipe(
113+
first(),
114+
mergeMap(([{ state }, { revealTimeout }]) => {
115+
const channel = state.channels[channelKey(action.meta)];
116+
assert(channel?.state === ChannelState.open, 'channel not open');
117+
118+
const {
119+
ownCapacity,
120+
partnerCapacity,
121+
ownTotalWithdrawable,
122+
partnerTotalWithdrawable,
123+
} = channelAmounts(channel);
124+
// when both capacities are zero, both sides should be ready; before that, just
125+
// skip silently, a matching state may come later or withdraw will expire
126+
assert(
127+
(!channel.own.locks.length &&
128+
!channel.partner.locks.length &&
129+
ownCapacity.isZero()) ||
130+
partnerCapacity.isZero(),
131+
'',
132+
);
133+
134+
const ownReq = channel.own.pendingWithdraws.find(
135+
(msg): msg is Signed<WithdrawRequest> =>
136+
msg.type === MessageType.WITHDRAW_REQUEST &&
137+
msg.expiration.gte(state.blockNumber + revealTimeout) &&
138+
msg.total_withdraw.eq(ownTotalWithdrawable) &&
139+
!!msg.coop_settle, // only requests where coop_settle is true
140+
);
141+
// not our request or expires too soon
142+
assert(ownReq && !ownReq.expiration.lt(state.blockNumber + revealTimeout), '');
143+
144+
const ownConfirmation = channel.own.pendingWithdraws.find(
145+
matchWithdraw(MessageType.WITHDRAW_CONFIRMATION, ownReq),
146+
);
147+
const partnerReq = channel.partner.pendingWithdraws.find(
148+
(msg): msg is Signed<WithdrawRequest> =>
149+
msg.type === MessageType.WITHDRAW_REQUEST &&
150+
msg.expiration.gte(state.blockNumber + revealTimeout) &&
151+
msg.total_withdraw.eq(partnerTotalWithdrawable),
152+
);
153+
assert(partnerReq, 'partner request not found'); // shouldn't happen
154+
const partnerConfirmation = channel.partner.pendingWithdraws.find(
155+
matchWithdraw(MessageType.WITHDRAW_CONFIRMATION, partnerReq),
156+
);
157+
assert(ownConfirmation && partnerConfirmation, [
158+
'no matching WithdrawConfirmations found',
159+
{ ownConfirmation, partnerConfirmation },
160+
]);
161+
162+
const withdrawMeta = withdrawMetaFromRequest(ownReq, channel);
163+
return requestSettle$(
164+
channelSettle.request(
165+
{
166+
coopSettle: [
167+
[ownReq, ownConfirmation],
168+
[partnerReq, partnerConfirmation],
169+
],
170+
},
171+
{ tokenNetwork: withdrawMeta.tokenNetwork, partner: withdrawMeta.partner },
172+
),
173+
).pipe(
174+
map((success) =>
175+
withdraw.success(
176+
pick(success.payload, ['txBlock', 'txHash', 'confirmed'] as const),
177+
withdrawMeta,
178+
),
179+
),
180+
catchError((err) => of(withdraw.failure(err, withdrawMeta))),
181+
// prevents this withdraw from expire-failing while we're trying to settle
182+
startWith(withdrawBusy(undefined, withdrawMeta)),
183+
);
184+
}),
185+
catchError((err) => {
186+
if (err.message) log.info(err.message, err.details);
187+
// these errors are just the asserts, to be ignored;
188+
// []actual errors are only the catched inside requestSettle$'s pipe
189+
return EMPTY;
190+
}),
191+
),
192+
),
193+
),
194+
),
195+
),
196+
),
197+
);
198+
}

raiden-ts/src/transfers/epics/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from '../mediate/epics';
2+
export * from './coopsettle';
23
export * from './expire';
34
export * from './init';
45
export * from './locked';

raiden-ts/src/transfers/epics/utils.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import type { Contract } from '@ethersproject/contracts';
23
import type { Observable } from 'rxjs';
34
import { defer, merge, of } from 'rxjs';
45
import { filter, ignoreElements, take } from 'rxjs/operators';
56

67
import type { RaidenAction } from '../../actions';
8+
import type { Channel } from '../../channels/state';
79
import type {
810
MessageType,
911
WithdrawConfirmation,
1012
WithdrawExpired,
1113
WithdrawRequest,
1214
} from '../../messages';
1315
import { messageSend } from '../../messages/actions';
16+
import { assert } from '../../utils';
1417
import { isResponseOf } from '../../utils/actions';
18+
import { contractHasMethod } from '../../utils/ethers';
1519
import { completeWith, repeatUntil } from '../../utils/rx';
1620
import type { UInt } from '../../utils/types';
21+
import { decode, HexString } from '../../utils/types';
22+
import type { withdraw } from '../actions';
23+
import { Direction } from '../state';
1724

1825
/**
1926
* Exponential back-off infinite generator
@@ -115,3 +122,44 @@ export function matchWithdraw<
115122
'totalWithdraw' in data ? data.totalWithdraw : data.total_withdraw,
116123
)));
117124
}
125+
126+
/**
127+
* @param req - WithdrawRequest message
128+
* @param channel - Channel in which it was received
129+
* @returns withdraw async action meta for respective request
130+
*/
131+
export function withdrawMetaFromRequest(
132+
req: WithdrawRequest,
133+
channel: Channel,
134+
): withdraw.request['meta'] {
135+
return {
136+
tokenNetwork: channel.tokenNetwork,
137+
partner: channel.partner.address,
138+
direction: req.participant === channel.partner.address ? Direction.RECEIVED : Direction.SENT,
139+
expiration: req.expiration.toNumber(),
140+
totalWithdraw: req.total_withdraw,
141+
};
142+
}
143+
144+
/**
145+
* Fetches contract's code and parse if it has given method (by name)
146+
*
147+
* @param contract - contract instance to check
148+
* @param method - method name
149+
* @returns Observable of true, emitting a single value if successful, or erroring
150+
*/
151+
export function checkContractHasMethod$<C extends Contract>(
152+
contract: C,
153+
method: keyof C['functions'] & string,
154+
): Observable<true> {
155+
return defer(async () => {
156+
const sighash = contract.interface.getSighash(method);
157+
// decode shouldn't fail if building with ^0.39 contracts, but runtime may be running
158+
// with 0.37 contracts, and the only way to know is by checking contract's code (memoized)
159+
assert(
160+
await contractHasMethod(decode(HexString(4), sighash, 'signature hash not found'), contract),
161+
['contract does not have method', { contract: contract.address, method }],
162+
);
163+
return true as const;
164+
});
165+
}

0 commit comments

Comments
 (0)