From 2873855f6b3b5019cdebac650cbe2282e7c210ba Mon Sep 17 00:00:00 2001
From: "Felix C. Morency" <1102868+fmorency@users.noreply.github.com>
Date: Fri, 10 Jan 2025 09:26:04 -0500
Subject: [PATCH] fix: ledger wallet

- Fixes the missing `getOfflineSigner` implementation
- Fixes `StdSignDoc` serialization to JSON. The object MUST be
  serialized in lexicographical key order
- Replace the encoding of `pubkey` in `getAccount` from a `TextEncoder`
  to using the `fromHex` function.
---
 wallets/ledger/package.json                   | 12 ++---
 wallets/ledger/src/index.ts                   |  2 +-
 .../ledger/src/web-usb-hid/chain-wallet.ts    |  4 +-
 wallets/ledger/src/web-usb-hid/client.ts      | 44 +++++++++++++++++--
 wallets/ledger/src/web-usb-hid/main-wallet.ts |  8 ++--
 wallets/ledger/src/web-usb-hid/registry.ts    |  1 +
 wallets/ledger/src/web-usb-hid/utils.ts       | 22 +++++-----
 yarn.lock                                     | 31 +++++++++++--
 8 files changed, 94 insertions(+), 30 deletions(-)

diff --git a/wallets/ledger/package.json b/wallets/ledger/package.json
index d5c20a6f6..ff45a4c48 100644
--- a/wallets/ledger/package.json
+++ b/wallets/ledger/package.json
@@ -66,12 +66,14 @@
   },
   "dependencies": {
     "@cosmos-kit/core": "^2.15.1",
-    "@ledgerhq/hw-app-cosmos": "^6.28.1",
-    "@ledgerhq/hw-transport-webhid": "^6.27.15",
-    "@ledgerhq/hw-transport-webusb": "^6.27.15"
+    "@ledgerhq/hw-app-cosmos": "^6.30.4",
+    "@ledgerhq/hw-transport-webhid": "^6.30.0",
+    "@ledgerhq/hw-transport-webusb": "^6.29.4"
   },
   "peerDependencies": {
-    "@cosmjs/amino": ">=0.32.3",
-    "@cosmjs/proto-signing": ">=0.32.3"
+    "@cosmjs/amino": ">=0.32.4",
+    "@cosmjs/crypto": ">=0.32.4",
+    "@cosmjs/encoding": ">=0.32.4",
+    "@cosmjs/proto-signing": ">=0.32.4"
   }
 }
diff --git a/wallets/ledger/src/index.ts b/wallets/ledger/src/index.ts
index 2037bc16b..a1b9991fb 100644
--- a/wallets/ledger/src/index.ts
+++ b/wallets/ledger/src/index.ts
@@ -1,3 +1,3 @@
+export * from './constant';
 export * from './ledger';
 export * from './web-usb-hid/registry';
-export * from './constant';
diff --git a/wallets/ledger/src/web-usb-hid/chain-wallet.ts b/wallets/ledger/src/web-usb-hid/chain-wallet.ts
index e62e83e16..723645ea1 100644
--- a/wallets/ledger/src/web-usb-hid/chain-wallet.ts
+++ b/wallets/ledger/src/web-usb-hid/chain-wallet.ts
@@ -1,7 +1,7 @@
 import { ChainRecord, ChainWalletBase, Wallet } from '@cosmos-kit/core';
 
-export class LedgerChianWallet extends ChainWalletBase {
+export class LedgerChainWallet extends ChainWalletBase {
   constructor(walletInfo: Wallet, chainInfo: ChainRecord) {
     super(walletInfo, chainInfo);
   }
-}
\ No newline at end of file
+}
diff --git a/wallets/ledger/src/web-usb-hid/client.ts b/wallets/ledger/src/web-usb-hid/client.ts
index a3bffa7a4..856ec07c9 100644
--- a/wallets/ledger/src/web-usb-hid/client.ts
+++ b/wallets/ledger/src/web-usb-hid/client.ts
@@ -1,9 +1,17 @@
-import { StdSignDoc } from '@cosmjs/amino';
+import {
+  encodeSecp256k1Signature,
+  OfflineAminoSigner,
+  StdSignDoc,
+} from '@cosmjs/amino';
+import { sortedJsonStringify } from '@cosmjs/amino/build/signdoc';
+import { Secp256k1Signature } from '@cosmjs/crypto';
+import { fromHex } from '@cosmjs/encoding';
 import { Algo } from '@cosmjs/proto-signing';
-import { WalletClient } from '@cosmos-kit/core';
+import { SignType, WalletClient } from '@cosmos-kit/core';
 import Cosmos from '@ledgerhq/hw-app-cosmos';
 
 import { ChainIdToBech32Prefix, getCosmosApp, getCosmosPath } from './utils';
+
 export class LedgerClient implements WalletClient {
   client: Cosmos;
 
@@ -39,16 +47,44 @@ export class LedgerClient implements WalletClient {
       username: username ?? path,
       address,
       algo: 'secp256k1' as Algo,
-      pubkey: new TextEncoder().encode(publicKey),
+      pubkey: fromHex(publicKey),
       isNanoLedger: true,
     };
   }
 
+  getOfflineSigner(chainId: string, preferredSignType?: SignType) {
+    // Ledger doesn't support direct sign, only Amino sign
+    if (preferredSignType === 'direct') {
+      throw new Error('Unsupported sign type: direct');
+    }
+    return this.getOfflineSignerAmino(chainId);
+  }
+
+  getOfflineSignerAmino(chainId: string): OfflineAminoSigner {
+    return {
+      getAccounts: async () => {
+        return [await this.getAccount(chainId)];
+      },
+      signAmino: async (_signerAddress, signDoc) => {
+        const { pubkey } = await this.getAccount(chainId);
+        const { signature: derSignature } = await this.sign(signDoc); // The signature is in DER format
+        const signature = Secp256k1Signature.fromDer(derSignature); // Convert the DER signature to fixed length (64 bytes)
+        return {
+          signed: signDoc,
+          signature: encodeSecp256k1Signature(
+            pubkey,
+            signature.toFixedLength()
+          ),
+        };
+      },
+    };
+  }
+
   async sign(signDoc: StdSignDoc, accountIndex = 0) {
     if (!this.client) await this.initClient();
     return await this.client.sign(
       getCosmosPath(accountIndex),
-      JSON.stringify(signDoc)
+      sortedJsonStringify(signDoc) // signDoc MUST be serialized in lexicographical key order
     );
   }
 }
diff --git a/wallets/ledger/src/web-usb-hid/main-wallet.ts b/wallets/ledger/src/web-usb-hid/main-wallet.ts
index 09cb7eea3..38d61252a 100644
--- a/wallets/ledger/src/web-usb-hid/main-wallet.ts
+++ b/wallets/ledger/src/web-usb-hid/main-wallet.ts
@@ -1,6 +1,6 @@
-import { EndpointOptions, Wallet } from '@cosmos-kit/core';
-import { MainWalletBase } from '@cosmos-kit/core';
-import { LedgerChianWallet } from './chain-wallet';
+import { EndpointOptions, MainWalletBase, Wallet } from '@cosmos-kit/core';
+
+import { LedgerChainWallet } from './chain-wallet';
 import { LedgerClient } from './client';
 import { TransportType } from './utils';
 
@@ -11,7 +11,7 @@ export class LedgerMainWallet extends MainWalletBase {
     preferredEndpoints?: EndpointOptions['endpoints'],
     transportType: TransportType = 'WebUSB'
   ) {
-    super(walletInfo, LedgerChianWallet);
+    super(walletInfo, LedgerChainWallet);
     this.preferredEndpoints = preferredEndpoints;
     this.transportType = transportType;
   }
diff --git a/wallets/ledger/src/web-usb-hid/registry.ts b/wallets/ledger/src/web-usb-hid/registry.ts
index b5bc6ebad..2b48b1743 100644
--- a/wallets/ledger/src/web-usb-hid/registry.ts
+++ b/wallets/ledger/src/web-usb-hid/registry.ts
@@ -1,4 +1,5 @@
 import { Wallet } from '@cosmos-kit/core';
+
 import { ICON } from '../constant';
 
 export const LedgerInfo: Wallet = {
diff --git a/wallets/ledger/src/web-usb-hid/utils.ts b/wallets/ledger/src/web-usb-hid/utils.ts
index 9b3600e75..928e129c9 100644
--- a/wallets/ledger/src/web-usb-hid/utils.ts
+++ b/wallets/ledger/src/web-usb-hid/utils.ts
@@ -1,25 +1,25 @@
-import { chains } from 'chain-registry'
-import Cosmos from "@ledgerhq/hw-app-cosmos";
+import Cosmos from '@ledgerhq/hw-app-cosmos';
+import TransportWebHID from '@ledgerhq/hw-transport-webhid';
 import TransportWebUSB from '@ledgerhq/hw-transport-webusb';
-import TransportWebHID from '@ledgerhq/hw-transport-webhid'
+import { chains } from 'chain-registry';
 
-export type TransportType = 'WebUSB' | 'WebHID'
+export type TransportType = 'WebUSB' | 'WebHID';
 
 export async function getCosmosApp(type: TransportType = 'WebUSB') {
   if (type === 'WebUSB') {
-    return new Cosmos(await TransportWebUSB.create())
+    return new Cosmos(await TransportWebUSB.create());
   }
   if (type === 'WebHID') {
-    return new Cosmos(await TransportWebHID.create())
+    return new Cosmos(await TransportWebHID.create());
   }
-  throw new Error(`Unknown transport type: ${type}`)
+  throw new Error(`Unknown transport type: ${type}`);
 }
 
 export function getCosmosPath(accountIndex = 0) {
-  return `44'/118'/${accountIndex}'/0/0`
+  return `44'/118'/${accountIndex}'/0/0`;
 }
 
-export const ChainIdToBech32Prefix = {} as { [k: string]: string }
+export const ChainIdToBech32Prefix = {} as { [k: string]: string };
 for (const chain of chains) {
-  ChainIdToBech32Prefix[chain.chain_id] = chain.bech32_prefix
-}
\ No newline at end of file
+  ChainIdToBech32Prefix[chain.chain_id] = chain.bech32_prefix;
+}
diff --git a/yarn.lock b/yarn.lock
index e0a2892d8..5e05c9156 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18024,7 +18024,16 @@ string-length@^4.0.1:
     char-regex "^1.0.2"
     strip-ansi "^6.0.0"
 
-"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0":
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -18140,7 +18149,7 @@ stringify-entities@^4.0.0:
     character-entities-html4 "^2.0.0"
     character-entities-legacy "^3.0.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -18168,6 +18177,13 @@ strip-ansi@^5.1.0:
   dependencies:
     ansi-regex "^4.1.0"
 
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
 strip-ansi@^7.0.1:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -19723,7 +19739,7 @@ wordwrap@^1.0.0:
   resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
   integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -19741,6 +19757,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
+wrap-ansi@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"