@swapkit/wallet-hardware 4.9.6 → 4.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { HDKey } from "@scure/bip32";
1
2
  import {
2
3
  Chain,
3
4
  type DerivationPathArray,
@@ -13,6 +14,7 @@ import {
13
14
  import {
14
15
  assertDerivationIndex,
15
16
  createHDWalletHelpers,
17
+ deriveAddressesFromXpub,
16
18
  getNetworkForChain,
17
19
  getUTXOAccountIndexFromPath,
18
20
  getUTXOAccountPath,
@@ -24,6 +26,74 @@ import type { BTCNetwork, PCZT, Transaction, ZcashTransaction } from "@swapkit/u
24
26
  import { NETWORKS, ZcashConsensusBranchId, ZcashVersionGroupId } from "@swapkit/utxo-signer";
25
27
  import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core";
26
28
 
29
+ type TrezorBip32Derivation = [Uint8Array, { fingerprint: number; path: number[] }];
30
+ type TrezorCoreMode = "auto" | "iframe" | "popup" | "suite-desktop" | "suite-web";
31
+ type TrezorTransport = "BridgeTransport" | "WebUsbTransport" | "NodeUsbTransport";
32
+ type TrezorExtendedPublicKeyInfo = {
33
+ accountIndex: number;
34
+ chainCode?: string;
35
+ depth?: number;
36
+ fingerprint?: number;
37
+ path: string;
38
+ publicKey?: string;
39
+ xpub: string;
40
+ xpubSegwit?: string;
41
+ };
42
+
43
+ const TREZOR_CORE_MODES = new Set<TrezorCoreMode>(["auto", "iframe", "popup", "suite-desktop", "suite-web"]);
44
+ const TREZOR_TRANSPORTS = new Set<TrezorTransport>(["BridgeTransport", "WebUsbTransport", "NodeUsbTransport"]);
45
+ const DEFAULT_TREZOR_MANIFEST = { appName: "SwapKit", appUrl: "https://swapkit.dev", email: "support@swapkit.dev" };
46
+ const DEFAULT_TREZOR_TRANSPORTS = ["WebUsbTransport" as const];
47
+ const trezorXpubCache = new Map<string, TrezorExtendedPublicKeyInfo>();
48
+ let trezorSessionDispose: Promise<void> | undefined;
49
+
50
+ async function disconnectTrezorSession() {
51
+ trezorXpubCache.clear();
52
+
53
+ const dispose = (async () => {
54
+ try {
55
+ const TrezorConnect = (await import("@trezor/connect-web")).default;
56
+ await TrezorConnect.dispose();
57
+ } catch {
58
+ // Ignore stale or already-disposed sessions.
59
+ }
60
+ })();
61
+
62
+ trezorSessionDispose = dispose;
63
+ await dispose;
64
+
65
+ if (trezorSessionDispose === dispose) {
66
+ trezorSessionDispose = undefined;
67
+ }
68
+ }
69
+
70
+ function normalizeTrezorCoreMode(coreMode: unknown): TrezorCoreMode | undefined {
71
+ return typeof coreMode === "string" && TREZOR_CORE_MODES.has(coreMode as TrezorCoreMode)
72
+ ? (coreMode as TrezorCoreMode)
73
+ : undefined;
74
+ }
75
+
76
+ function normalizeTrezorTransports(transports: unknown): TrezorTransport[] | undefined {
77
+ if (!Array.isArray(transports)) return undefined;
78
+
79
+ const normalized = transports.filter(
80
+ (transport): transport is TrezorTransport =>
81
+ typeof transport === "string" && TREZOR_TRANSPORTS.has(transport as TrezorTransport),
82
+ );
83
+
84
+ return normalized.length > 0 ? normalized : undefined;
85
+ }
86
+
87
+ function getTrezorManifestValue(value: unknown, fallback: string) {
88
+ return typeof value === "string" && value.trim() ? value : fallback;
89
+ }
90
+
91
+ function getDefaultTrezorAppUrl() {
92
+ return typeof globalThis.location !== "undefined" && globalThis.location.origin
93
+ ? globalThis.location.origin
94
+ : DEFAULT_TREZOR_MANIFEST.appUrl;
95
+ }
96
+
27
97
  function decodeOpReturnData(script: Uint8Array): string | null {
28
98
  if (script.length < 2 || script[0] !== 0x6a) return null;
29
99
  const dataLen = script[1];
@@ -50,6 +120,48 @@ function hardenDerivationPath(derivationPath: DerivationPathArray): number[] {
50
120
  );
51
121
  }
52
122
 
123
+ function isTrezorBip32Derivation(value: unknown): value is TrezorBip32Derivation {
124
+ return (
125
+ Array.isArray(value) &&
126
+ value[0] instanceof Uint8Array &&
127
+ typeof value[1] === "object" &&
128
+ value[1] !== null &&
129
+ typeof (value[1] as { fingerprint?: unknown }).fingerprint === "number" &&
130
+ Array.isArray((value[1] as { path?: unknown }).path)
131
+ );
132
+ }
133
+
134
+ function getFirstBip32Derivation(input: { bip32Derivation?: unknown }): TrezorBip32Derivation | undefined {
135
+ if (!Array.isArray(input.bip32Derivation)) return undefined;
136
+ const [firstDerivation] = input.bip32Derivation;
137
+
138
+ return isTrezorBip32Derivation(firstDerivation) ? firstDerivation : undefined;
139
+ }
140
+
141
+ function getPrevoutAmount(input: {
142
+ index?: number;
143
+ nonWitnessUtxo?: { outputs?: Array<{ amount?: bigint | number }> };
144
+ witnessUtxo?: { amount?: bigint | number };
145
+ }) {
146
+ if (input.witnessUtxo?.amount !== undefined) return input.witnessUtxo.amount.toString();
147
+
148
+ const prevout = input.index !== undefined ? input.nonWitnessUtxo?.outputs?.[input.index] : undefined;
149
+ if (prevout?.amount !== undefined) return prevout.amount.toString();
150
+
151
+ return undefined;
152
+ }
153
+
154
+ function normalizeTrezorSignature(signatureHex: string) {
155
+ const signature = Buffer.from(signatureHex, "hex");
156
+ const derLength = signature[1] !== undefined ? signature[1] + 2 : undefined;
157
+
158
+ if (derLength !== undefined && signature.length === derLength) {
159
+ return new Uint8Array([...signature, 0x01]);
160
+ }
161
+
162
+ return new Uint8Array(signature);
163
+ }
164
+
53
165
  function buildPCZTInputsForTrezor(
54
166
  pczt: PCZT,
55
167
  address_n: number[],
@@ -206,8 +318,27 @@ function buildUtxoOutputsForTrezor(
206
318
  const outputAddress = tx.getOutputAddress(i, network);
207
319
 
208
320
  if (!outputAddress) {
209
- outputs.push({ amount: "0", op_return_data: Buffer.from(memo).toString("hex"), script_type: "PAYTOOPRETURN" });
210
- continue;
321
+ const opReturnData = output.script ? decodeOpReturnData(output.script) : null;
322
+ if (opReturnData !== null || memo) {
323
+ outputs.push({
324
+ amount: "0",
325
+ op_return_data: opReturnData ?? Buffer.from(memo).toString("hex"),
326
+ script_type: "PAYTOOPRETURN",
327
+ });
328
+ continue;
329
+ }
330
+
331
+ throw new SwapKitError({
332
+ errorKey: "wallet_trezor_failed_to_sign_transaction",
333
+ info: { chain, error: "Unable to decode output address from scriptPubkey" },
334
+ });
335
+ }
336
+
337
+ if (output.amount === undefined) {
338
+ throw new SwapKitError({
339
+ errorKey: "wallet_trezor_failed_to_sign_transaction",
340
+ info: { chain, error: "Output amount is missing" },
341
+ });
211
342
  }
212
343
 
213
344
  const isBch = chain === Chain.BitcoinCash;
@@ -218,8 +349,8 @@ function buildUtxoOutputsForTrezor(
218
349
 
219
350
  outputs.push(
220
351
  isChangeAddress
221
- ? { address_n, amount: Number(output.amount), script_type: scriptType.output }
222
- : { address: cashAddrWithPrefix, amount: Number(output.amount), script_type: "PAYTOADDRESS" },
352
+ ? { address_n, amount: output.amount.toString(), script_type: scriptType.output }
353
+ : { address: cashAddrWithPrefix, amount: output.amount.toString(), script_type: "PAYTOADDRESS" },
223
354
  );
224
355
  }
225
356
  return outputs;
@@ -434,7 +565,7 @@ async function getTrezorWallet<T extends Chain>({
434
565
  case Chain.Dash:
435
566
  case Chain.Dogecoin:
436
567
  case Chain.Litecoin: {
437
- const { toCashAddress, getUtxoToolbox } = await import("@swapkit/toolboxes/utxo");
568
+ const { toCashAddress, getUtxoToolbox, stripPrefix } = await import("@swapkit/toolboxes/utxo");
438
569
  const utxoChain = chain as UTXOChain;
439
570
  const scriptType = getScriptType(derivationPath);
440
571
 
@@ -446,7 +577,11 @@ async function getTrezorWallet<T extends Chain>({
446
577
 
447
578
  const getAddress = async (path: DerivationPathArray = derivationPath) => {
448
579
  const TrezorConnect = (await import("@trezor/connect-web")).default;
449
- const { success, payload } = await TrezorConnect.getAddress({ coin, path: derivationPathToString(path) });
580
+ const { success, payload } = await TrezorConnect.getAddress({
581
+ coin,
582
+ path: derivationPathToString(path),
583
+ showOnTrezor: false,
584
+ });
450
585
 
451
586
  if (!success) {
452
587
  throw new SwapKitError({
@@ -456,19 +591,57 @@ async function getTrezorWallet<T extends Chain>({
456
591
  }
457
592
 
458
593
  if (chain === Chain.BitcoinCash) {
459
- const toolbox = await getUtxoToolbox(chain as typeof Chain.BitcoinCash);
460
- return toolbox.stripPrefix(payload.address);
594
+ return stripPrefix(payload.address);
461
595
  }
462
596
 
463
597
  return payload.address;
464
598
  };
465
599
 
466
- const address = await getAddress();
600
+ async function getAddressFromExtendedPublicKey() {
601
+ const accountInfo = await getExtendedPublicKeyInfo();
602
+ const addressIndex = Number(derivationPath[4] ?? 0);
603
+ const change = Boolean(derivationPath[3] ?? 0);
604
+
605
+ try {
606
+ // deriveAddressesFromXpub returns both external and change branches for each index.
607
+ const derivedAddress = deriveAddressesFromXpub({
608
+ accountIndex: accountInfo.accountIndex,
609
+ chain: utxoChain,
610
+ count: 1,
611
+ startIndex: addressIndex,
612
+ xpub: accountInfo.xpub,
613
+ }).find((derived) => derived.change === change && derived.index === addressIndex);
614
+
615
+ if (!derivedAddress) {
616
+ throw new SwapKitError({
617
+ errorKey: "wallet_trezor_failed_to_get_address",
618
+ info: { chain, error: "Unable to derive address from Trezor account public key" },
619
+ });
620
+ }
621
+
622
+ return derivedAddress.address;
623
+ } catch (error) {
624
+ if (error instanceof SwapKitError) throw error;
625
+
626
+ throw new SwapKitError({
627
+ errorKey: "wallet_trezor_failed_to_get_address",
628
+ info: {
629
+ chain,
630
+ error: error instanceof Error ? error.message : "Unable to derive address from Trezor xpub",
631
+ },
632
+ });
633
+ }
634
+ }
635
+
636
+ const address =
637
+ chain === Chain.Bitcoin || chain === Chain.Litecoin
638
+ ? await getAddressFromExtendedPublicKey()
639
+ : await getAddress();
640
+ const baseToolbox = getUtxoToolbox(chain);
467
641
 
468
642
  const signTransaction = async (tx: Transaction, inputs: UTXOType[], memo = "") => {
469
643
  const TrezorConnect = (await import("@trezor/connect-web")).default;
470
644
  const address_n = hardenDerivationPath(derivationPath);
471
- const toolbox = getUtxoToolbox(chain as typeof Chain.BitcoinCash);
472
645
  const network = getNetworkForChain(chain as UTXOChain);
473
646
 
474
647
  const outputs = buildUtxoOutputsForTrezor(
@@ -480,7 +653,7 @@ async function getTrezorWallet<T extends Chain>({
480
653
  chain,
481
654
  scriptType,
482
655
  toCashAddress,
483
- toolbox.stripPrefix,
656
+ stripPrefix,
484
657
  );
485
658
 
486
659
  const trezorInputs = inputs.map(({ hash, index, value }) => ({
@@ -504,13 +677,108 @@ async function getTrezorWallet<T extends Chain>({
504
677
  });
505
678
  };
506
679
 
680
+ const signPsbtTransaction = async (tx: Transaction): Promise<Transaction> => {
681
+ const TrezorConnect = (await import("@trezor/connect-web")).default;
682
+ const { hex: hexEncode } = await import("@scure/base");
683
+ const address_n = hardenDerivationPath(derivationPath);
684
+ const network = getNetworkForChain(chain as UTXOChain);
685
+ let fallbackPublicKey: Uint8Array | undefined;
686
+
687
+ async function getFallbackDerivation(): Promise<TrezorBip32Derivation> {
688
+ if (!fallbackPublicKey) {
689
+ const accountInfo = await getExtendedPublicKeyInfo();
690
+ const accountKey = HDKey.fromExtendedKey(accountInfo.xpub);
691
+ const leaf = accountKey.derive(`m/${Number(derivationPath[3] ?? 0)}/${Number(derivationPath[4] ?? 0)}`);
692
+
693
+ if (!leaf.publicKey) {
694
+ throw new SwapKitError({
695
+ errorKey: "wallet_trezor_failed_to_get_public_key",
696
+ info: { chain, error: "Unable to derive Trezor leaf public key from account xpub" },
697
+ });
698
+ }
699
+
700
+ fallbackPublicKey = leaf.publicKey;
701
+ }
702
+
703
+ return [fallbackPublicKey, { fingerprint: 0, path: address_n }];
704
+ }
705
+
706
+ const signerPubkeys: Uint8Array[] = [];
707
+ const trezorInputs = [];
708
+
709
+ for (let inputIndex = 0; inputIndex < tx.inputsLength; inputIndex++) {
710
+ const input = tx.getInput(inputIndex);
711
+ const existingDerivation = getFirstBip32Derivation(input);
712
+ const derivation = existingDerivation ?? (await getFallbackDerivation());
713
+ const amount = getPrevoutAmount(input);
714
+
715
+ if (!input.txid || input.index === undefined || !amount) {
716
+ throw new SwapKitError({
717
+ errorKey: "wallet_trezor_failed_to_sign_transaction",
718
+ info: { chain, error: `Input ${inputIndex} is missing prevout data required by Trezor` },
719
+ });
720
+ }
721
+
722
+ signerPubkeys[inputIndex] = derivation[0];
723
+
724
+ if (!existingDerivation) {
725
+ tx.updateInput(inputIndex, { bip32Derivation: [derivation] });
726
+ }
727
+
728
+ trezorInputs.push({
729
+ address_n: derivation[1].path,
730
+ amount,
731
+ prev_hash: hexEncode.encode(input.txid),
732
+ prev_index: input.index,
733
+ script_type: scriptType.input,
734
+ ...(input.sequence !== undefined ? { sequence: input.sequence } : {}),
735
+ });
736
+ }
737
+
738
+ const outputs = buildUtxoOutputsForTrezor(
739
+ tx,
740
+ network,
741
+ address_n,
742
+ address,
743
+ "",
744
+ chain,
745
+ scriptType,
746
+ toCashAddress,
747
+ stripPrefix,
748
+ );
749
+
750
+ const result = await TrezorConnect.signTransaction({
751
+ coin,
752
+ inputs: trezorInputs,
753
+ locktime: tx.lockTime,
754
+ outputs,
755
+ version: tx.version,
756
+ });
757
+
758
+ if (!result.success) {
759
+ const payload = result.payload as { error?: string; code?: string };
760
+ throw new SwapKitError({
761
+ errorKey: "wallet_trezor_failed_to_sign_transaction",
762
+ info: { chain, code: payload?.code ?? "unknown", error: payload?.error ?? "unknown", payload },
763
+ });
764
+ }
765
+
766
+ result.payload.signatures.forEach((signatureHex, inputIndex) => {
767
+ const pubkey = signerPubkeys[inputIndex];
768
+ if (!(signatureHex && pubkey)) return;
769
+
770
+ tx.updateInput(inputIndex, { partialSig: [[pubkey, normalizeTrezorSignature(signatureHex)]] });
771
+ });
772
+
773
+ return tx;
774
+ };
775
+
507
776
  const signTransactionWithMultipleInputs = async (
508
777
  tx: Transaction,
509
778
  inputs: Array<{ hash: string; index: number; value: number; derivationIndex: number; isChange: boolean }>,
510
779
  memo = "",
511
780
  ) => {
512
781
  const TrezorConnect = (await import("@trezor/connect-web")).default;
513
- const toolbox = await getUtxoToolbox(chain as typeof Chain.BitcoinCash);
514
782
  const network = getNetworkForChain(chain as UTXOChain);
515
783
  const baseAddressN = hardenDerivationPath(derivationPath.slice(0, 3) as DerivationPathArray);
516
784
 
@@ -523,7 +791,7 @@ async function getTrezorWallet<T extends Chain>({
523
791
  chain,
524
792
  scriptType,
525
793
  toCashAddress,
526
- toolbox.stripPrefix,
794
+ stripPrefix,
527
795
  );
528
796
 
529
797
  const trezorInputs = inputs.map(({ hash, index: inputIndex, value, derivationIndex, isChange }) => {
@@ -629,12 +897,21 @@ async function getTrezorWallet<T extends Chain>({
629
897
  return txHash;
630
898
  };
631
899
 
632
- const toolbox = await getUtxoToolbox(chain);
900
+ const toolbox =
901
+ chain === Chain.Bitcoin || chain === Chain.Litecoin
902
+ ? await getUtxoToolbox(utxoChain, {
903
+ signer: { getAddress: async () => address, signTransaction: signPsbtTransaction },
904
+ })
905
+ : baseToolbox;
633
906
 
634
907
  async function getExtendedPublicKeyInfo({ accountIndex }: { accountIndex?: number } = {}) {
635
908
  const TrezorConnect = (await import("@trezor/connect-web")).default;
636
909
  const resolvedAccountPath = getUTXOAccountPath({ accountIndex, chain: utxoChain, derivationPath });
637
910
  const path = derivationPathToString(resolvedAccountPath);
911
+ const cacheKey = `${chain}:${path}`;
912
+ const cached = trezorXpubCache.get(cacheKey);
913
+ if (cached) return cached;
914
+
638
915
  const { success, payload } = await TrezorConnect.getPublicKey({ coin, path });
639
916
 
640
917
  if (!success) {
@@ -644,7 +921,7 @@ async function getTrezorWallet<T extends Chain>({
644
921
  });
645
922
  }
646
923
 
647
- return {
924
+ const info = {
648
925
  accountIndex: getUTXOAccountIndexFromPath(resolvedAccountPath),
649
926
  chainCode: payload.chainCode,
650
927
  depth: payload.depth,
@@ -654,10 +931,13 @@ async function getTrezorWallet<T extends Chain>({
654
931
  xpub: payload.xpub,
655
932
  xpubSegwit: payload.xpubSegwit,
656
933
  };
934
+
935
+ trezorXpubCache.set(cacheKey, info);
936
+ return info;
657
937
  }
658
938
 
659
- function getExtendedPublicKey() {
660
- return getExtendedPublicKeyInfo();
939
+ function getExtendedPublicKey(params: { accountIndex?: number } = {}) {
940
+ return getExtendedPublicKeyInfo(params);
661
941
  }
662
942
 
663
943
  async function deriveAddressAtIndex({
@@ -791,19 +1071,55 @@ export const trezorWallet = createWallet({
791
1071
  }
792
1072
 
793
1073
  const TrezorConnect = (await import("@trezor/connect-web")).default;
794
- const { success } = await TrezorConnect.getDeviceState();
795
-
796
- if (!success) {
797
- const trezorConfig = SKConfig.get("integrations").trezor;
798
- const manifest = trezorConfig
799
- ? { ...trezorConfig, appName: (trezorConfig as any).appName || "SwapKit" }
800
- : { appName: "SwapKit", appUrl: "", email: "" };
801
- TrezorConnect.init({ lazyLoad: true, manifest });
1074
+
1075
+ const trezorConfig = SKConfig.get("integrations").trezor as Record<string, unknown> | undefined;
1076
+ const {
1077
+ connectSrc,
1078
+ coreMode,
1079
+ debug,
1080
+ interactionTimeout,
1081
+ lazyLoad,
1082
+ pendingTransportEvent,
1083
+ popup,
1084
+ transportReconnect,
1085
+ transports,
1086
+ ...manifestConfig
1087
+ } = trezorConfig ?? {};
1088
+ const manifest = {
1089
+ ...manifestConfig,
1090
+ appName: getTrezorManifestValue(trezorConfig?.appName, DEFAULT_TREZOR_MANIFEST.appName),
1091
+ appUrl: getTrezorManifestValue(trezorConfig?.appUrl, getDefaultTrezorAppUrl()),
1092
+ email: getTrezorManifestValue(trezorConfig?.email, DEFAULT_TREZOR_MANIFEST.email),
1093
+ };
1094
+ const isLocalhost =
1095
+ typeof globalThis.location !== "undefined" && ["localhost", "127.0.0.1"].includes(globalThis.location.hostname);
1096
+ const resolvedCoreMode = normalizeTrezorCoreMode(coreMode) ?? "popup";
1097
+ const resolvedTransports = normalizeTrezorTransports(transports) ?? DEFAULT_TREZOR_TRANSPORTS;
1098
+
1099
+ if (trezorSessionDispose) {
1100
+ await trezorSessionDispose;
802
1101
  }
803
1102
 
1103
+ if (isLocalhost) {
1104
+ await TrezorConnect.dispose();
1105
+ }
1106
+
1107
+ await TrezorConnect.init({
1108
+ connectSrc: connectSrc as string | undefined,
1109
+ coreMode: resolvedCoreMode,
1110
+ debug: debug as boolean | undefined,
1111
+ interactionTimeout: interactionTimeout as number | undefined,
1112
+ lazyLoad: (lazyLoad as boolean | undefined) ?? false,
1113
+ manifest,
1114
+ pendingTransportEvent: pendingTransportEvent as boolean | undefined,
1115
+ popup: (popup as boolean | undefined) ?? true,
1116
+ transportReconnect: transportReconnect as boolean | undefined,
1117
+ transports: resolvedTransports,
1118
+ });
1119
+
804
1120
  const wallet = await getTrezorWallet({ chain, derivationPath });
805
1121
 
806
- addChain({ ...wallet, chain, walletType });
1122
+ addChain({ ...wallet, chain, disconnect: disconnectTrezorSession, walletType });
807
1123
 
808
1124
  return true;
809
1125
  },
@@ -814,13 +1130,15 @@ export const trezorWallet = createWallet({
814
1130
  [Chain.Base]: true,
815
1131
  [Chain.Berachain]: true,
816
1132
  [Chain.BinanceSmartChain]: true,
1133
+ [Chain.Bitcoin]: true,
817
1134
  [Chain.Ethereum]: true,
818
1135
  [Chain.Gnosis]: true,
1136
+ [Chain.Litecoin]: true,
819
1137
  [Chain.Monad]: true,
820
1138
  [Chain.Optimism]: true,
821
1139
  [Chain.Polygon]: true,
822
1140
  [Chain.XLayer]: true,
823
- // BTC/BCH/DASH/DOGE/LTC/ZEC: pending PSBT→TrezorConnect converter (V3 plan PR)
1141
+ // BCH/DASH/DOGE/ZEC: pending PSBT→TrezorConnect converter (V3 plan PR)
824
1142
  },
825
1143
  name: "connectTrezor",
826
1144
  supportedChains: [