Skip to content

Commit 941c5a2

Browse files
committed
channels: indicate channels currently being used in a swap
1 parent cb2c084 commit 941c5a2

File tree

17 files changed

+424
-29
lines changed

17 files changed

+424
-29
lines changed

app/src/__stories__/ChannelRow.stories.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import { useObserver } from 'mobx-react-lite';
3+
import { SwapState, SwapType } from 'types/generated/loop_pb';
34
import { SwapDirection } from 'types/state';
45
import { lndListChannels } from 'util/tests/sampleData';
56
import { useStore } from 'store';
@@ -87,3 +88,37 @@ export const Dimmed = () => {
8788
store.buildSwapStore.setDirection(SwapDirection.OUT);
8889
return renderStory(channel);
8990
};
91+
92+
export const LoopingIn = () => {
93+
const store = useStore();
94+
const channel = new Channel(store, lndListChannels.channelsList[0]);
95+
const swap = store.swapStore.sortedSwaps[0];
96+
swap.type = SwapType.LOOP_IN;
97+
swap.state = SwapState.INITIATED;
98+
store.swapStore.addSwappedChannels(swap.id, [channel.chanId]);
99+
return renderStory(channel, { ratio: 0.3 });
100+
};
101+
102+
export const LoopingOut = () => {
103+
const store = useStore();
104+
const channel = new Channel(store, lndListChannels.channelsList[0]);
105+
const swap = store.swapStore.sortedSwaps[0];
106+
swap.type = SwapType.LOOP_OUT;
107+
swap.state = SwapState.INITIATED;
108+
store.swapStore.addSwappedChannels(swap.id, [channel.chanId]);
109+
return renderStory(channel, { ratio: 0.3 });
110+
};
111+
112+
export const LoopingInAndOut = () => {
113+
const store = useStore();
114+
const channel = new Channel(store, lndListChannels.channelsList[0]);
115+
const swap1 = store.swapStore.sortedSwaps[0];
116+
swap1.type = SwapType.LOOP_IN;
117+
swap1.state = SwapState.INITIATED;
118+
store.swapStore.addSwappedChannels(swap1.id, [channel.chanId]);
119+
const swap2 = store.swapStore.sortedSwaps[1];
120+
swap2.type = SwapType.LOOP_OUT;
121+
swap2.state = SwapState.INITIATED;
122+
store.swapStore.addSwappedChannels(swap2.id, [channel.chanId]);
123+
return renderStory(channel, { ratio: 0.3 });
124+
};

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React from 'react';
2+
import { SwapState, SwapType } from 'types/generated/loop_pb';
23
import { SwapDirection } from 'types/state';
34
import { fireEvent } from '@testing-library/react';
45
import { formatSats } from 'util/formatters';
56
import { renderWithProviders } from 'util/tests';
67
import { createStore, Store } from 'store';
7-
import { Channel } from 'store/models';
8+
import { Channel, Swap } from 'store/models';
89
import ChannelRow from 'components/loop/ChannelRow';
910

1011
describe('ChannelRow component', () => {
@@ -121,4 +122,44 @@ describe('ChannelRow component', () => {
121122
fireEvent.click(getByRole('checkbox'));
122123
expect(store.buildSwapStore.selectedChanIds).toEqual([]);
123124
});
125+
126+
describe('pending swaps', () => {
127+
let swap1: Swap;
128+
let swap2: Swap;
129+
130+
beforeEach(() => {
131+
swap1 = store.swapStore.sortedSwaps[0];
132+
swap2 = store.swapStore.sortedSwaps[1];
133+
swap1.state = swap2.state = SwapState.INITIATED;
134+
});
135+
136+
it('should display the pending Loop In icon', () => {
137+
swap1.type = SwapType.LOOP_IN;
138+
store.swapStore.addSwappedChannels(swap1.id, [channel.chanId]);
139+
const { getByText } = render();
140+
expect(getByText('chevrons-right.svg')).toBeInTheDocument();
141+
fireEvent.mouseEnter(getByText('chevrons-right.svg'));
142+
expect(getByText('Loop In currently in progress')).toBeInTheDocument();
143+
});
144+
145+
it('should display the pending Loop Out icon', () => {
146+
swap1.type = SwapType.LOOP_OUT;
147+
store.swapStore.addSwappedChannels(swap1.id, [channel.chanId]);
148+
const { getByText } = render();
149+
expect(getByText('chevrons-left.svg')).toBeInTheDocument();
150+
fireEvent.mouseEnter(getByText('chevrons-left.svg'));
151+
expect(getByText('Loop Out currently in progress')).toBeInTheDocument();
152+
});
153+
154+
it('should display the pending Loop In and Loop Out icon', () => {
155+
swap1.type = SwapType.LOOP_IN;
156+
swap2.type = SwapType.LOOP_OUT;
157+
store.swapStore.addSwappedChannels(swap1.id, [channel.chanId]);
158+
store.swapStore.addSwappedChannels(swap2.id, [channel.chanId]);
159+
const { getByText } = render();
160+
expect(getByText('chevrons.svg')).toBeInTheDocument();
161+
fireEvent.mouseEnter(getByText('chevrons.svg'));
162+
expect(getByText('Loop In and Loop Out currently in progress')).toBeInTheDocument();
163+
});
164+
});
124165
});

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

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,6 @@ describe('BuildSwapStore', () => {
1515
let rootStore: Store;
1616
let store: BuildSwapStore;
1717

18-
const addChannel = (capacity: number, localBalance: number) => {
19-
const remoteBalance = capacity - localBalance;
20-
const lndChan = { ...lndChannel, capacity, localBalance, remoteBalance };
21-
const channel = new Channel(rootStore, lndChan);
22-
channel.chanId = `${channel.chanId}${rootStore.channelStore.channels.size}`;
23-
rootStore.channelStore.channels.set(channel.chanId, channel);
24-
};
25-
26-
const round = (amount: number) => {
27-
return Math.floor(amount / store.AMOUNT_INCREMENT) * store.AMOUNT_INCREMENT;
28-
};
29-
3018
beforeEach(async () => {
3119
rootStore = createStore();
3220
await rootStore.fetchAllData();
@@ -96,6 +84,16 @@ describe('BuildSwapStore', () => {
9684
expect(+store.amountForSelected).toBe(loopTerms.maxSwapAmount);
9785
});
9886

87+
it('should select all channels with the same peer for loop in', () => {
88+
const channels = rootStore.channelStore.sortedChannels;
89+
channels[1].remotePubkey = channels[0].remotePubkey;
90+
channels[2].remotePubkey = channels[0].remotePubkey;
91+
expect(store.selectedChanIds).toHaveLength(0);
92+
store.toggleSelectedChannel(channels[0].chanId);
93+
store.setDirection(SwapDirection.IN);
94+
expect(store.selectedChanIds).toHaveLength(3);
95+
});
96+
9997
it('should fetch a loop in quote', async () => {
10098
expect(+store.quote.swapFee).toEqual(0);
10199
expect(+store.quote.minerFee).toEqual(0);
@@ -166,6 +164,31 @@ describe('BuildSwapStore', () => {
166164
});
167165
});
168166

167+
it('should store swapped channels after a loop in', async () => {
168+
const channels = rootStore.channelStore.sortedChannels;
169+
// the pubkey in the sampleData is not valid, so hard-code this valid one
170+
channels[0].remotePubkey =
171+
'035c82e14eb74d2324daa17eebea8c58b46a9eabac87191cc83ee26275b514e6a0';
172+
store.toggleSelectedChannel(channels[0].chanId);
173+
store.setDirection(SwapDirection.IN);
174+
store.setAmount(Big(600));
175+
expect(rootStore.swapStore.swappedChannels.size).toBe(0);
176+
store.requestSwap();
177+
await waitFor(() => expect(store.currentStep).toBe(BuildSwapSteps.Closed));
178+
expect(rootStore.swapStore.swappedChannels.size).toBe(1);
179+
});
180+
181+
it('should store swapped channels after a loop out', async () => {
182+
const channels = rootStore.channelStore.sortedChannels;
183+
store.toggleSelectedChannel(channels[0].chanId);
184+
store.setDirection(SwapDirection.OUT);
185+
store.setAmount(Big(600));
186+
expect(rootStore.swapStore.swappedChannels.size).toBe(0);
187+
store.requestSwap();
188+
await waitFor(() => expect(store.currentStep).toBe(BuildSwapSteps.Closed));
189+
expect(rootStore.swapStore.swappedChannels.size).toBe(1);
190+
});
191+
169192
it('should set the correct swap deadline in production', async () => {
170193
store.setDirection(SwapDirection.OUT);
171194
store.setAmount(Big(600));
@@ -245,6 +268,19 @@ describe('BuildSwapStore', () => {
245268
});
246269

247270
describe('min/max swap limits', () => {
271+
const addChannel = (capacity: number, localBalance: number) => {
272+
const remoteBalance = capacity - localBalance;
273+
const lndChan = { ...lndChannel, capacity, localBalance, remoteBalance };
274+
const channel = new Channel(rootStore, lndChan);
275+
channel.chanId = `${channel.chanId}${rootStore.channelStore.channels.size}`;
276+
channel.remotePubkey = `${channel.remotePubkey}${rootStore.channelStore.channels.size}`;
277+
rootStore.channelStore.channels.set(channel.chanId, channel);
278+
};
279+
280+
const round = (amount: number) => {
281+
return Math.floor(amount / store.AMOUNT_INCREMENT) * store.AMOUNT_INCREMENT;
282+
};
283+
248284
beforeEach(() => {
249285
rootStore.channelStore.channels.clear();
250286
[

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ describe('ChannelStore', () => {
133133
// the alias is fetched from the API and should be updated after a few ticks
134134
await waitFor(() => {
135135
expect(channel.alias).toBe(lndGetNodeInfo.node.alias);
136+
expect(channel.aliasLabel).toBe(lndGetNodeInfo.node.alias);
136137
});
137138
});
138139

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,41 @@ describe('SwapStore', () => {
1717
store = rootStore.swapStore;
1818
});
1919

20+
it('should add swapped channels', () => {
21+
expect(store.swappedChannels.size).toBe(0);
22+
store.addSwappedChannels('s1', ['c1', 'c2']);
23+
expect(store.swappedChannels.size).toBe(2);
24+
expect(store.swappedChannels.get('c1')).toEqual(['s1']);
25+
expect(store.swappedChannels.get('c2')).toEqual(['s1']);
26+
store.addSwappedChannels('s2', ['c2']);
27+
expect(store.swappedChannels.size).toBe(2);
28+
expect(store.swappedChannels.get('c2')).toEqual(['s1', 's2']);
29+
});
30+
31+
it('should prune the swapped channels list', async () => {
32+
await rootStore.channelStore.fetchChannels();
33+
await store.fetchSwaps();
34+
const swaps = store.sortedSwaps;
35+
// make these swaps pending
36+
swaps[0].state = LOOP.SwapState.HTLC_PUBLISHED;
37+
swaps[1].state = LOOP.SwapState.INITIATED;
38+
const channels = rootStore.channelStore.sortedChannels;
39+
const [c1, c2, c3] = channels.map(c => c.chanId);
40+
store.addSwappedChannels(swaps[0].id, [c1, c2]);
41+
store.addSwappedChannels(swaps[1].id, [c2, c3]);
42+
// confirm swapped channels are set
43+
expect(store.swappedChannels.size).toBe(3);
44+
expect(store.swappedChannels.get(c2)).toHaveLength(2);
45+
// change one swap to complete
46+
swaps[1].state = LOOP.SwapState.SUCCESS;
47+
store.pruneSwappedChannels();
48+
// confirm swap1 removed
49+
expect(store.swappedChannels.size).toBe(2);
50+
expect(store.swappedChannels.get(c1)).toHaveLength(1);
51+
expect(store.swappedChannels.get(c2)).toHaveLength(1);
52+
expect(store.swappedChannels.get(c3)).toBeUndefined();
53+
});
54+
2055
it('should fetch list of swaps', async () => {
2156
expect(store.sortedSwaps).toHaveLength(0);
2257
await store.fetchSwaps();
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 4 additions & 0 deletions
Loading

app/src/components/base/icons.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { ReactComponent as ArrowRightIcon } from 'assets/icons/arrow-right.svg';
33
import { ReactComponent as BitcoinIcon } from 'assets/icons/bitcoin.svg';
44
import { ReactComponent as BoltIcon } from 'assets/icons/bolt.svg';
55
import { ReactComponent as CheckIcon } from 'assets/icons/check.svg';
6+
import { ReactComponent as ChevronsLeftIcon } from 'assets/icons/chevrons-left.svg';
7+
import { ReactComponent as ChevronsRightIcon } from 'assets/icons/chevrons-right.svg';
68
import { ReactComponent as ChevronsIcon } from 'assets/icons/chevrons.svg';
79
import { ReactComponent as ClockIcon } from 'assets/icons/clock.svg';
810
import { ReactComponent as CloseIcon } from 'assets/icons/close.svg';
@@ -65,6 +67,8 @@ export const Bolt = Icon.withComponent(BoltIcon);
6567
export const Bitcoin = Icon.withComponent(BitcoinIcon);
6668
export const Check = Icon.withComponent(CheckIcon);
6769
export const Chevrons = Icon.withComponent(ChevronsIcon);
70+
export const ChevronsLeft = Icon.withComponent(ChevronsLeftIcon);
71+
export const ChevronsRight = Icon.withComponent(ChevronsRightIcon);
6872
export const Close = Icon.withComponent(CloseIcon);
6973
export const Dot = Icon.withComponent(DotIcon);
7074
export const Menu = Icon.withComponent(MenuIcon);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React from 'react';
2+
import { styled } from 'components/theme';
3+
import { Chevrons, ChevronsLeft, ChevronsRight } from '../base';
4+
5+
const BaseIcon = styled.span`
6+
padding: 0;
7+
8+
&.success {
9+
color: ${props => props.theme.colors.green};
10+
}
11+
&.warn {
12+
color: ${props => props.theme.colors.yellow};
13+
}
14+
&.error {
15+
color: ${props => props.theme.colors.pink};
16+
}
17+
&.idle {
18+
color: ${props => props.theme.colors.gray};
19+
}
20+
`;
21+
22+
interface Props {
23+
status: 'success' | 'warn' | 'error' | 'idle';
24+
direction: 'in' | 'out' | 'both';
25+
}
26+
27+
const DirectionIcons: Record<Props['direction'], any> = {
28+
both: BaseIcon.withComponent(Chevrons),
29+
in: BaseIcon.withComponent(ChevronsRight),
30+
out: BaseIcon.withComponent(ChevronsLeft),
31+
};
32+
33+
const StatusArrow: React.FC<Props> = ({ status, direction }) => {
34+
const Icon = DirectionIcons[direction];
35+
return <Icon size="small" className={status} aria-label={`${status} ${direction}`} />;
36+
};
37+
38+
export default StatusArrow;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from 'react';
2+
import { observer } from 'mobx-react-lite';
3+
import { usePrefixedTranslation } from 'hooks';
4+
import { BalanceStatus } from 'util/constants';
5+
import { Channel } from 'store/models';
6+
import StatusArrow from 'components/common/StatusArrow';
7+
import StatusDot from 'components/common/StatusDot';
8+
import Tip from 'components/common/Tip';
9+
10+
type Status = 'success' | 'warn' | 'error' | 'idle';
11+
12+
const StatusMap: Record<BalanceStatus, Status> = {
13+
[BalanceStatus.ok]: 'success',
14+
[BalanceStatus.warn]: 'warn',
15+
[BalanceStatus.danger]: 'error',
16+
};
17+
18+
interface Props {
19+
channel: Channel;
20+
}
21+
22+
const ChannelIcon: React.FC<Props> = ({ channel }) => {
23+
const { l } = usePrefixedTranslation('cmps.loop.ChannelIcon.processing');
24+
let status = StatusMap[channel.balanceStatus];
25+
if (!channel.active) status = 'idle';
26+
27+
if (channel.processingSwapsDirection !== 'none') {
28+
return (
29+
<Tip
30+
overlay={l(channel.processingSwapsDirection)}
31+
capitalize={false}
32+
placement="right"
33+
>
34+
<span>
35+
<StatusArrow status={status} direction={channel.processingSwapsDirection} />
36+
</span>
37+
</Tip>
38+
);
39+
}
40+
41+
return <StatusDot status={status} />;
42+
};
43+
44+
export default observer(ChannelIcon);

0 commit comments

Comments
 (0)