diff --git a/packages/core/docs/api/wallet.md b/packages/core/docs/api/wallet.md index c1e955c5d..966d7b254 100644 --- a/packages/core/docs/api/wallet.md +++ b/packages/core/docs/api/wallet.md @@ -80,7 +80,7 @@ Returns meta information about the wallet such as `name`, `description`, `iconUr - `params` (`object`) - `contractId` (`string`): Account ID of the Smart Contract. - `methodNames` (`Array?`): Specify limited access to particular methods on the Smart Contract. - - `derivationPaths` (`Array?`): Required for hardware wallets (e.g. Ledger). This is a list of derivation paths linked to public keys on your device. + - `accounts` (`Array<{derivationPath: string, publicKey: string, accountId: string}>?`): Required for hardware wallets (e.g. Ledger). This is a list of `accounts` linked to public keys on your device. **Returns** @@ -114,9 +114,17 @@ Programmatically sign in. Hardware wallets (e.g. Ledger) require `derivationPath // Ledger (async () => { const wallet = await selector.wallet("ledger"); + const derivationPath = "44'/397'/0'/0'/1'"; + const publicKey = await wallet.getPublicKey(derivationPath); + const accountId = "youraccountid.testnet" + const accounts = await wallet.signIn({ contractId: "test.testnet", - derivationPaths: ["44'/397'/0'/0'/1'"], + accounts: [{ + derivationPath, + publicKey, + accountId + }], }); })(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ef04c31d..cf396f4ff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,7 @@ export type { HardwareWalletSignInParams, HardwareWalletBehaviour, HardwareWallet, + HardwareWalletAccount, BridgeWalletMetadata, BridgeWalletBehaviour, BridgeWallet, diff --git a/packages/core/src/lib/wallet/wallet.types.ts b/packages/core/src/lib/wallet/wallet.types.ts index b04d5da2b..6a3a3c7c3 100644 --- a/packages/core/src/lib/wallet/wallet.types.ts +++ b/packages/core/src/lib/wallet/wallet.types.ts @@ -120,14 +120,22 @@ export type InjectedWallet = BaseWallet< export type HardwareWalletMetadata = BaseWalletMetadata; +export interface HardwareWalletAccount { + derivationPath: string; + publicKey: string; + accountId: string; +} + export interface HardwareWalletSignInParams extends SignInParams { - derivationPaths: Array; + accounts: Array; } export type HardwareWalletBehaviour = Modify< BaseWalletBehaviour, { signIn(params: HardwareWalletSignInParams): Promise> } ->; +> & { + getPublicKey(derivationPath: string): Promise; +}; export type HardwareWallet = BaseWallet< "hardware", diff --git a/packages/ledger/src/lib/ledger.spec.ts b/packages/ledger/src/lib/ledger.spec.ts index f1a019811..34a436d2b 100644 --- a/packages/ledger/src/lib/ledger.spec.ts +++ b/packages/ledger/src/lib/ledger.spec.ts @@ -100,16 +100,12 @@ describe("connect", () => { it("signs in", async () => { const accountId = "amirsaran.testnet"; const derivationPath = "44'/397'/0'/0'/1'"; - const { wallet, ledgerClient, storage, publicKey } = - await createLedgerWallet(); + const { wallet, storage, publicKey } = await createLedgerWallet(); await wallet.signIn({ - derivationPaths: [derivationPath], + accounts: [{ derivationPath, publicKey, accountId }], contractId: "guest-book.testnet", }); - expect(ledgerClient.isConnected).toHaveBeenCalledTimes(1); - expect(ledgerClient.connect).toHaveBeenCalledTimes(1); - expect(ledgerClient.getPublicKey).toHaveBeenCalledTimes(1); expect(storage.setItem).toHaveBeenCalledWith("accounts", [ { accountId, @@ -123,9 +119,10 @@ describe("connect", () => { describe("getAccounts", () => { it("returns account objects", async () => { const accountId = "amirsaran.testnet"; - const { wallet } = await createLedgerWallet(); + const derivationPath = "44'/397'/0'/0'/1'"; + const { wallet, publicKey } = await createLedgerWallet(); await wallet.signIn({ - derivationPaths: ["44'/397'/0'/0'/1'"], + accounts: [{ derivationPath, publicKey, accountId }], contractId: "guest-book.testnet", }); const result = await wallet.getAccounts(); @@ -140,9 +137,11 @@ describe("getAccounts", () => { describe("signAndSendTransaction", () => { it("signs and sends transaction", async () => { - const { wallet, ledgerClient } = await createLedgerWallet(); + const accountId = "amirsaran.testnet"; + const derivationPath = "44'/397'/0'/0'/1'"; + const { wallet, ledgerClient, publicKey } = await createLedgerWallet(); await wallet.signIn({ - derivationPaths: ["44'/397'/0'/0'/1'"], + accounts: [{ derivationPath, publicKey, accountId }], contractId: "guest-book.testnet", }); await wallet.signAndSendTransaction({ @@ -156,9 +155,11 @@ describe("signAndSendTransaction", () => { describe("signAndSendTransactions", () => { it("signs and sends only one transaction", async () => { - const { wallet, ledgerClient } = await createLedgerWallet(); + const accountId = "amirsaran.testnet"; + const derivationPath = "44'/397'/0'/0'/1'"; + const { wallet, ledgerClient, publicKey } = await createLedgerWallet(); await wallet.signIn({ - derivationPaths: ["44'/397'/0'/0'/1'"], + accounts: [{ derivationPath, publicKey, accountId }], contractId: "guest-book.testnet", }); const transactions: Array = [ @@ -176,9 +177,11 @@ describe("signAndSendTransactions", () => { }); it("signs and sends multiple transactions", async () => { - const { wallet, ledgerClient } = await createLedgerWallet(); + const accountId = "amirsaran.testnet"; + const derivationPath = "44'/397'/0'/0'/1'"; + const { wallet, ledgerClient, publicKey } = await createLedgerWallet(); await wallet.signIn({ - derivationPaths: ["44'/397'/0'/0'/1'"], + accounts: [{ derivationPath, publicKey, accountId }], contractId: "guest-book.testnet", }); const transactions: Array = [ diff --git a/packages/ledger/src/lib/ledger.ts b/packages/ledger/src/lib/ledger.ts index 7496c7ba6..9a8a56f78 100644 --- a/packages/ledger/src/lib/ledger.ts +++ b/packages/ledger/src/lib/ledger.ts @@ -26,10 +26,6 @@ interface ValidateAccessKeyParams { publicKey: string; } -interface GetAccountIdFromPublicKeyParams { - publicKey: string; -} - interface LedgerState { client: LedgerClient; accounts: Array; @@ -157,28 +153,6 @@ const Ledger: WalletBehaviourFactory = async ({ ); }; - const getAccountIdFromPublicKey = async ({ - publicKey, - }: GetAccountIdFromPublicKeyParams): Promise => { - const response = await fetch( - `${options.network.indexerUrl}/publicKey/ed25519:${publicKey}/accounts` - ); - - if (!response.ok) { - throw new Error("Failed to get account id from public key"); - } - - const accountIds = await response.json(); - - if (!Array.isArray(accountIds) || !accountIds.length) { - throw new Error( - "Failed to find account linked for public key: " + publicKey - ); - } - - return accountIds[0]; - }; - const transformTransactions = ( transactions: Array> ): Array => { @@ -199,30 +173,17 @@ const Ledger: WalletBehaviourFactory = async ({ }; return { - async signIn({ derivationPaths }) { + async signIn({ accounts }) { const existingAccounts = getAccounts(); if (existingAccounts.length) { return existingAccounts; } - if (!derivationPaths.length) { - throw new Error("Invalid derivation paths"); - } - - // Note: Connection must be triggered by user interaction. - await connectLedgerDevice(); - - const accounts: Array = []; - - for (let i = 0; i < derivationPaths.length; i += 1) { - const derivationPath = derivationPaths[i]; - const publicKey = await _state.client.getPublicKey({ derivationPath }); - const accountId = await getAccountIdFromPublicKey({ publicKey }); + const ledgerAccounts: Array = []; - if (accounts.some((x) => x.accountId === accountId)) { - throw new Error("Duplicate account id: " + accountId); - } + for (let i = 0; i < accounts.length; i++) { + const { derivationPath, accountId, publicKey } = accounts[i]; const accessKey = await validateAccessKey({ accountId, publicKey }); @@ -232,15 +193,15 @@ const Ledger: WalletBehaviourFactory = async ({ ); } - accounts.push({ + ledgerAccounts.push({ accountId, derivationPath, publicKey, }); } - await storage.setItem(STORAGE_ACCOUNTS, accounts); - _state.accounts = accounts; + await storage.setItem(STORAGE_ACCOUNTS, ledgerAccounts); + _state.accounts = ledgerAccounts; return getAccounts(); }, @@ -290,6 +251,11 @@ const Ledger: WalletBehaviourFactory = async ({ signedTransactions.map((signedTx) => provider.sendTransaction(signedTx)) ); }, + async getPublicKey(derivationPath: string) { + await connectLedgerDevice(); + + return await _state.client.getPublicKey({ derivationPath }); + }, }; }; diff --git a/packages/modal-ui/src/lib/components/DerivationPath.tsx b/packages/modal-ui/src/lib/components/DerivationPath.tsx index 7f2d52d7f..4675a8d62 100644 --- a/packages/modal-ui/src/lib/components/DerivationPath.tsx +++ b/packages/modal-ui/src/lib/components/DerivationPath.tsx @@ -2,24 +2,32 @@ import React, { ChangeEvent, KeyboardEventHandler, useState } from "react"; import type { Wallet, WalletSelector } from "@near-wallet-selector/core"; import type { ModalOptions } from "../modal.types"; import type { DerivationPathModalRouteParams } from "./Modal.types"; +import type { HardwareWalletAccount } from "@near-wallet-selector/core"; +import HardwareWalletAccountsForm from "./HardwareWalletAccountsForm"; +import { WalletConnecting } from "./WalletConnecting"; interface DerivationPathProps { selector: WalletSelector; options: ModalOptions; onBack: () => void; - onConnecting: (wallet: Wallet) => void; onConnected: () => void; params: DerivationPathModalRouteParams; onError: (message: string) => void; } +export interface HardwareWalletAccountState { + derivationPath: string; + publicKey: string; + accountIds: Array; + selectedAccountId: string; +} + export const DEFAULT_DERIVATION_PATH = "44'/397'/0'/0'/1'"; export const DerivationPath: React.FC = ({ selector, options, onBack, - onConnecting, onConnected, params, onError, @@ -28,6 +36,16 @@ export const DerivationPath: React.FC = ({ Array<{ path: string }> >([{ path: DEFAULT_DERIVATION_PATH }]); + const [hardwareWalletAccounts, setHardwareWalletAccounts] = useState< + Array + >([]); + + const [showMultipleAccountsSelect, setShowMultipleAccountsSelect] = + useState(false); + + const [connecting, setConnecting] = useState(false); + const [hardwareWallet, setHardwareWallet] = useState(); + const handleDerivationPathAdd = () => { setDerivationPaths((prevDerivationPaths) => { return [...prevDerivationPaths, { path: "" }]; @@ -51,19 +69,60 @@ export const DerivationPath: React.FC = ({ }); }; - const handleConnectClick = async () => { - const wallet = await selector.wallet(params.walletId); - onConnecting(wallet); + const getAccountIdsFromPublicKey = async ( + publicKey: string + ): Promise> => { + const response = await fetch( + `${selector.options.network.indexerUrl}/publicKey/ed25519:${publicKey}/accounts` + ); - if (wallet.type !== "hardware") { - return; + if (!response.ok) { + throw new Error("Failed to get account id from public key"); + } + + const accountIds = await response.json(); + + if (!Array.isArray(accountIds) || !accountIds.length) { + throw new Error( + "Failed to find account linked for public key: " + publicKey + ); } + return accountIds; + }; + + const resolveAccounts = async (wallet: Wallet) => { + const accounts: Array = []; + + for (let i = 0; i < derivationPaths.length; i += 1) { + const derivationPath = derivationPaths[i].path; + + if (wallet.type === "hardware") { + const publicKey = await wallet.getPublicKey(derivationPath); + const accountIds = await getAccountIdsFromPublicKey(publicKey); + + accounts.push({ + derivationPath, + publicKey, + accountIds, + selectedAccountId: accountIds[0], + }); + } + } + return accounts; + }; + + const signIn = ( + wallet: Wallet, + contractId: string, + methodNames: Array | undefined, + accounts: Array + ) => { return wallet .signIn({ - contractId: options.contractId, - methodNames: options.methodNames, - derivationPaths: derivationPaths.map((d) => d.path), + contractId, + methodNames, + accounts, }) .then(() => onConnected()) .catch((err) => { @@ -71,6 +130,82 @@ export const DerivationPath: React.FC = ({ }); }; + const handleConnectClick = async () => { + const wallet = await selector.wallet(params.walletId); + + if (wallet.type !== "hardware") { + return; + } + + setConnecting(true); + setHardwareWallet(wallet); + + try { + const accounts = await resolveAccounts(wallet); + const multipleAccounts = accounts.some((x) => x.accountIds.length > 1); + + if (!multipleAccounts) { + const mapAccounts = accounts.map((account) => { + return { + derivationPath: account.derivationPath, + publicKey: account.publicKey, + accountId: account.accountIds[0], + }; + }); + + return signIn( + wallet, + options.contractId, + options.methodNames, + mapAccounts + ); + } else { + setConnecting(false); + + setHardwareWalletAccounts(accounts); + setShowMultipleAccountsSelect(true); + } + } catch (err) { + setConnecting(false); + const message = + err instanceof Error ? err.message : "Something went wrong"; + + onError(message); + } finally { + setConnecting(false); + } + }; + + const handleMultipleAccountsSignIn = async ( + accounts: Array + ) => { + await signIn( + hardwareWallet!, + options.contractId, + options.methodNames, + accounts + ); + }; + + const handleAccountChange = ( + derivationPath: string, + selectedAccountId: string + ) => { + setHardwareWalletAccounts((accounts) => { + const mapAccounts = accounts.map((account) => { + const selectedId = + derivationPath === account.derivationPath + ? selectedAccountId + : account.selectedAccountId; + return { + ...account, + selectedAccountId: selectedId, + }; + }); + return [...mapAccounts]; + }); + }; + const handleEnterClick: KeyboardEventHandler = async ( e ) => { @@ -79,56 +214,91 @@ export const DerivationPath: React.FC = ({ } }; + if (connecting) { + return ( +
+ { + setConnecting(false); + }} + /> +
+ ); + } + return (
-

- Make sure your device is plugged in, then enter an account id to - connect: -

-
- {derivationPaths.map((path, index) => { - return ( -
- { - handleDerivationPathChange(index, e); - }} - onKeyPress={handleEnterClick} - /> - - {index !== 0 && ( - - )} - {index === derivationPaths.length - 1 && ( - - )} -
- ); - })} -
-
- - -
+ {showMultipleAccountsSelect ? ( + { + handleAccountChange(derivationPath, selectedAccountId); + }} + onSubmit={(accounts, e) => { + e.preventDefault(); + const mapAccounts = accounts.map((account) => { + return { + derivationPath: account.derivationPath, + publicKey: account.publicKey, + accountId: account.selectedAccountId, + }; + }); + handleMultipleAccountsSignIn(mapAccounts); + }} + /> + ) : ( +
+

+ Make sure your device is plugged in, then enter an account id to + connect: +

+
+ {derivationPaths.map((path, index) => { + return ( +
+ { + handleDerivationPathChange(index, e); + }} + onKeyPress={handleEnterClick} + /> + + {index !== 0 && ( + + )} + {index === derivationPaths.length - 1 && ( + + )} +
+ ); + })} +
+
+ + +
+
+ )}
); }; diff --git a/packages/modal-ui/src/lib/components/HardwareWalletAccountsForm.tsx b/packages/modal-ui/src/lib/components/HardwareWalletAccountsForm.tsx new file mode 100644 index 000000000..ac1b30bce --- /dev/null +++ b/packages/modal-ui/src/lib/components/HardwareWalletAccountsForm.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import type { HardwareWalletAccountState } from "./DerivationPath"; + +interface FormProps { + hardwareWalletAccounts: Array; + onAccountChanged: (derivationPath: string, selectedAccountId: string) => void; + onSubmit: ( + accounts: Array, + e: React.FormEvent + ) => void; +} + +const HardwareWalletAccountsForm: React.FC = ({ + hardwareWalletAccounts, + onAccountChanged, + onSubmit, +}) => { + return ( +
+

+ Multiple accounts found. Please choose an account per derivation path. +

+
{ + onSubmit(hardwareWalletAccounts, e); + }} + > +
+ {hardwareWalletAccounts.map((account, accountIndex) => { + return ( +
+ + +
+ ); + })} + +
+ +
+
+
+
+ ); +}; + +export default HardwareWalletAccountsForm; diff --git a/packages/modal-ui/src/lib/components/Modal.tsx b/packages/modal-ui/src/lib/components/Modal.tsx index bf2e47587..03a50bfbe 100644 --- a/packages/modal-ui/src/lib/components/Modal.tsx +++ b/packages/modal-ui/src/lib/components/Modal.tsx @@ -140,12 +140,6 @@ export const Modal: React.FC = ({ { - setRoute({ - name: "WalletConnecting", - params: { wallet: wallet }, - }); - }} onConnected={handleDismissClick} params={route.params} onBack={() => diff --git a/packages/modal-ui/src/lib/components/styles.css b/packages/modal-ui/src/lib/components/styles.css index e5ca94dfb..724e7a7b0 100644 --- a/packages/modal-ui/src/lib/components/styles.css +++ b/packages/modal-ui/src/lib/components/styles.css @@ -246,6 +246,41 @@ color: var(--wallet-selector-error, var(--error)); } +/** + * Modal Wallet ChooseLedgerAccountForm/Wrapper + */ +.nws-modal-wrapper .modal .choose-ledger-account-form-wrapper .form-control { + display: flex; + margin-bottom: 16px; + padding: 10px; + box-shadow: rgb(0 0 0 / 16%) 0 1px 4px; + justify-content: space-between; + align-items: center; + color: var(--text-color); +} +.nws-modal-wrapper .modal .choose-ledger-account-form-wrapper .form-control label { + color: inherit; +} +.nws-modal-wrapper .modal .choose-ledger-account-form-wrapper .form-control select { + padding: 8px; + font-size: 14px; + border-radius: 10px; + background-color: transparent; + border: none; + outline: none; + text-align: right; + color: inherit; +} + +.nws-modal-wrapper .modal .choose-ledger-account-form-wrapper .form-control select option { + background-color: var(--content-bg); +} + + +.nws-modal-wrapper .modal .choose-ledger-account-form-wrapper .action-buttons { + justify-content: flex-end; +} + /** * Modal Wallet Options Section/Wrapper */ @@ -403,6 +438,14 @@ .nws-modal-wrapper .modal .derivation-path-wrapper .derivation-path-list input { max-width: 140px; } + + .nws-modal-wrapper .modal .choose-ledger-account-form-wrapper .form-control { + flex-direction: column; + } + + .nws-modal-wrapper .modal .choose-ledger-account-form-wrapper .form-control select { + text-align: center; + } } .nws-modal-wrapper.dark-theme .modal #near-wallet img,