Skip to content

Commit 0174d9b

Browse files
authored
feat: import multisig wallets by Subscan address lookup (#249)
* feat: import multisig wallets by verified Subscan address lookup * fix: prevent long wallet addresses from distorting list layout
1 parent 6f15be8 commit 0174d9b

8 files changed

Lines changed: 379 additions & 25 deletions

File tree

‎public/locales/en/translation.json‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,5 +261,22 @@
261261
"Invalid account address": "Invalid account address",
262262
"Check the address in this link and try again": "Check the address in this link and try again",
263263
"Back to wallets": "Back to wallets",
264-
"Unable to load wallet": "Unable to load wallet. Check the network and retry."
264+
"Unable to load wallet": "Unable to load wallet. Check the network and retry.",
265+
"importWallet": {
266+
"title": "Import by address",
267+
"network": "Network: {{network}}",
268+
"scope": "Searches the selected network using Subscan. This does not search every network. Importing only saves the wallet configuration in this browser.",
269+
"address": "Multisig address",
270+
"lookup": "Find wallet",
271+
"threshold": "Threshold: {{threshold}} of {{count}} members",
272+
"name": "Wallet name (optional)",
273+
"confirm": "Import wallet",
274+
"manual": "Not found? Enter members and threshold manually",
275+
"unsupported": "Subscan address lookup is unavailable on this network. Enter members and threshold manually.",
276+
"notFound": "No indexed multisig configuration was found on this network. Try the network where the wallet was used, or enter members and threshold manually.",
277+
"apiKeyRequired": "Subscan requires a valid API key. Configure it in API key settings, then retry.",
278+
"requestFailed": "Unable to retrieve the wallet configuration. Check your API key and network connection, then retry.",
279+
"mismatch": "The returned members and threshold do not match this multisig address. Import was blocked.",
280+
"member": "Member"
281+
}
265282
}

‎public/locales/zh/translation.json‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,5 +260,22 @@
260260
"Invalid account address": "账户地址无效",
261261
"Check the address in this link and try again": "请检查链接中的账户地址后重试",
262262
"Back to wallets": "返回钱包列表",
263-
"Unable to load wallet": "无法加载钱包,请检查网络后重试"
263+
"Unable to load wallet": "无法加载钱包,请检查网络后重试",
264+
"importWallet": {
265+
"title": "通过地址导入",
266+
"network": "查询网络:{{network}}",
267+
"scope": "通过 Subscan 查询当前网络,不会搜索所有网络。导入仅将钱包配置保存到本浏览器。",
268+
"address": "多签地址",
269+
"lookup": "查找钱包",
270+
"threshold": "阈值:{{count}} 位成员中需要 {{threshold}} 位批准",
271+
"name": "钱包名称(可选)",
272+
"confirm": "导入钱包",
273+
"manual": "查不到?手动填写成员和阈值",
274+
"unsupported": "当前网络不支持 Subscan 地址查询,请手动填写成员和阈值。",
275+
"notFound": "当前网络未找到已索引的多签配置。请切换到钱包曾使用的网络,或手动填写成员和阈值。",
276+
"apiKeyRequired": "Subscan 需要有效的 API Key,请配置后重试。",
277+
"requestFailed": "无法获取钱包配置,请检查 API Key 和网络连接后重试。",
278+
"mismatch": "返回的成员和阈值与该多签地址不匹配,已阻止导入。",
279+
"member": "成员"
280+
}
264281
}

‎src/components/ImportWallet.tsx‎

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/* eslint-disable complexity */
2+
import { Alert, Button, Input, Modal, message } from 'antd';
3+
import keyring from '@polkadot/ui-keyring';
4+
import { encodeAddress } from '@polkadot/util-crypto';
5+
import { useEffect, useRef, useState } from 'react';
6+
import { useTranslation } from 'react-i18next';
7+
import { useHistory } from 'react-router-dom';
8+
import { useApi } from '../hooks';
9+
import axiosRequest from '../hooks/AxiosRequest';
10+
import { ShareScope } from '../model';
11+
import { findLocalMultisig, updateMultiAccountScope } from '../utils';
12+
import {
13+
ImportedMultisig,
14+
MultisigSearchResponse,
15+
parseMultisigSearch,
16+
verifyMultisig,
17+
} from '../utils/helper/importMultisig';
18+
import { ApiKeyModal } from './modals/ApiKeyModal';
19+
20+
export function ImportWallet() {
21+
const { network, networkConfig, api, chain } = useApi();
22+
// Changing networks discards the preview and invalidates any outstanding lookup.
23+
return (
24+
<ImportWalletForm
25+
key={`${network}:${networkConfig?.api?.subscan}:${api?.genesisHash.toHex()}:${chain.ss58Format}`}
26+
/>
27+
);
28+
}
29+
30+
function ImportWalletForm() {
31+
const { t } = useTranslation();
32+
const history = useHistory();
33+
const { api, chain, network, networkConfig } = useApi();
34+
const [visible, setVisible] = useState(false);
35+
const [apiKeyVisible, setApiKeyVisible] = useState(false);
36+
const [address, setAddress] = useState('');
37+
const [name, setName] = useState('');
38+
const [result, setResult] = useState<ImportedMultisig | null>(null);
39+
const [error, setError] = useState('');
40+
const [loading, setLoading] = useState(false);
41+
const generation = useRef(0);
42+
useEffect(
43+
() => () => {
44+
generation.current++;
45+
},
46+
[]
47+
);
48+
49+
const reset = () => {
50+
generation.current++;
51+
setResult(null);
52+
setError('');
53+
setLoading(false);
54+
};
55+
const lookup = async () => {
56+
reset();
57+
const request = generation.current;
58+
let normalized: string;
59+
try {
60+
normalized = encodeAddress(address.trim(), Number(chain.ss58Format));
61+
} catch {
62+
setError('You must input a ss58 format address');
63+
return;
64+
}
65+
if (!networkConfig?.api?.subscan) {
66+
setError('importWallet.unsupported');
67+
return;
68+
}
69+
setLoading(true);
70+
try {
71+
const { data } = await axiosRequest.post<MultisigSearchResponse['data']>(
72+
`${networkConfig.api.subscan}/api/v2/scan/search`,
73+
{ key: normalized }
74+
);
75+
const recovered = parseMultisigSearch(normalized, data);
76+
if (generation.current !== request) return;
77+
if (!recovered) {
78+
setError('importWallet.notFound');
79+
return;
80+
}
81+
verifyMultisig(
82+
recovered,
83+
api?.consts.multisig?.maxSignatories ? Number(api.consts.multisig.maxSignatories.toString()) : undefined
84+
);
85+
setResult({ ...recovered, id: normalized });
86+
} catch (caught) {
87+
if (generation.current !== request) return;
88+
const response = (caught as { response?: { status?: number; data?: { code?: number } } }).response;
89+
const status = response?.data?.code || response?.status;
90+
// eslint-disable-next-line no-magic-numbers
91+
setError(
92+
status === 401 || status === 403
93+
? 'importWallet.apiKeyRequired'
94+
: caught instanceof Error && caught.message.startsWith('importWallet.')
95+
? caught.message
96+
: 'importWallet.requestFailed'
97+
);
98+
} finally {
99+
if (generation.current === request) setLoading(false);
100+
}
101+
};
102+
const save = () => {
103+
if (!result || !api) return;
104+
try {
105+
verifyMultisig(
106+
result,
107+
api.consts.multisig?.maxSignatories ? Number(api.consts.multisig.maxSignatories.toString()) : undefined
108+
);
109+
const existing = findLocalMultisig(result.id);
110+
if (existing && !existing.meta.isMultisig) throw new Error('importWallet.mismatch');
111+
if (!existing || existing.meta.isTemp) {
112+
const members = result.members.map((member, index) => ({
113+
address: encodeAddress(member, Number(chain.ss58Format)),
114+
name: `${t('importWallet.member')} ${index + 1}`,
115+
}));
116+
const walletName = name.trim() || `${result.id.slice(0, 6)}…${result.id.slice(-6)}`;
117+
keyring.addMultisig(result.members, result.threshold, {
118+
name: walletName,
119+
addressPair: members,
120+
genesisHash: api.genesisHash.toHex(),
121+
isTemp: false,
122+
});
123+
updateMultiAccountScope(
124+
{
125+
name: walletName,
126+
members,
127+
threshold: result.threshold,
128+
share: ShareScope.current,
129+
rememberExternal: false,
130+
},
131+
network
132+
);
133+
}
134+
message.success(t('success'));
135+
setVisible(false);
136+
history.push(`/account/${result.id}${history.location.hash}`);
137+
} catch (caught) {
138+
setError(caught instanceof Error ? caught.message : 'importWallet.requestFailed');
139+
}
140+
};
141+
142+
return (
143+
<>
144+
<Button
145+
onClick={() => {
146+
reset();
147+
setVisible(true);
148+
}}
149+
>
150+
{t('importWallet.title')}
151+
</Button>
152+
<Modal
153+
title={t('importWallet.title')}
154+
visible={visible}
155+
onCancel={() => {
156+
reset();
157+
setVisible(false);
158+
}}
159+
footer={null}
160+
destroyOnClose
161+
>
162+
<p>{t('importWallet.network', { network: networkConfig?.displayName || network })}</p>
163+
<Alert type="info" showIcon message={t('importWallet.scope')} className="mb-4" />
164+
<label htmlFor="import-wallet-address">{t('importWallet.address')}</label>
165+
<Input
166+
id="import-wallet-address"
167+
value={address}
168+
onChange={(event) => {
169+
reset();
170+
setAddress(event.target.value);
171+
}}
172+
onPressEnter={() => !loading && lookup()}
173+
className="mb-4"
174+
/>
175+
<Button loading={loading} onClick={lookup} disabled={!address.trim()}>
176+
{t('importWallet.lookup')}
177+
</Button>
178+
<Button type="link" onClick={() => setApiKeyVisible(true)}>
179+
{t('api_key.title')}
180+
</Button>
181+
{error && <Alert className="mt-4" type="error" showIcon message={t(error)} />}
182+
{result && (
183+
<div className="mt-4">
184+
<p>{t('importWallet.threshold', { threshold: result.threshold, count: result.members.length })}</p>
185+
<ul className="mb-4">
186+
{result.members.map((member) => (
187+
<li key={member} className="break-all py-1">
188+
{encodeAddress(member, Number(chain.ss58Format))}
189+
</li>
190+
))}
191+
</ul>
192+
<label htmlFor="import-wallet-name">{t('importWallet.name')}</label>
193+
<Input
194+
id="import-wallet-name"
195+
value={name}
196+
onChange={(event) => setName(event.target.value)}
197+
className="mb-4"
198+
/>
199+
<Button type="primary" onClick={save}>
200+
{t('importWallet.confirm')}
201+
</Button>
202+
</div>
203+
)}
204+
<p className="mt-4">
205+
<a href={`/wallet${history.location.hash}`}>{t('importWallet.manual')}</a>
206+
</p>
207+
</Modal>
208+
<ApiKeyModal visible={apiKeyVisible} onCancel={() => setApiKeyVisible(false)} />
209+
</>
210+
);
211+
}

‎src/components/Wallets.tsx‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
updateMultiAccountScopeFromKey,
2727
} from '../utils';
2828
import { genExpandMembersIcon } from './expandIcon';
29+
import { ImportWallet } from './ImportWallet';
2930
import { MemberList } from './Members';
3031
import { SubscanLink } from './SubscanLink';
3132

@@ -219,7 +220,12 @@ export function Wallets() {
219220
};
220221

221222
const renderAddress = (address: string) => (
222-
<Link to={Path.extrinsic + '/' + address + history.location.hash} style={{ color: linkColor }}>
223+
<Link
224+
to={Path.extrinsic + '/' + address + history.location.hash}
225+
style={{ color: linkColor }}
226+
title={address}
227+
className="wallet-address-link"
228+
>
223229
{address}
224230
</Link>
225231
);
@@ -259,22 +265,29 @@ export function Wallets() {
259265
{
260266
title: t('name'),
261267
dataIndex: ['meta', 'name'],
268+
width: 200,
269+
ellipsis: true,
262270
},
263271
{
264272
title: t('address'),
265273
dataIndex: 'address',
274+
width: 330,
266275
render: renderAddress,
267276
},
268277
{
269278
title: t('balance'),
270279
key: 'balance',
280+
width: 220,
281+
className: 'wallet-nowrap',
271282
render: (account) => {
272283
return <Space direction="vertical">{renderBalances(account, chain)}</Space>;
273284
},
274285
},
275286
{
276287
title: t('status.index'),
277288
key: 'status',
289+
width: 120,
290+
className: 'wallet-nowrap',
278291
render: (_, record) => {
279292
const {
280293
meta: { addressPair },
@@ -289,6 +302,8 @@ export function Wallets() {
289302
// title: t('actions'),
290303
title: '',
291304
key: 'action',
305+
width: 110,
306+
className: 'wallet-nowrap',
292307
render: (_1: unknown, row) => renderAction(row),
293308
},
294309
];
@@ -386,6 +401,9 @@ export function Wallets() {
386401
</Button>
387402
</Link>
388403

404+
<div className="my-3">
405+
<ImportWallet />
406+
</div>
389407
<div className="my-1">{t('or')}</div>
390408

391409
<Upload {...uploadProps} showUploadList={false}>
@@ -399,7 +417,8 @@ export function Wallets() {
399417
return (
400418
<Space direction="vertical" className="wallet-list" id="wallets">
401419
<div className="flex flex-col md:justify-between md:flex-row">
402-
<div className="flex items-center">
420+
<div className="flex flex-wrap items-center gap-3">
421+
<ImportWallet />
403422
<Link to={Path.wallet + history.location.hash}>
404423
<Button type="primary" className="w-44">
405424
{t('wallet.add')}
@@ -445,7 +464,14 @@ export function Wallets() {
445464
columns={columns}
446465
dataSource={displayMultisigAccounts}
447466
rowKey="address"
448-
expandable={{ expandedRowRender, expandIcon: genExpandMembersIcon(t('members')), expandIconColumnIndex: 4 }}
467+
tableLayout="fixed"
468+
scroll={{ x: 1060 }}
469+
expandable={{
470+
expandedRowRender,
471+
expandIcon: genExpandMembersIcon(t('members')),
472+
expandIconColumnIndex: 4,
473+
columnWidth: 80,
474+
}}
449475
pagination={false}
450476
loading={isCalculating}
451477
className="lg:block hidden multisig-list-table"

‎src/hooks/subscan.ts‎

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { useCallback, useState } from 'react';
33
import { MultisigRecordsQueryRes } from 'src/components/ExtrinsicRecords';
44
import { NetConfigV2 } from 'src/model';
5+
import { parseMultisigSearch, MultisigSearchResponse } from '../utils/helper/importMultisig';
56
import axiosRequest from './AxiosRequest';
67
import { MultisigAccountDetailResult, MultisigCountsResult, MultisigRecordCountResult } from './combineQuery';
78

@@ -27,26 +28,13 @@ export const useMultisigAccountDetail = (network: NetConfigV2 | undefined) => {
2728

2829
SetLoading(true);
2930
try {
30-
const { data } = await axiosRequest.post<{
31-
account: {
32-
multisig: {
33-
multi_account_member: {
34-
address: string;
35-
}[];
36-
threshold: number;
37-
};
38-
};
39-
}>(`${network.api?.subscan}/api/v2/scan/search`, {
40-
key: account,
41-
});
42-
SetUserInfo({
43-
queriedAccount: account,
44-
multisigAccount: {
45-
id: account,
46-
threshold: data.data.account.multisig.threshold,
47-
members: data.data.account.multisig.multi_account_member.map((o) => o.address),
48-
},
49-
});
31+
const { data } = await axiosRequest.post<MultisigSearchResponse['data']>(
32+
`${network.api?.subscan}/api/v2/scan/search`,
33+
{
34+
key: account,
35+
}
36+
);
37+
SetUserInfo({ queriedAccount: account, multisigAccount: parseMultisigSearch(account, data) });
5038
SetLoading(false);
5139
} catch (error) {
5240
SetUserInfo({ multisigAccount: null, failed: true, queriedAccount: account });

0 commit comments

Comments
 (0)