Skip to content

Commit 50917a0

Browse files
committed
loop: prevent Loop In when multiple channels are selected
1 parent bcd64ef commit 50917a0

File tree

6 files changed

+84
-27
lines changed

6 files changed

+84
-27
lines changed

app/src/__tests__/components/loop/LoopPage.spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe('LoopPage component', () => {
9393
const { getByText } = render();
9494
expect(getByText('Loop')).toBeInTheDocument();
9595
fireEvent.click(getByText('Loop'));
96-
store.channelStore.sortedChannels.slice(0, 3).forEach(c => {
96+
store.channelStore.sortedChannels.slice(0, 1).forEach(c => {
9797
store.buildSwapStore.toggleSelectedChannel(c.chanId);
9898
});
9999
fireEvent.click(getByText('Loop in'));
@@ -104,7 +104,7 @@ describe('LoopPage component', () => {
104104
const { getByText } = render();
105105
expect(getByText('Loop')).toBeInTheDocument();
106106
fireEvent.click(getByText('Loop'));
107-
store.channelStore.sortedChannels.slice(0, 3).forEach(c => {
107+
store.channelStore.sortedChannels.slice(0, 1).forEach(c => {
108108
store.buildSwapStore.toggleSelectedChannel(c.chanId);
109109
});
110110
fireEvent.click(getByText('Loop in'));

app/src/__tests__/store/buildSwapStore.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ describe('BuildSwapStore', () => {
108108
});
109109

110110
it('should perform a loop in', async () => {
111+
const channels = rootStore.channelStore.sortedChannels;
112+
// the pubkey in the sampleData is not valid, so hard-code this valid one
113+
channels[0].remotePubkey =
114+
'035c82e14eb74d2324daa17eebea8c58b46a9eabac87191cc83ee26275b514e6a0';
115+
store.toggleSelectedChannel(channels[0].chanId);
111116
store.setDirection(SwapDirection.IN);
112117
store.setAmount(600);
113118
store.requestSwap();
@@ -120,6 +125,8 @@ describe('BuildSwapStore', () => {
120125
});
121126

122127
it('should perform a loop out', async () => {
128+
const channels = rootStore.channelStore.sortedChannels;
129+
store.toggleSelectedChannel(channels[0].chanId);
123130
store.setDirection(SwapDirection.OUT);
124131
store.setAmount(600);
125132
store.requestSwap();

app/src/api/loop.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,16 @@ class LoopApi {
6868
/**
6969
* call the Loop `LoopIn` RPC and return the response
7070
*/
71-
async loopIn(amount: number, quote: Quote): Promise<LOOP.SwapResponse.AsObject> {
71+
async loopIn(
72+
amount: number,
73+
quote: Quote,
74+
lastHop?: string,
75+
): Promise<LOOP.SwapResponse.AsObject> {
7276
const req = new LOOP.LoopInRequest();
7377
req.setAmt(amount);
7478
req.setMaxSwapFee(quote.swapFee);
7579
req.setMaxMinerFee(quote.minerFee);
80+
if (lastHop) req.setLastHop(Buffer.from(lastHop, 'hex').toString('base64'));
7681
const res = await this._grpc.request(SwapClient.LoopIn, req, this._meta);
7782
return res.toObject();
7883
}

app/src/components/loop/LoopActions.tsx

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ const Styled = {
1212
margin: 50px 0;
1313
`,
1414
Actions: styled.div`
15-
display: inline-block;
1615
margin-top: -15px;
16+
`,
17+
ActionBar: styled.div`
18+
display: inline-block;
1719
padding: 15px;
1820
background-color: ${props => props.theme.colors.darkBlue};
1921
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.5);
@@ -31,6 +33,12 @@ const Styled = {
3133
display: inline-block;
3234
margin-right: 50px;
3335
`,
36+
Note: styled.span`
37+
margin-left: 20px;
38+
font-size: ${props => props.theme.sizes.s};
39+
color: ${props => props.theme.colors.gray};
40+
/* font-style: italic; */
41+
`,
3442
};
3543

3644
const LoopActions: React.FC = () => {
@@ -43,28 +51,34 @@ const LoopActions: React.FC = () => {
4351
const handleLoopIn = useCallback(() => setDirection(SwapDirection.IN), [setDirection]);
4452
const selectedCount = buildSwapStore.selectedChanIds.length;
4553

46-
const { Wrapper, Actions, CloseIcon, Selected } = Styled;
54+
const { Wrapper, Actions, ActionBar, CloseIcon, Selected, Note } = Styled;
4755
return (
4856
<Wrapper>
4957
{buildSwapStore.showActions ? (
5058
<Actions>
51-
<CloseIcon onClick={buildSwapStore.cancel} />
52-
<Pill>{selectedCount}</Pill>
53-
<Selected>{l('channelsSelected')}</Selected>
54-
<Button
55-
primary={inferredDirection === SwapDirection.OUT}
56-
borderless
57-
onClick={handleLoopOut}
58-
>
59-
Loop out
60-
</Button>
61-
<Button
62-
primary={inferredDirection === SwapDirection.IN}
63-
borderless
64-
onClick={handleLoopIn}
65-
>
66-
Loop in
67-
</Button>
59+
<ActionBar>
60+
<CloseIcon onClick={buildSwapStore.cancel} />
61+
<Pill>{selectedCount}</Pill>
62+
<Selected>{l('channelsSelected')}</Selected>
63+
<Button
64+
primary={inferredDirection === SwapDirection.OUT}
65+
borderless
66+
onClick={handleLoopOut}
67+
>
68+
Loop out
69+
</Button>
70+
<Button
71+
primary={
72+
buildSwapStore.loopInAllowed && inferredDirection === SwapDirection.IN
73+
}
74+
borderless
75+
onClick={handleLoopIn}
76+
disabled={!buildSwapStore.loopInAllowed}
77+
>
78+
Loop in
79+
</Button>
80+
</ActionBar>
81+
{!buildSwapStore.loopInAllowed && <Note>{l('loopInNote')}</Note>}
6882
</Actions>
6983
) : (
7084
<Button onClick={buildSwapStore.startSwap}>

app/src/i18n/locales/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"cmps.loop.ChannelRowHeader.peer": "Peer/Alias",
1919
"cmps.loop.ChannelRowHeader.capacity": "Capacity",
2020
"cmps.loop.LoopActions.channelsSelected": "channels selected",
21+
"cmps.loop.LoopActions.loopInNote": "For multi Loop In, all channels must use the same peer.",
2122
"cmps.loop.LoopPage.pageTitle": "Lightning Loop",
2223
"cmps.loop.LoopHistory.emptyMsg": "After performing swaps, you will see ongoing loops and history here.",
2324
"cmps.loop.LoopTiles.history": "Loop History",

app/src/store/stores/buildSwapStore.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { action, computed, observable, runInAction, toJS, values } from 'mobx';
22
import { BuildSwapSteps, Quote, SwapDirection, SwapTerms } from 'types/state';
33
import { formatSats } from 'util/formatters';
44
import { Store } from 'store';
5+
import Channel from 'store/models/channel';
56

67
// an artificial delay to allow the user to abort a swap before it executed
78
export const SWAP_ABORT_DELAY = 3000;
@@ -122,6 +123,39 @@ class BuildSwapStore {
122123
return avgPct < 50 ? SwapDirection.IN : SwapDirection.OUT;
123124
}
124125

126+
/**
127+
* determines if Loop In is allowed, which is only true when the selected
128+
* channels are using a single peer. If multiple peers are chosen, then
129+
* Loop in should not be allowed
130+
*/
131+
@computed
132+
get loopInAllowed() {
133+
if (this.selectedChanIds.length > 0) {
134+
return this.loopInLastHop !== undefined;
135+
}
136+
137+
return true;
138+
}
139+
140+
/**
141+
* Returns the unique peer pubkey of the selected channels. If no channels
142+
* are selected OR the selected channels are using more than one peer, then
143+
* undefined is returned
144+
*/
145+
@computed
146+
get loopInLastHop(): string | undefined {
147+
const channels = this.selectedChanIds
148+
.map(id => this._store.channelStore.channels.get(id))
149+
.filter(c => !!c) as Channel[];
150+
const peers = channels.reduce((peers, c) => {
151+
if (!peers.includes(c.remotePubkey)) {
152+
peers.push(c.remotePubkey);
153+
}
154+
return peers;
155+
}, [] as string[]);
156+
return peers.length === 1 ? peers[0] : undefined;
157+
}
158+
125159
//
126160
// Actions
127161
//
@@ -193,10 +227,6 @@ class BuildSwapStore {
193227
*/
194228
@action.bound
195229
goToPrevStep() {
196-
if (this.currentStep === BuildSwapSteps.ChooseAmount) {
197-
this.cancel();
198-
return;
199-
}
200230
if (this.currentStep === BuildSwapSteps.Processing) {
201231
// if back is clicked on the processing step
202232
this.abortSwap();
@@ -299,7 +329,7 @@ class BuildSwapStore {
299329
const chanIds = this.selectedChanIds.map(v => parseInt(v));
300330
const res =
301331
direction === SwapDirection.IN
302-
? await this._store.api.loop.loopIn(amount, quote)
332+
? await this._store.api.loop.loopIn(amount, quote, this.loopInLastHop)
303333
: await this._store.api.loop.loopOut(amount, quote, chanIds);
304334
this._store.log.info('completed loop', toJS(res));
305335
runInAction('requestSwapContinuation', () => {

0 commit comments

Comments
 (0)