Skip to content

Commit 7a0833e

Browse files
author
Yuejian
committed
chore: 1. 为硬件钱包添加 WalletConnect 链接功能。2. 支持两种签名方式。 3. 新增多语种配置 USB
1 parent cc2b2ab commit 7a0833e

File tree

12 files changed

+1583
-101
lines changed

12 files changed

+1583
-101
lines changed

packages/common/src/locale/en_US.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const localeValues: RequiredLocale = {
4242
guideTipTitle: 'New to crypto wallets?',
4343
guideTipLearnMoreLinkText: 'Learn More',
4444
walletPanelPlugin: 'PLUGIN',
45+
walletPanelPluginHardware: 'USB',
4546
walletListEmpty: 'No wallet available',
4647
walletConnectSuccess: 'Wallet Connected!',
4748
getWalletTipsTitle: "Not what you're looking for?",

packages/common/src/locale/zh_HK.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const localeValues: RequiredLocale = {
4040
guideTipTitle: '第一次使用加密錢包?',
4141
guideTipLearnMoreLinkText: '了解更多',
4242
walletPanelPlugin: '插件',
43+
walletPanelPluginHardware: 'USB',
4344
walletListEmpty: '未發現任何錢包',
4445
walletConnectSuccess: '錢包已連接!',
4546
getWalletTipsTitle: '沒有找到你想要的?',

packages/common/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ export interface RequiredLocale {
307307
guideTipTitle: string;
308308
guideTipLearnMoreLinkText: string;
309309
walletPanelPlugin: string;
310+
walletPanelPluginHardware: string;
310311
walletConnectSuccess: string;
311312
walletListEmpty: string;
312313
getWalletTipsTitle: string;

packages/ledger/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@ledgerhq/device-management-kit": "^0.9.1",
4949
"@ledgerhq/device-signer-kit-ethereum": "^1.8.0",
5050
"@ledgerhq/device-transport-kit-web-hid": "^1.2.0",
51+
"@walletconnect/universal-provider": "^2.14.0",
5152
"rxjs": "^7.8.1"
5253
},
5354
"devDependencies": {

packages/ledger/src/ledger/errors.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ export type LedgerErrorCode =
88
| 'CANNOT_GET_ADDRESS'
99
| 'NO_SESSION'
1010
| 'SIGN_MESSAGE_FAILED'
11-
| 'SIGN_TYPED_DATA_FAILED';
11+
| 'SIGN_TYPED_DATA_FAILED'
12+
| 'WALLETCONNECT_NOT_CONFIGURED'
13+
| 'WALLETCONNECT_PROVIDER_NOT_AVAILABLE'
14+
| 'WALLETCONNECT_SESSION_NOT_AVAILABLE'
15+
| 'WALLETCONNECT_NO_ACCOUNTS'
16+
| 'WALLETCONNECT_INVALID_ACCOUNT'
17+
| 'WALLETCONNECT_CONNECTION_FAILED'
18+
| 'WALLETCONNECT_SESSION_TIMEOUT';
1219

1320
/**
1421
* Extended Error class with code and details

packages/ledger/src/ledger/index.tsx

Lines changed: 307 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Wallet } from '@ant-design/web3-common';
1+
import type { Account, Wallet } from '@ant-design/web3-common';
22
import { LedgerFilled } from '@ant-design/web3-icons';
33
import type { DeviceSessionId } from '@ledgerhq/device-management-kit';
44
import { DeviceStatus as DeviceStatusType } from '@ledgerhq/device-management-kit';
55

6-
import { LedgerAccount } from '../types';
6+
import type { LedgerAccount } from '../types';
77
import AppCommand from './AppCommand';
88
import AvailableDevices from './AvailableDevices';
99
import Connect from './Connect';
@@ -17,6 +17,12 @@ export class Ledger {
1717
icon: <LedgerFilled />,
1818
group: 'Hardware',
1919
remark: 'Ledger Hardware Wallet',
20+
app: {
21+
link: 'https://www.ledger.com/',
22+
},
23+
universalProtocol: {
24+
link: 'https://www.ledger.com/',
25+
},
2026
hasWalletReady: async () => true,
2127
};
2228

@@ -31,6 +37,10 @@ export class Ledger {
3137
public sessionId: DeviceSessionId | null = null;
3238
public accounts: LedgerAccount[] = [];
3339

40+
private _getWalletConnectProvider?: () => Promise<any>;
41+
private _walletConnectAccount?: Account;
42+
private _sessionDeleteHandler?: () => void;
43+
3444
constructor(name?: string, derivationPath?: string) {
3545
this.wallet.name = name || 'Ledger';
3646
this.derivationPath = derivationPath || "44'/60'/0'/0/0";
@@ -132,6 +142,12 @@ export class Ledger {
132142
};
133143

134144
public signMessage = async (message: string) => {
145+
// Check if using WalletConnect
146+
if (this._walletConnectAccount) {
147+
return this._signMessageWithWalletConnect(message);
148+
}
149+
150+
// Use hardware wallet signing
135151
if (!this.sessionId) {
136152
throw new LedgerError(
137153
'NO_SESSION',
@@ -146,6 +162,12 @@ export class Ledger {
146162
};
147163

148164
public signTypedData = async (typedData: any) => {
165+
// Check if using WalletConnect
166+
if (this._walletConnectAccount) {
167+
return this._signTypedDataWithWalletConnect(typedData);
168+
}
169+
170+
// Use hardware wallet signing
149171
if (!this.sessionId) {
150172
throw new LedgerError(
151173
'NO_SESSION',
@@ -162,4 +184,287 @@ export class Ledger {
162184
throw new LedgerError('SIGN_TYPED_DATA_FAILED', 'Failed to sign typed data');
163185
}
164186
};
187+
188+
public setWalletConnectProviderGetter = (providerGetter: () => Promise<any>) => {
189+
this._getWalletConnectProvider = providerGetter;
190+
};
191+
192+
public connectWalletConnect = async () => {
193+
if (!this._getWalletConnectProvider) {
194+
throw new LedgerError('WALLETCONNECT_NOT_CONFIGURED', 'WalletConnect is not configured');
195+
}
196+
197+
const provider = await this._getWalletConnectProvider();
198+
if (!provider) {
199+
throw new LedgerError(
200+
'WALLETCONNECT_PROVIDER_NOT_AVAILABLE',
201+
'WalletConnect provider not available',
202+
);
203+
}
204+
205+
try {
206+
// Check if there's an existing pairing (from getQrCode or previous connection)
207+
const hasPairing = provider.client?.session?.map?.size > 0;
208+
209+
// Connect to WalletConnect
210+
// If hasPairing is true, skipPairing will make connect() return immediately with existing session
211+
// If hasPairing is false, connect() will wait for mobile wallet to confirm
212+
await provider.connect({
213+
namespaces: {
214+
eip155: {
215+
methods: [
216+
'eth_sendTransaction',
217+
'eth_signTransaction',
218+
'eth_sign',
219+
'eth_signTypedData',
220+
'personal_sign',
221+
],
222+
chains: ['eip155:1'], // Mainnet, can be extended
223+
events: ['chainChanged', 'accountsChanged'],
224+
},
225+
},
226+
skipPairing: hasPairing,
227+
});
228+
229+
// Get session after connection (mobile wallet confirmed or existing session used)
230+
const session = provider.session;
231+
if (!session) {
232+
throw new LedgerError(
233+
'WALLETCONNECT_SESSION_NOT_AVAILABLE',
234+
'WalletConnect session not available',
235+
);
236+
}
237+
238+
// Listen for session_delete event to handle remote disconnection
239+
this._sessionDeleteHandler = () => {
240+
this._walletConnectAccount = undefined;
241+
};
242+
provider.on('session_delete', this._sessionDeleteHandler);
243+
244+
const accounts = session.namespaces.eip155?.accounts || [];
245+
if (accounts.length === 0) {
246+
throw new LedgerError(
247+
'WALLETCONNECT_NO_ACCOUNTS',
248+
'No accounts found in WalletConnect session',
249+
);
250+
}
251+
252+
// Extract address from account string (format: eip155:1:0x...)
253+
const address = accounts[0].split(':')[2];
254+
if (!address) {
255+
throw new LedgerError(
256+
'WALLETCONNECT_INVALID_ACCOUNT',
257+
'Invalid account format from WalletConnect',
258+
);
259+
}
260+
261+
this._walletConnectAccount = {
262+
address,
263+
};
264+
265+
return this._walletConnectAccount;
266+
} catch (error: any) {
267+
if (error instanceof LedgerError) {
268+
throw error;
269+
}
270+
throw new LedgerError(
271+
'WALLETCONNECT_CONNECTION_FAILED',
272+
error?.message || 'Failed to connect via WalletConnect',
273+
);
274+
}
275+
};
276+
277+
public disconnectWalletConnect = async () => {
278+
if (this._getWalletConnectProvider) {
279+
try {
280+
const provider = await this._getWalletConnectProvider();
281+
if (provider) {
282+
// Remove session_delete listener
283+
if (this._sessionDeleteHandler) {
284+
provider.off('session_delete', this._sessionDeleteHandler);
285+
this._sessionDeleteHandler = undefined;
286+
}
287+
288+
if (provider.session) {
289+
await provider.disconnect();
290+
}
291+
}
292+
} catch {
293+
// Ignore disconnect errors
294+
}
295+
}
296+
this._walletConnectAccount = undefined;
297+
};
298+
299+
public getWalletConnectAccount = (): Account | undefined => {
300+
return this._walletConnectAccount;
301+
};
302+
303+
/**
304+
* Sign message using WalletConnect
305+
* @private
306+
*/
307+
private _signMessageWithWalletConnect = async (message: string): Promise<any> => {
308+
if (!this._getWalletConnectProvider) {
309+
throw new LedgerError('WALLETCONNECT_NOT_CONFIGURED', 'WalletConnect is not configured');
310+
}
311+
312+
if (!this._walletConnectAccount?.address) {
313+
throw new LedgerError('WALLETCONNECT_NO_ACCOUNTS', 'No WalletConnect account available');
314+
}
315+
316+
try {
317+
const provider = await this._getWalletConnectProvider();
318+
if (!provider) {
319+
throw new LedgerError(
320+
'WALLETCONNECT_PROVIDER_NOT_AVAILABLE',
321+
'WalletConnect provider not available',
322+
);
323+
}
324+
325+
const session = provider.session;
326+
if (!session) {
327+
throw new LedgerError(
328+
'WALLETCONNECT_SESSION_NOT_AVAILABLE',
329+
'WalletConnect session not available',
330+
);
331+
}
332+
333+
// Get chain ID from session (format: eip155:1)
334+
const accounts = session.namespaces.eip155?.accounts || [];
335+
if (accounts.length === 0) {
336+
throw new LedgerError(
337+
'WALLETCONNECT_NO_ACCOUNTS',
338+
'No accounts found in WalletConnect session',
339+
);
340+
}
341+
342+
// Extract chain ID from account string (format: eip155:1:0x...)
343+
const chainId = accounts[0].split(':')[1];
344+
const chain = chainId ? `eip155:${chainId}` : undefined;
345+
346+
// Convert message to hex string if it's not already
347+
let messageHex: string;
348+
if (message.startsWith('0x')) {
349+
messageHex = message;
350+
} else {
351+
// Convert string to hex using TextEncoder for browser compatibility
352+
const encoder = new TextEncoder();
353+
const bytes = encoder.encode(message);
354+
messageHex = `0x${Array.from(bytes)
355+
.map((byte) => byte.toString(16).padStart(2, '0'))
356+
.join('')}`;
357+
}
358+
359+
// Call personal_sign via WalletConnect
360+
const signature = (await provider.request(
361+
{
362+
method: 'personal_sign',
363+
params: [messageHex, this._walletConnectAccount.address],
364+
},
365+
chain,
366+
)) as string;
367+
368+
return signature;
369+
} catch (error: any) {
370+
if (error instanceof LedgerError) {
371+
throw error;
372+
}
373+
throw new LedgerError(
374+
'SIGN_MESSAGE_FAILED',
375+
error?.message || 'Failed to sign message via WalletConnect',
376+
);
377+
}
378+
};
379+
380+
/**
381+
* Sign typed data (EIP-712) using WalletConnect
382+
* @private
383+
*/
384+
private _signTypedDataWithWalletConnect = async (typedData: any): Promise<any> => {
385+
if (!this._getWalletConnectProvider) {
386+
throw new LedgerError('WALLETCONNECT_NOT_CONFIGURED', 'WalletConnect is not configured');
387+
}
388+
389+
if (!this._walletConnectAccount?.address) {
390+
throw new LedgerError('WALLETCONNECT_NO_ACCOUNTS', 'No WalletConnect account available');
391+
}
392+
393+
try {
394+
const provider = await this._getWalletConnectProvider();
395+
if (!provider) {
396+
throw new LedgerError(
397+
'WALLETCONNECT_PROVIDER_NOT_AVAILABLE',
398+
'WalletConnect provider not available',
399+
);
400+
}
401+
402+
const session = provider.session;
403+
if (!session) {
404+
throw new LedgerError(
405+
'WALLETCONNECT_SESSION_NOT_AVAILABLE',
406+
'WalletConnect session not available',
407+
);
408+
}
409+
410+
// Get chain ID from session (format: eip155:1)
411+
const accounts = session.namespaces.eip155?.accounts || [];
412+
if (accounts.length === 0) {
413+
throw new LedgerError(
414+
'WALLETCONNECT_NO_ACCOUNTS',
415+
'No accounts found in WalletConnect session',
416+
);
417+
}
418+
419+
// Extract chain ID from account string (format: eip155:1:0x...)
420+
const chainId = accounts[0].split(':')[1];
421+
const chain = chainId ? `eip155:${chainId}` : undefined;
422+
423+
// Ensure chainId in domain is a number (not string) for proper serialization
424+
const normalizedTypedData = {
425+
...typedData,
426+
domain: {
427+
...typedData.domain,
428+
chainId:
429+
typeof typedData.domain?.chainId === 'string'
430+
? parseInt(typedData.domain.chainId, 10)
431+
: typedData.domain?.chainId,
432+
},
433+
};
434+
435+
// Call eth_signTypedData via WalletConnect
436+
// Note: WalletConnect v2 expects the second parameter to be a JSON string
437+
// Some wallets may also support eth_signTypedData_v4
438+
let signature: string;
439+
try {
440+
// Try eth_signTypedData_v4 first (preferred for WalletConnect v2)
441+
signature = (await provider.request(
442+
{
443+
method: 'eth_signTypedData_v4',
444+
params: [this._walletConnectAccount.address, JSON.stringify(normalizedTypedData)],
445+
},
446+
chain,
447+
)) as string;
448+
} catch (error: any) {
449+
// Fallback to eth_signTypedData if v4 is not supported
450+
signature = (await provider.request(
451+
{
452+
method: 'eth_signTypedData',
453+
params: [this._walletConnectAccount.address, JSON.stringify(normalizedTypedData)],
454+
},
455+
chain,
456+
)) as string;
457+
}
458+
459+
return signature;
460+
} catch (error: any) {
461+
if (error instanceof LedgerError) {
462+
throw error;
463+
}
464+
throw new LedgerError(
465+
'SIGN_TYPED_DATA_FAILED',
466+
error?.message || 'Failed to sign typed data via WalletConnect',
467+
);
468+
}
469+
};
165470
}

0 commit comments

Comments
 (0)