Skip to content

Commit 5c3faf6

Browse files
authored
feat(mining): automatically mine new blocks (#707)
1 parent 61f7941 commit 5c3faf6

27 files changed

+547
-38
lines changed

TODO.md

-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
Small Stuff
66

77
- implement real-time channel updates from LND via GRPC streams
8-
- implement option to auto-mine every X minutes
98
- switch renovatebot to dependabot and use automatic security fixes
109
- mock or install docker on build servers for e2e tests
1110
- consistent scrollbars for all OS's (https://github.com/xobotyi/react-scrollbars-custom) (https://github.com/souhe/reactScrollbar)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React from 'react';
2+
import { fireEvent } from '@testing-library/dom';
3+
import { getNetwork, renderWithProviders } from 'utils/tests';
4+
import AutoMineButton from './AutoMineButton';
5+
import { AutoMineMode } from 'types';
6+
import { AutoMinerModel } from 'store/models/network';
7+
import { Status } from 'shared/types';
8+
9+
describe('AutoMineButton', () => {
10+
let unmount: () => void;
11+
12+
const renderComponent = (autoMineMode: AutoMineMode = AutoMineMode.AutoOff) => {
13+
const network = getNetwork(1, 'test network', Status.Started);
14+
network.autoMineMode = autoMineMode;
15+
16+
const autoMiner = {
17+
startTime: 0,
18+
timer: undefined,
19+
mining: false,
20+
} as AutoMinerModel;
21+
22+
if (autoMineMode != AutoMineMode.AutoOff) {
23+
// set start time into the past to test the percentage of the timer
24+
autoMiner.startTime = Date.now() - 15000;
25+
autoMiner.mining = true;
26+
}
27+
28+
const initialState = {
29+
network: {
30+
networks: [network],
31+
designer: {
32+
activeId: network.id,
33+
},
34+
autoMiners: {
35+
'1': autoMiner,
36+
},
37+
},
38+
};
39+
const cmp = <AutoMineButton network={network}></AutoMineButton>;
40+
const result = renderWithProviders(cmp, { initialState });
41+
unmount = result.unmount;
42+
return result;
43+
};
44+
45+
afterEach(() => unmount());
46+
47+
it('should display the button text', async () => {
48+
const { getByText } = renderComponent();
49+
expect(getByText('Auto Mine: Off')).toBeInTheDocument();
50+
});
51+
52+
it('should display dropdown options', async () => {
53+
const { getByText, findByText } = renderComponent();
54+
fireEvent.mouseOver(getByText('Auto Mine: Off'));
55+
expect(await findByText('30s')).toBeInTheDocument();
56+
expect(await findByText('1m')).toBeInTheDocument();
57+
expect(await findByText('5m')).toBeInTheDocument();
58+
expect(await findByText('10m')).toBeInTheDocument();
59+
});
60+
61+
it('should display correct automine mode based on network', async () => {
62+
const { getByText } = renderComponent(AutoMineMode.Auto30s);
63+
expect(getByText('Auto Mine: 30s')).toBeInTheDocument();
64+
});
65+
66+
it('should calculate remaining percentage correctly', async () => {
67+
jest.useFakeTimers({ now: Date.now() });
68+
69+
const { findByText } = renderComponent(AutoMineMode.Auto30s);
70+
71+
// advance by one interval
72+
jest.advanceTimersByTime(1500);
73+
74+
const progressBar = (await findByText('Auto Mine: 30s'))
75+
.nextElementSibling as HTMLElement;
76+
77+
const progressBarWidthPercentage = parseFloat(progressBar.style.width.slice(0, -1));
78+
expect(progressBarWidthPercentage).toBeGreaterThan(49.0);
79+
expect(progressBarWidthPercentage).toBeLessThan(51.0);
80+
81+
jest.useRealTimers();
82+
});
83+
84+
it('should change remaining percentage on mode change', async () => {
85+
const { getByText, findByText } = renderComponent();
86+
87+
let progressBar = getByText('Auto Mine: Off').nextElementSibling as HTMLElement;
88+
expect(progressBar.style.width).toBe('0%');
89+
90+
// click to start automine
91+
fireEvent.mouseOver(getByText('Auto Mine: Off'));
92+
fireEvent.click(await findByText('30s'));
93+
// the button text doesn't change since the autoMine store action is not really run
94+
progressBar = getByText('Auto Mine: Off').nextElementSibling as HTMLElement;
95+
expect(progressBar.style.width).toBe('100%');
96+
97+
// click again to turn off automine
98+
fireEvent.mouseOver(getByText('Auto Mine: Off'));
99+
fireEvent.click(await findByText('Off'));
100+
progressBar = getByText('Auto Mine: Off').nextElementSibling as HTMLElement;
101+
expect(progressBar.style.width).toBe('0%');
102+
});
103+
});
+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { FieldTimeOutlined } from '@ant-design/icons';
2+
import styled from '@emotion/styled';
3+
import { Button, Dropdown, Tooltip, MenuProps } from 'antd';
4+
import { ItemType } from 'antd/lib/menu/hooks/useItems';
5+
import { usePrefixedTranslation } from 'hooks';
6+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
7+
import { useStoreActions, useStoreState } from 'store';
8+
import { AutoMineMode, Network } from 'types';
9+
10+
const Styled = {
11+
Button: styled(Button)`
12+
margin-left: 8px;
13+
`,
14+
RemainingBar: styled.div`
15+
transition: width 400ms ease-in-out;
16+
background: #d46b08;
17+
position: absolute;
18+
width: 100%;
19+
height: 3px;
20+
bottom: 0;
21+
left: 0;
22+
`,
23+
};
24+
25+
interface Props {
26+
network: Network;
27+
}
28+
29+
const getRemainingPercentage = (mode: AutoMineMode, startTime: number) => {
30+
if (mode === AutoMineMode.AutoOff) return 0;
31+
else {
32+
const elapsedTime = Date.now() - startTime;
33+
const autoMineInterval = 1000 * mode;
34+
const remainingTime = autoMineInterval - (elapsedTime % autoMineInterval);
35+
const remainingPercentage = (100 * remainingTime) / autoMineInterval;
36+
37+
return remainingPercentage;
38+
}
39+
};
40+
41+
const AutoMineButton: React.FC<Props> = ({ network }) => {
42+
const { l } = usePrefixedTranslation('cmps.designer.AutoMineButton');
43+
const { autoMine } = useStoreActions(s => s.network);
44+
const autoMiner = useStoreState(s => s.network.autoMiners[network.id]);
45+
const [remainingPercentage, setRemainingPercentage] = useState(0);
46+
const [tickTimer, setTickTimer] = useState<NodeJS.Timer | undefined>(undefined);
47+
48+
useEffect(() => {
49+
return () => {
50+
clearInterval(tickTimer);
51+
setTickTimer(undefined);
52+
};
53+
}, []);
54+
55+
useEffect(() => {
56+
let tt = tickTimer;
57+
clearInterval(tt);
58+
59+
const setPercentage = () => {
60+
setRemainingPercentage(
61+
getRemainingPercentage(network.autoMineMode, autoMiner?.startTime || 0),
62+
);
63+
};
64+
65+
if (network.autoMineMode === AutoMineMode.AutoOff) {
66+
setPercentage();
67+
setTickTimer(undefined);
68+
} else {
69+
tt = setInterval(() => {
70+
setPercentage();
71+
}, 1000);
72+
setTickTimer(tt);
73+
}
74+
return () => {
75+
clearInterval(tt);
76+
};
77+
}, [network.autoMineMode]);
78+
79+
const autoMineStatusShortMap = useMemo(() => {
80+
return {
81+
[AutoMineMode.AutoOff]: l('autoOff'),
82+
[AutoMineMode.Auto30s]: l('autoThirtySecondsShort'),
83+
[AutoMineMode.Auto1m]: l('autoOneMinuteShort'),
84+
[AutoMineMode.Auto5m]: l('autoFiveMinutesShort'),
85+
[AutoMineMode.Auto10m]: l('autoTenMinutesShort'),
86+
};
87+
}, [l]);
88+
89+
const handleAutoMineModeChanged: MenuProps['onClick'] = useCallback(
90+
info => {
91+
info.key == AutoMineMode.AutoOff
92+
? setRemainingPercentage(0)
93+
: setRemainingPercentage(100);
94+
autoMine({
95+
mode: +info.key,
96+
id: network.id,
97+
});
98+
},
99+
[network, autoMine],
100+
);
101+
102+
function createMenuItem(key: AutoMineMode): ItemType {
103+
return {
104+
label: autoMineStatusShortMap[key],
105+
key: String(key),
106+
};
107+
}
108+
109+
const menu: MenuProps = {
110+
selectedKeys: [String(network.autoMineMode || AutoMineMode.AutoOff)],
111+
onClick: handleAutoMineModeChanged,
112+
items: [
113+
createMenuItem(AutoMineMode.AutoOff),
114+
{
115+
type: 'divider',
116+
},
117+
createMenuItem(AutoMineMode.Auto30s),
118+
createMenuItem(AutoMineMode.Auto1m),
119+
createMenuItem(AutoMineMode.Auto5m),
120+
createMenuItem(AutoMineMode.Auto10m),
121+
],
122+
};
123+
124+
return (
125+
<Tooltip title={l('autoMineBtnTip')}>
126+
<Dropdown menu={menu} trigger={['hover']} overlayClassName="polar-context-menu">
127+
<Styled.Button icon={<FieldTimeOutlined />} loading={autoMiner?.mining}>
128+
{l('autoMine')}: {autoMineStatusShortMap[network.autoMineMode]}
129+
<Styled.RemainingBar
130+
style={{
131+
width: `${remainingPercentage}%`,
132+
}}
133+
/>
134+
</Styled.Button>
135+
</Dropdown>
136+
</Tooltip>
137+
);
138+
};
139+
140+
export default AutoMineButton;

src/components/designer/SyncButton.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { Network } from 'types';
1010
const Styled = {
1111
Button: styled(Button)`
1212
margin-left: 8px;
13+
font-size: 18px;
14+
padding: 2px 0 !important;
1315
`,
1416
};
1517

src/components/designer/bitcoind/BitcoindDetails.spec.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('BitcoindDetails', () => {
2121
},
2222
bitcoind: {
2323
nodes: {
24-
backend1: {},
24+
'1-backend1': {},
2525
},
2626
},
2727
};

src/components/designer/bitcoind/BitcoindDetails.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import SidebarCard from '../SidebarCard';
1010
import ActionsTab from './ActionsTab';
1111
import ConnectTab from './ConnectTab';
1212
import InfoTab from './InfoTab';
13+
import { getNetworkBackendId } from 'utils/network';
1314

1415
const BitcoindDetails: React.FC<{ node: BitcoinNode }> = ({ node }) => {
1516
const { l } = usePrefixedTranslation('cmps.designer.bitcoind.BitcoinDetails');
@@ -26,7 +27,7 @@ const BitcoindDetails: React.FC<{ node: BitcoinNode }> = ({ node }) => {
2627
);
2728

2829
let extra: ReactNode | undefined;
29-
const nodeState = nodes[node.name];
30+
const nodeState = nodes[getNetworkBackendId(node)];
3031
if (node.status === Status.Started && nodeState && nodeState.walletInfo) {
3132
extra = <strong>{nodeState.walletInfo.balance} BTC</strong>;
3233
}

src/components/designer/bitcoind/InfoTab.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { dockerConfigs } from 'utils/constants';
77
import { ellipseInner } from 'utils/strings';
88
import { CopyIcon, DetailsList, StatusBadge } from 'components/common';
99
import { DetailValues } from 'components/common/DetailsList';
10+
import { getNetworkBackendId } from 'utils/network';
1011

1112
interface Props {
1213
node: BitcoinNode;
@@ -34,7 +35,7 @@ const InfoTab: React.FC<Props> = ({ node }) => {
3435
details.splice(3, 0, { label: l('customImage'), value: node.docker.image });
3536
}
3637

37-
const nodeState = nodes[node.name];
38+
const nodeState = nodes[getNetworkBackendId(node)];
3839
if (
3940
node.status === Status.Started &&
4041
nodeState &&

src/components/designer/bitcoind/actions/SendOnChainModal.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BitcoinNode } from 'shared/types';
66
import { useStoreActions, useStoreState } from 'store';
77
import { Network } from 'types';
88
import { format } from 'utils/units';
9+
import { getNetworkBackendId } from 'utils/network';
910

1011
interface Props {
1112
network: Network;
@@ -22,8 +23,9 @@ const SendOnChainModal: React.FC<Props> = ({ network }) => {
2223
const [selected, setSelected] = useState(backendName || '');
2324

2425
const balance: number = useMemo(() => {
25-
if (nodes && nodes[selected] && nodes[selected].walletInfo) {
26-
return nodes[selected].walletInfo?.balance || 0;
26+
const node = network.nodes.bitcoin.find(n => n.name === selected);
27+
if (nodes && node?.name && node?.networkId) {
28+
return nodes[getNetworkBackendId(node as BitcoinNode)]?.walletInfo?.balance || 0;
2729
}
2830
return 0;
2931
}, [selected, nodes, l]);

src/components/network/NetworkActions.spec.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('NetworkActions Component', () => {
2828
},
2929
bitcoind: {
3030
nodes: {
31-
backend1: {
31+
'1-backend1': {
3232
chainInfo: {
3333
blocks: 10,
3434
},

src/components/network/NetworkActions.tsx

+13-15
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import React, { ReactNode, useCallback } from 'react';
2-
import { useAsyncCallback } from 'react-async-hook';
31
import {
42
CloseOutlined,
53
ExportOutlined,
@@ -13,11 +11,15 @@ import {
1311
import styled from '@emotion/styled';
1412
import { Button, Divider, Dropdown, MenuProps, Tag } from 'antd';
1513
import { ButtonType } from 'antd/lib/button';
14+
import AutoMineButton from 'components/designer/AutoMineButton';
15+
import { useMiningAsync } from 'hooks/useMiningAsync';
16+
import SyncButton from 'components/designer/SyncButton';
1617
import { usePrefixedTranslation } from 'hooks';
18+
import React, { ReactNode, useCallback } from 'react';
1719
import { Status } from 'shared/types';
18-
import { useStoreActions, useStoreState } from 'store';
20+
import { useStoreState } from 'store';
1921
import { Network } from 'types';
20-
import SyncButton from 'components/designer/SyncButton';
22+
import { getNetworkBackendId } from 'utils/network';
2123

2224
const Styled = {
2325
Button: styled(Button)`
@@ -88,16 +90,11 @@ const NetworkActions: React.FC<Props> = ({
8890
const started = status === Status.Started;
8991
const { label, type, danger, icon } = config[status];
9092

91-
const nodeState = useStoreState(s => s.bitcoind.nodes[bitcoinNode.name]);
92-
const { notify } = useStoreActions(s => s.app);
93-
const { mine } = useStoreActions(s => s.bitcoind);
94-
const mineAsync = useAsyncCallback(async () => {
95-
try {
96-
await mine({ blocks: 1, node: bitcoinNode });
97-
} catch (error: any) {
98-
notify({ message: l('mineError'), error });
99-
}
100-
});
93+
const nodeState = useStoreState(
94+
s => s.bitcoind.nodes[getNetworkBackendId(bitcoinNode)],
95+
);
96+
97+
const mineAsync = useMiningAsync(network);
10198

10299
const handleClick: MenuProps['onClick'] = useCallback(info => {
103100
switch (info.key) {
@@ -121,7 +118,7 @@ const NetworkActions: React.FC<Props> = ({
121118

122119
return (
123120
<>
124-
{bitcoinNode.status === Status.Started && nodeState && nodeState.chainInfo && (
121+
{bitcoinNode.status === Status.Started && nodeState?.chainInfo && (
125122
<>
126123
<Tag>height: {nodeState.chainInfo.blocks}</Tag>
127124
<Button
@@ -131,6 +128,7 @@ const NetworkActions: React.FC<Props> = ({
131128
>
132129
{l('mineBtn')}
133130
</Button>
131+
<AutoMineButton network={network} />
134132
<SyncButton network={network} />
135133
<Divider type="vertical" />
136134
</>

0 commit comments

Comments
 (0)