Skip to content

Commit a6c0074

Browse files
authored
Merge pull request #112 from jamaljsr/swap-addl-options
loop: add options for conf target and loop out address
2 parents 75a26db + da3b727 commit a6c0074

File tree

12 files changed

+325
-16
lines changed

12 files changed

+325
-16
lines changed

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import { values } from 'mobx';
23
import { SwapDirection } from 'types/state';
34
import { fireEvent } from '@testing-library/react';
45
import Big from 'big.js';
@@ -69,6 +70,46 @@ describe('SwapWizard component', () => {
6970
expect(+build.amountForSelected).toEqual(575000);
7071
expect(getByText(`575,000 sats`)).toBeInTheDocument();
7172
});
73+
74+
it('should show additional options', () => {
75+
const { getByText } = render();
76+
fireEvent.click(getByText('Additional Options'));
77+
expect(getByText('Hide Options')).toBeInTheDocument();
78+
});
79+
80+
it('should store the specified conf target', () => {
81+
const { getByText, getByPlaceholderText } = render();
82+
fireEvent.click(getByText('Additional Options'));
83+
fireEvent.change(getByPlaceholderText('number of blocks (ex: 6)'), {
84+
target: { value: 20 },
85+
});
86+
expect(store.buildSwapStore.confTarget).toBeUndefined();
87+
fireEvent.click(getByText('Next'));
88+
expect(store.buildSwapStore.confTarget).toBe(20);
89+
});
90+
91+
it('should store the specified destination address', () => {
92+
const { getByText, getByPlaceholderText } = render();
93+
fireEvent.click(getByText('Additional Options'));
94+
fireEvent.change(getByPlaceholderText('segwit address'), {
95+
target: { value: 'abcdef' },
96+
});
97+
expect(store.buildSwapStore.loopOutAddress).toBeUndefined();
98+
fireEvent.click(getByText('Next'));
99+
expect(store.buildSwapStore.loopOutAddress).toBe('abcdef');
100+
});
101+
102+
it('should handle invalid conf target', () => {
103+
const { getByText, getByPlaceholderText } = render();
104+
fireEvent.click(getByText('Additional Options'));
105+
fireEvent.change(getByPlaceholderText('number of blocks (ex: 6)'), {
106+
target: { value: 'asdf' },
107+
});
108+
fireEvent.click(getByText('Next'));
109+
expect(values(store.uiStore.alerts)[0].message).toBe(
110+
'Confirmation target must be between 20 and 60.',
111+
);
112+
});
72113
});
73114

74115
describe('Review Step', () => {

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

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { waitFor } from '@testing-library/react';
55
import Big from 'big.js';
66
import { BalanceMode } from 'util/constants';
77
import { injectIntoGrpcUnary } from 'util/tests';
8-
import { lndChannel, loopInTerms } from 'util/tests/sampleData';
8+
import { lndChannel, loopInTerms, loopOutTerms } from 'util/tests/sampleData';
99
import { BuildSwapStore, createStore, Store } from 'store';
1010
import { Channel } from 'store/models';
1111
import { SWAP_ABORT_DELAY } from 'store/stores/buildSwapStore';
@@ -83,7 +83,12 @@ describe('BuildSwapStore', () => {
8383
expect(store.terms.out).toEqual({ min: Big(0), max: Big(0) });
8484
await store.getTerms();
8585
expect(store.terms.in).toEqual({ min: Big(250000), max: Big(1000000) });
86-
expect(store.terms.out).toEqual({ min: Big(250000), max: Big(1000000) });
86+
expect(store.terms.out).toEqual({
87+
min: Big(250000),
88+
max: Big(1000000),
89+
minCltv: 20,
90+
maxCltv: 60,
91+
});
8792
});
8893

8994
it('should handle errors fetching loop terms', async () => {
@@ -116,6 +121,68 @@ describe('BuildSwapStore', () => {
116121
expect(+store.amountForSelected).toBe(loopInTerms.maxSwapAmount);
117122
});
118123

124+
it('should validate the conf target', async () => {
125+
const { minCltvDelta, maxCltvDelta } = loopOutTerms;
126+
expect(store.confTarget).toBeUndefined();
127+
128+
let target = maxCltvDelta - 10;
129+
store.setConfTarget(target);
130+
expect(store.confTarget).toBe(target);
131+
132+
store.setDirection(SwapDirection.OUT);
133+
await store.getTerms();
134+
135+
store.setConfTarget(target);
136+
expect(store.confTarget).toBe(target);
137+
138+
target = minCltvDelta - 10;
139+
expect(() => store.setConfTarget(target)).toThrow();
140+
141+
target = maxCltvDelta + 10;
142+
expect(() => store.setConfTarget(target)).toThrow();
143+
});
144+
145+
it('should submit the Loop Out conf target', async () => {
146+
const target = 23;
147+
store.setDirection(SwapDirection.OUT);
148+
store.setAmount(Big(500000));
149+
150+
expect(store.confTarget).toBeUndefined();
151+
store.setConfTarget(target);
152+
expect(store.confTarget).toBe(target);
153+
154+
let reqTarget = '';
155+
// mock the grpc unary function in order to capture the supplied dest
156+
// passed in with the API request
157+
injectIntoGrpcUnary((desc, props) => {
158+
reqTarget = (props.request.toObject() as any).sweepConfTarget;
159+
});
160+
161+
store.requestSwap();
162+
await waitFor(() => expect(reqTarget).toBe(target));
163+
});
164+
165+
it('should submit the Loop Out address', async () => {
166+
const addr = 'xyzabc';
167+
store.setDirection(SwapDirection.OUT);
168+
store.setAmount(Big(500000));
169+
170+
expect(store.loopOutAddress).toBeUndefined();
171+
store.setLoopOutAddress(addr);
172+
expect(store.loopOutAddress).toBe(addr);
173+
// store.goToNextStep();
174+
175+
let reqAddr = '';
176+
// mock the grpc unary function in order to capture the supplied dest
177+
// passed in with the API request
178+
injectIntoGrpcUnary((desc, props) => {
179+
reqAddr = (props.request.toObject() as any).dest;
180+
});
181+
182+
store.requestSwap();
183+
await waitFor(() => expect(reqAddr).toBe(addr));
184+
});
185+
119186
it('should select all channels with the same peer for loop in', () => {
120187
const channels = rootStore.channelStore.sortedChannels;
121188
channels[1].remotePubkey = channels[0].remotePubkey;

app/src/api/loop.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,27 @@ class LoopApi extends BaseApi<LoopEvents> {
5151
/**
5252
* call the Loop `GetLoopInQuote` RPC and return the response
5353
*/
54-
async getLoopInQuote(amount: Big): Promise<LOOP.InQuoteResponse.AsObject> {
54+
async getLoopInQuote(
55+
amount: Big,
56+
confTarget?: number,
57+
): Promise<LOOP.InQuoteResponse.AsObject> {
5558
const req = new LOOP.QuoteRequest();
5659
req.setAmt(+amount);
60+
if (confTarget) req.setConfTarget(confTarget);
5761
const res = await this._grpc.request(SwapClient.GetLoopInQuote, req, this._meta);
5862
return res.toObject();
5963
}
6064

6165
/**
6266
* call the Loop `LoopOutQuote` RPC and return the response
6367
*/
64-
async getLoopOutQuote(amount: Big): Promise<LOOP.OutQuoteResponse.AsObject> {
68+
async getLoopOutQuote(
69+
amount: Big,
70+
confTarget?: number,
71+
): Promise<LOOP.OutQuoteResponse.AsObject> {
6572
const req = new LOOP.QuoteRequest();
6673
req.setAmt(+amount);
74+
if (confTarget) req.setConfTarget(confTarget);
6775
const res = await this._grpc.request(SwapClient.LoopOutQuote, req, this._meta);
6876
return res.toObject();
6977
}
@@ -75,12 +83,14 @@ class LoopApi extends BaseApi<LoopEvents> {
7583
amount: Big,
7684
quote: Quote,
7785
lastHop?: string,
86+
confTarget?: number,
7887
): Promise<LOOP.SwapResponse.AsObject> {
7988
const req = new LOOP.LoopInRequest();
8089
req.setAmt(+amount);
8190
req.setMaxSwapFee(+quote.swapFee);
8291
req.setMaxMinerFee(+quote.minerFee);
8392
if (lastHop) req.setLastHop(Buffer.from(lastHop, 'hex').toString('base64'));
93+
if (confTarget) req.setHtlcConfTarget(confTarget);
8494
const res = await this._grpc.request(SwapClient.LoopIn, req, this._meta);
8595
return res.toObject();
8696
}
@@ -93,6 +103,8 @@ class LoopApi extends BaseApi<LoopEvents> {
93103
quote: Quote,
94104
chanIds: number[],
95105
deadline: number,
106+
confTarget?: number,
107+
destAddress?: string,
96108
): Promise<LOOP.SwapResponse.AsObject> {
97109
const req = new LOOP.LoopOutRequest();
98110
req.setAmt(+amount);
@@ -103,6 +115,8 @@ class LoopApi extends BaseApi<LoopEvents> {
103115
req.setMaxPrepayRoutingFee(this._calcRoutingFee(+quote.prepayAmount));
104116
req.setOutgoingChanSetList(chanIds);
105117
req.setSwapPublicationDeadline(deadline);
118+
if (confTarget) req.setSweepConfTarget(confTarget);
119+
if (destAddress) req.setDest(destAddress);
106120

107121
const res = await this._grpc.request(SwapClient.LoopOut, req, this._meta);
108122
return res.toObject();

app/src/assets/icons/settings.svg

Lines changed: 4 additions & 0 deletions
Loading

app/src/components/base/icons.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ReactComponent as MaximizeIcon } from 'assets/icons/maximize.svg';
1818
import { ReactComponent as MenuIcon } from 'assets/icons/menu.svg';
1919
import { ReactComponent as MinimizeIcon } from 'assets/icons/minimize.svg';
2020
import { ReactComponent as RefreshIcon } from 'assets/icons/refresh-cw.svg';
21+
import { ReactComponent as SettingsIcon } from 'assets/icons/settings.svg';
2122
import { ReactComponent as CancelIcon } from 'assets/icons/slash.svg';
2223
import { styled } from 'components/theme';
2324

@@ -92,3 +93,4 @@ export const Menu = Icon.withComponent(MenuIcon);
9293
export const Minimize = Icon.withComponent(MinimizeIcon);
9394
export const Maximize = Icon.withComponent(MaximizeIcon);
9495
export const Refresh = Icon.withComponent(RefreshIcon);
96+
export const Settings = Icon.withComponent(SettingsIcon);

app/src/components/base/shared.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ export const Input = styled.input`
135135
&:focus {
136136
outline: none;
137137
background-color: ${props => props.theme.colors.overlay};
138+
border-bottom-color: ${props => props.theme.colors.white};
139+
}
140+
141+
&::placeholder {
142+
color: ${props => props.theme.colors.gray};
138143
}
139144
`;
140145

app/src/components/common/Tip.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ const TooltipWrapper: React.FC<Props> = ({
6868
* prop. So we basically proxy the className using the TooltipWrapper
6969
* above, then export this styled component for the rest of the app to use
7070
*/
71-
const Tip = styled(TooltipWrapper)<{ capitalize?: boolean }>`
71+
const Tip = styled(TooltipWrapper)<{ capitalize?: boolean; maxWidth?: number }>`
72+
max-width: ${props => (props.maxWidth ? `${props.maxWidth}px` : 'auto')};
7273
color: ${props => props.theme.colors.blue};
7374
font-family: ${props => props.theme.fonts.open.semiBold};
7475
font-size: ${props => props.theme.sizes.xs};

app/src/components/loop/swap/StepButtons.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,28 @@ import { Button } from 'components/base';
55

66
const Styled = {
77
Wrapper: styled.div`
8-
text-align: right;
8+
display: flex;
9+
justify-content: flex-end;
10+
`,
11+
ExtraContent: styled.div`
12+
flex: 1;
913
`,
1014
};
1115

1216
interface Props {
1317
confirm?: boolean;
1418
onCancel: () => void;
1519
onNext: () => void;
20+
extra?: React.ReactNode;
1621
}
1722

18-
const StepButtons: React.FC<Props> = ({ confirm, onCancel, onNext }) => {
23+
const StepButtons: React.FC<Props> = ({ confirm, onCancel, onNext, extra }) => {
1924
const { l } = usePrefixedTranslation('cmps.loop.swap.StepButtons');
2025

21-
const { Wrapper } = Styled;
26+
const { Wrapper, ExtraContent } = Styled;
2227
return (
2328
<Wrapper>
29+
<ExtraContent>{extra}</ExtraContent>
2430
<Button ghost borderless onClick={onCancel}>
2531
{l('cancel')}
2632
</Button>

app/src/components/loop/swap/SwapConfigStep.tsx

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import React from 'react';
1+
import React, { useCallback, useState } from 'react';
22
import { observer } from 'mobx-react-lite';
33
import { SwapDirection } from 'types/state';
44
import styled from '@emotion/styled';
55
import { usePrefixedTranslation } from 'hooks';
66
import { useStore } from 'store';
7+
import { Button, HeaderFour, HelpCircle, Input, Settings } from 'components/base';
78
import Range from 'components/common/Range';
9+
import Tip from 'components/common/Tip';
810
import StepButtons from './StepButtons';
911
import StepSummary from './StepSummary';
1012

@@ -27,13 +29,53 @@ const Styled = {
2729
flex-direction: column;
2830
justify-content: space-between;
2931
`,
32+
Options: styled.div<{ visible: boolean }>`
33+
padding: ${props => (props.visible ? '30px 0' : '0')};
34+
margin: 30px 0;
35+
background-color: #27273c;
36+
box-shadow: inset rgba(0, 0, 0, 0.5) 0px 0px 5px 0px;
37+
border-radius: 15px;
38+
display: flex;
39+
justify-content: space-between;
40+
overflow: hidden;
41+
height: ${props => (props.visible ? '125px' : '0')};
42+
transition: all 0.3s;
43+
44+
> div {
45+
flex: 1;
46+
margin: 0 30px;
47+
}
48+
`,
49+
SmallInput: styled(Input)`
50+
width: 100%;
51+
font-size: ${props => props.theme.sizes.s};
52+
text-align: left;
53+
border-bottom-width: 1px;
54+
`,
3055
};
3156

3257
const SwapConfigStep: React.FC = () => {
3358
const { l } = usePrefixedTranslation('cmps.loop.swap.SwapConfigStep');
34-
const { buildSwapStore } = useStore();
59+
const { buildSwapStore, uiStore } = useStore();
60+
const [confTarget, setConfTarget] = useState(
61+
(buildSwapStore.confTarget || '').toString(),
62+
);
63+
const [destAddress, setDestAddress] = useState(buildSwapStore.loopOutAddress || '');
3564

36-
const { Wrapper, Summary, Config } = Styled;
65+
const handleNext = useCallback(() => {
66+
try {
67+
if (buildSwapStore.addlOptionsVisible) {
68+
const target = confTarget !== '' ? parseInt(confTarget) : undefined;
69+
buildSwapStore.setConfTarget(target);
70+
buildSwapStore.setLoopOutAddress(destAddress);
71+
}
72+
buildSwapStore.goToNextStep();
73+
} catch (error) {
74+
uiStore.handleError(error);
75+
}
76+
}, [buildSwapStore, confTarget, destAddress, uiStore]);
77+
78+
const { Wrapper, Summary, Config, Options, SmallInput } = Styled;
3779
return (
3880
<Wrapper data-tour="loop-amount">
3981
<Summary>
@@ -56,9 +98,45 @@ const SwapConfigStep: React.FC = () => {
5698
step={buildSwapStore.AMOUNT_INCREMENT}
5799
onChange={buildSwapStore.setAmount}
58100
/>
101+
<Options visible={buildSwapStore.addlOptionsVisible}>
102+
<div>
103+
<HeaderFour>
104+
{l('confTargetLabel')}
105+
<Tip overlay={l('confTargetTip')} capitalize={false} maxWidth={400}>
106+
<HelpCircle />
107+
</Tip>
108+
</HeaderFour>
109+
<SmallInput
110+
placeholder={l('confTargetHint')}
111+
value={confTarget}
112+
onChange={e => setConfTarget(e.target.value)}
113+
/>
114+
</div>
115+
{buildSwapStore.direction === SwapDirection.OUT && (
116+
<div>
117+
<HeaderFour>
118+
{l('destAddrLabel')}
119+
<Tip overlay={l('destAddrTip')} capitalize={false} maxWidth={400}>
120+
<HelpCircle />
121+
</Tip>
122+
</HeaderFour>
123+
<SmallInput
124+
placeholder={l('destAddrHint')}
125+
value={destAddress}
126+
onChange={e => setDestAddress(e.target.value)}
127+
/>
128+
</div>
129+
)}
130+
</Options>
59131
<StepButtons
60132
onCancel={buildSwapStore.cancel}
61-
onNext={buildSwapStore.goToNextStep}
133+
onNext={handleNext}
134+
extra={
135+
<Button ghost borderless onClick={buildSwapStore.toggleAddlOptions}>
136+
<Settings />
137+
{buildSwapStore.addlOptionsVisible ? l('hideOptions') : l('addlOptions')}
138+
</Button>
139+
}
62140
/>
63141
</Config>
64142
</Wrapper>

0 commit comments

Comments
 (0)