From 78fcd03bad548132b8fea072d8d4afdc30bd45aa Mon Sep 17 00:00:00 2001 From: "Justin R. Evans" Date: Tue, 30 May 2023 12:21:13 +0200 Subject: [PATCH] Implement Ledger service with transfer functionality --- .../src/App/Accounts/AccountListing.tsx | 22 ++- apps/extension/src/App/App.tsx | 8 +- .../Settings/ExtraSettings/ExtraSettings.tsx | 49 ++---- .../ResetPassword/ResetPassword.tsx | 49 +++--- .../src/App/Settings/ExtraSettings/types.ts | 4 +- apps/extension/src/App/Settings/Settings.tsx | 79 ++++----- apps/extension/src/Approvals/Approvals.tsx | 6 + .../ApproveTransfer/ApproveTransfer.tsx | 7 +- .../ApproveTransfer/ConfirmLedgerTransfer.tsx | 111 ++++++++++++ .../ApproveTransfer/ConfirmTransfer.tsx | 1 + apps/extension/src/Approvals/types.ts | 1 + apps/extension/src/Setup/Ledger/Ledger.tsx | 111 ++++++------ .../src/Setup/Ledger/LedgerConfirmation.tsx | 89 ++++++++++ apps/extension/src/Setup/Setup.tsx | 13 +- apps/extension/src/Setup/types.ts | 1 + .../src/background/approvals/handler.ts | 4 +- .../src/background/approvals/messages.ts | 5 +- .../src/background/approvals/service.ts | 25 +-- apps/extension/src/background/index.ts | 21 ++- .../src/background/keyring/handler.ts | 6 +- .../src/background/keyring/keyring.ts | 161 +++++++----------- .../src/background/keyring/messages.ts | 22 ++- .../src/background/keyring/service.ts | 71 ++++++-- .../extension/src/background/keyring/types.ts | 24 ++- .../src/background/ledger/constants.ts | 1 + .../src/background/ledger/handler.ts | 58 +++++++ apps/extension/src/background/ledger/index.ts | 3 + apps/extension/src/background/ledger/init.ts | 17 ++ .../extension/src/background/ledger/ledger.ts | 112 ++++++------ .../src/background/ledger/messages.ts | 121 +++++++++++++ .../src/background/ledger/service.ts | 142 +++++++++++++++ .../web-workers/submit-transfer-web-worker.ts | 4 +- apps/extension/src/provider/Anoma.test.ts | 21 ++- apps/extension/src/provider/Anoma.ts | 15 +- apps/extension/src/provider/InjectedAnoma.ts | 20 ++- apps/extension/src/provider/Signer.ts | 17 +- apps/extension/src/provider/data.mock.ts | 14 +- apps/extension/src/provider/messages.ts | 8 +- apps/extension/src/test/init.ts | 43 +++-- apps/extension/src/utils/index.ts | 42 ++++- apps/namada-interface/src/slices/accounts.ts | 5 +- apps/namada-interface/src/slices/transfers.ts | 26 +-- apps/namada-interface/src/store/mocks.ts | 30 ++-- packages/integrations/src/Keplr.test.ts | 3 +- packages/integrations/src/Keplr.ts | 11 +- packages/integrations/src/Metamask.ts | 5 +- packages/shared/lib/Cargo.lock | 40 ++++- packages/types/src/account.ts | 9 +- packages/types/src/anoma.ts | 7 +- packages/types/src/signer.ts | 4 +- packages/types/src/tx/schema/index.ts | 1 - packages/types/src/tx/schema/shielded.ts | 86 ---------- packages/types/src/tx/types.ts | 23 --- packages/utils/src/helpers/index.ts | 58 +++++-- 54 files changed, 1246 insertions(+), 590 deletions(-) create mode 100644 apps/extension/src/Approvals/ApproveTransfer/ConfirmLedgerTransfer.tsx create mode 100644 apps/extension/src/Setup/Ledger/LedgerConfirmation.tsx create mode 100644 apps/extension/src/background/ledger/constants.ts create mode 100644 apps/extension/src/background/ledger/handler.ts create mode 100644 apps/extension/src/background/ledger/init.ts create mode 100644 apps/extension/src/background/ledger/messages.ts create mode 100644 apps/extension/src/background/ledger/service.ts delete mode 100644 packages/types/src/tx/schema/shielded.ts diff --git a/apps/extension/src/App/Accounts/AccountListing.tsx b/apps/extension/src/App/Accounts/AccountListing.tsx index 6393fcd63c..002fc50cd9 100644 --- a/apps/extension/src/App/Accounts/AccountListing.tsx +++ b/apps/extension/src/App/Accounts/AccountListing.tsx @@ -29,6 +29,24 @@ const textToClipboard = (content: string): void => { navigator.clipboard.writeText(content); }; +const isChild = (type: AccountType, path: Bip44Path): boolean => { + // All PrivateKey accounts are child accounts + if (type === AccountType.PrivateKey) { + return true; + } + + if (type === AccountType.Ledger) { + // If this is a Ledger account, a child account is any account + // with a path that isn't the default path (/0'/0/0). This is for display + // purposes only. If the sum of the path components is greater than + // zero, it is a child. + const { account, change, index = 0 } = path; + return account + change + index > 0; + } + + return false; +}; + const formatDerivationPath = ( isChildAccount: boolean, { account, change, index = 0 }: Bip44Path, @@ -36,14 +54,14 @@ const formatDerivationPath = ( ): string => isChildAccount ? `/${account}'/${ - type === AccountType.PrivateKey ? `${change}/` : "" + type !== AccountType.Mnemonic ? `${change}/` : "" }${index}` : ""; const AccountListing = ({ account, parentAlias }: Props): JSX.Element => { const { address, alias, path, type } = account; const navigate = useNavigate(); - const isChildAccount = type !== AccountType.Mnemonic; + const isChildAccount = isChild(type, path); return ( diff --git a/apps/extension/src/App/App.tsx b/apps/extension/src/App/App.tsx index 5bd85db9f7..0927f98a1e 100644 --- a/apps/extension/src/App/App.tsx +++ b/apps/extension/src/App/App.tsx @@ -78,11 +78,13 @@ export const App: React.FC = () => { setStatus(Status.Pending); try { - const parentId = await requester.sendMessage( + const parent = await requester.sendMessage( Ports.Background, new GetActiveAccountMsg() ); - const parentAccount = accounts.find((account) => account.id === parentId); + const parentAccount = accounts.find( + (account) => account.id === parent?.id + ); setParentAccount(parentAccount); } catch (e) { console.error(e); @@ -115,7 +117,7 @@ export const App: React.FC = () => { }, onFail: () => { setError("An error occurred connecting to extension"); - setStatus(Status.Failed); + setStatus(Status.Completed); }, }, { tries: 10, ms: 100 }, diff --git a/apps/extension/src/App/Settings/ExtraSettings/ExtraSettings.tsx b/apps/extension/src/App/Settings/ExtraSettings/ExtraSettings.tsx index 51112e6555..6a64e7184b 100644 --- a/apps/extension/src/App/Settings/ExtraSettings/ExtraSettings.tsx +++ b/apps/extension/src/App/Settings/ExtraSettings/ExtraSettings.tsx @@ -4,10 +4,7 @@ import { ExtensionRequester } from "extension"; import { Mode, ExtraSetting } from "./types"; import { ResetPassword } from "./ResetPassword"; import { DeleteAccount } from "./DeleteAccount"; -import { - ExtraSettingsContainer, - CloseLink -} from "./ExtraSettings.components"; +import { ExtraSettingsContainer, CloseLink } from "./ExtraSettings.components"; /** * Container for additional settings forms such as the reset password form. @@ -17,37 +14,27 @@ const ExtraSettings: React.FC<{ requester: ExtensionRequester; onClose: () => void; onDeleteAccount: (id: string) => void; -}> = ({ - extraSetting, - requester, - onClose, - onDeleteAccount, -}) => { +}> = ({ extraSetting, requester, onClose, onDeleteAccount }) => { return ( - {extraSetting && - - Close - } - - { - extraSetting === null ? "" : - - extraSetting.mode === Mode.ResetPassword ? - : - - extraSetting.mode === Mode.DeleteAccount ? - : + {extraSetting && Close} + {extraSetting === null ? ( + "" + ) : extraSetting.mode === Mode.ResetPassword ? ( + + ) : extraSetting.mode === Mode.DeleteAccount ? ( + + ) : ( assertNever(extraSetting.mode) - } + )} ); }; diff --git a/apps/extension/src/App/Settings/ExtraSettings/ResetPassword/ResetPassword.tsx b/apps/extension/src/App/Settings/ExtraSettings/ResetPassword/ResetPassword.tsx index 666b4ed773..fce267e2c1 100644 --- a/apps/extension/src/App/Settings/ExtraSettings/ResetPassword/ResetPassword.tsx +++ b/apps/extension/src/App/Settings/ExtraSettings/ResetPassword/ResetPassword.tsx @@ -1,9 +1,6 @@ import { useState } from "react"; -import { - Button, - ButtonVariant -} from "@anoma/components"; -import zxcvbn, { ZXCVBNFeedback } from "zxcvbn"; +import { Button, ButtonVariant } from "@anoma/components"; +import zxcvbn from "zxcvbn"; import { Input, @@ -23,12 +20,12 @@ enum Status { Unsubmitted, Pending, Complete, - Failed -}; + Failed, +} export type Props = { - accountId: string, - requester: ExtensionRequester + accountId: string; + requester: ExtensionRequester; }; const ResetPassword: React.FC = ({ accountId, requester }) => { @@ -42,8 +39,8 @@ const ResetPassword: React.FC = ({ accountId, requester }) => { const match = newPassword === confirmNewPassword; const { feedback } = zxcvbn(newPassword); - const hasFeedback = feedback.warning !== "" || - feedback.suggestions.length > 0; + const hasFeedback = + feedback.warning !== "" || feedback.suggestions.length > 0; const shouldDisableSubmit = status === Status.Pending || @@ -52,7 +49,7 @@ const ResetPassword: React.FC = ({ accountId, requester }) => { !match || (process.env.NODE_ENV !== "development" && hasFeedback); - const handleSubmit = async () => { + const handleSubmit = async (): Promise => { setStatus(Status.Pending); const result = await requester.sendMessage( @@ -87,7 +84,7 @@ const ResetPassword: React.FC = ({ accountId, requester }) => { setCurrentPassword(e.target.value)} + onChange={(e) => setCurrentPassword(e.target.value)} /> @@ -96,16 +93,16 @@ const ResetPassword: React.FC = ({ accountId, requester }) => { setNewPassword(e.target.value)} + onChange={(e) => setNewPassword(e.target.value)} /> {feedback.warning} - {feedback.suggestions.map((suggestion, i) => + {feedback.suggestions.map((suggestion, i) => ( {suggestion} - )} + ))} @@ -113,35 +110,27 @@ const ResetPassword: React.FC = ({ accountId, requester }) => { setConfirmNewPassword(e.target.value)} + onChange={(e) => setConfirmNewPassword(e.target.value)} /> - {!match && ( - Passwords do not match - )} + {!match && Passwords do not match} - {errorMessage && ( - {errorMessage} - )} + {errorMessage && {errorMessage}} )} - {status === Status.Complete && ( -

Complete!

- )} + {status === Status.Complete &&

Complete!

} ); -} +}; export default ResetPassword; diff --git a/apps/extension/src/App/Settings/ExtraSettings/types.ts b/apps/extension/src/App/Settings/ExtraSettings/types.ts index a41b62d343..f7254a1263 100644 --- a/apps/extension/src/App/Settings/ExtraSettings/types.ts +++ b/apps/extension/src/App/Settings/ExtraSettings/types.ts @@ -2,9 +2,9 @@ export enum Mode { ResetPassword = "Reset password", DeleteAccount = "Delete account", -}; +} export type ExtraSetting = { mode: Mode; accountId: string; -} +}; diff --git a/apps/extension/src/App/Settings/Settings.tsx b/apps/extension/src/App/Settings/Settings.tsx index 5695cb8bac..d613350006 100644 --- a/apps/extension/src/App/Settings/Settings.tsx +++ b/apps/extension/src/App/Settings/Settings.tsx @@ -4,7 +4,6 @@ import browser from "webextension-polyfill"; import { DerivedAccount } from "@anoma/types"; import { Button, ButtonVariant } from "@anoma/components"; -import { assertNever } from "@anoma/utils"; import { ExtensionRequester } from "extension"; import { Ports } from "router"; @@ -12,6 +11,7 @@ import { LockKeyRingMsg, SetActiveAccountMsg, QueryParentAccountsMsg, + ParentAccount, } from "background/keyring"; import { SettingsContainer, @@ -29,10 +29,6 @@ import { } from "../Accounts/Accounts.components"; import { TopLevelRoute } from "../types"; import { Status } from "../App"; -import { - ResetPassword, - Props as ResetPasswordProps -} from "./ExtraSettings/ResetPassword" import { ExtraSettings } from "./ExtraSettings"; import { Mode, ExtraSetting } from "./ExtraSettings/types"; @@ -43,11 +39,7 @@ const Settings: React.FC<{ activeAccountId: string; requester: ExtensionRequester; onSelectAccount: (account: DerivedAccount) => void; -}> = ({ - activeAccountId, - requester, - onSelectAccount, -}) => { +}> = ({ activeAccountId, requester, onSelectAccount }) => { const [extraSetting, setExtraSetting] = useState(null); const [status, setStatus] = useState(Status.Pending); const [error, setError] = useState(""); @@ -75,19 +67,21 @@ const Settings: React.FC<{ fetchParentAccounts(); }, []); - const handleSelectAccount = async (account: DerivedAccount): Promise => { - const { id } = account; + const handleSelectAccount = async ( + account: DerivedAccount + ): Promise => { + const { id, type } = account; try { await requester.sendMessage( Ports.Background, - new SetActiveAccountMsg(id) + new SetActiveAccountMsg(id, type as ParentAccount) ); // Lock current wallet keyring: await requester.sendMessage(Ports.Background, new LockKeyRingMsg()); // Fetch accounts for selected parent account - await onSelectAccount(account); + onSelectAccount(account); } catch (e) { console.error(e); setError(`An error occurred while setting active account: ${e}`); @@ -95,7 +89,9 @@ const Settings: React.FC<{ } }; - const handleDeleteAccount = async (deletedAccountId: string): Promise => { + const handleDeleteAccount = async ( + deletedAccountId: string + ): Promise => { await fetchParentAccounts(); }; @@ -122,25 +118,26 @@ const Settings: React.FC<{ - {status === Status.Failed && (

Error communicating with extension background!

)}

{error ? error : "Select account:"}

- {parentAccounts.map((account, i) => + {parentAccounts.map((account, i) => ( handleSelectAccount(account)} - onSelectMode={(mode) => setExtraSetting({ - mode, - accountId: account.id - })} + onSelectMode={(mode) => + setExtraSetting({ + mode, + accountId: account.id, + }) + } /> - )} + ))} @@ -168,7 +165,6 @@ const Settings: React.FC<{ onClose={() => setExtraSetting(null)} onDeleteAccount={handleDeleteAccount} /> -
@@ -183,37 +179,28 @@ const AccountListItem: React.FC<{ activeAccountId: string; onSelectAccount: () => void; onSelectMode: (mode: Mode) => void; -}> = ({ - account, - activeAccountId, - onSelectAccount, - onSelectMode, -}) => { +}> = ({ account, activeAccountId, onSelectAccount, onSelectMode }) => { const [expanded, setExpanded] = useState(false); return ( <> - + {account.alias} {activeAccountId === account.id && (selected)} - setExpanded(!expanded)} - /> + setExpanded(!expanded)} /> - {expanded && + {expanded && ( { setExpanded(false); onSelectMode(mode); }} /> - } + )} ); }; @@ -223,24 +210,16 @@ const AccountListItem: React.FC<{ */ const ModeSelect: React.FC<{ onSelectMode: (mode: Mode) => void; -}> = ({ - onSelectMode, -}) => { - const modes = [ - Mode.ResetPassword, - Mode.DeleteAccount, - ]; +}> = ({ onSelectMode }) => { + const modes = [Mode.ResetPassword, Mode.DeleteAccount]; return ( - {modes.map((mode, i) => - onSelectMode(mode)} - > + {modes.map((mode, i) => ( + onSelectMode(mode)}> {mode} - )} + ))} ); }; diff --git a/apps/extension/src/Approvals/Approvals.tsx b/apps/extension/src/Approvals/Approvals.tsx index 979b3d0efe..b7db641a81 100644 --- a/apps/extension/src/Approvals/Approvals.tsx +++ b/apps/extension/src/Approvals/Approvals.tsx @@ -14,6 +14,7 @@ import { import { ApproveTransfer, ConfirmTransfer } from "./ApproveTransfer"; import { ApproveConnection } from "./ApproveConnection"; import { TopLevelRoute } from "Approvals/types"; +import { ConfirmLedgerTransfer } from "./ApproveTransfer/ConfirmLedgerTransfer"; export enum Status { Completed, @@ -45,6 +46,11 @@ export const Approvals: React.FC = () => { path={TopLevelRoute.ConfirmTx} element={} /> + } + /> + } diff --git a/apps/extension/src/Approvals/ApproveTransfer/ApproveTransfer.tsx b/apps/extension/src/Approvals/ApproveTransfer/ApproveTransfer.tsx index a68f33d073..a996cedbd9 100644 --- a/apps/extension/src/Approvals/ApproveTransfer/ApproveTransfer.tsx +++ b/apps/extension/src/Approvals/ApproveTransfer/ApproveTransfer.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { Button, ButtonVariant } from "@anoma/components"; import { shortenAddress } from "@anoma/utils"; -import { Tokens } from "@anoma/types"; +import { AccountType, Tokens } from "@anoma/types"; import { useQuery } from "hooks"; import { Address } from "App/Accounts/AccountListing.components"; @@ -28,6 +28,7 @@ export const ApproveTransfer: React.FC = ({ setAddress, setMsgId }) => { const query = useQuery(); // TODO: Get current parent account alias to display to user + const type = query.get("type") || ""; const id = query.get("id") || ""; const amount = query.get("amount") || ""; const source = query.get("source") || ""; @@ -45,6 +46,9 @@ export const ApproveTransfer: React.FC = ({ setAddress, setMsgId }) => { const handleApproveClick = (): void => { setMsgId(id); + if (type === AccountType.Ledger) { + return navigate(`${TopLevelRoute.ConfirmLedgerTx}`); + } navigate(TopLevelRoute.ConfirmTx); }; @@ -64,7 +68,6 @@ export const ApproveTransfer: React.FC = ({ setAddress, setMsgId }) => { return (

Approve this Transaction?

-

ID: {id}

Target: 

{shortenAddress(target)}

Source: 

diff --git a/apps/extension/src/Approvals/ApproveTransfer/ConfirmLedgerTransfer.tsx b/apps/extension/src/Approvals/ApproveTransfer/ConfirmLedgerTransfer.tsx new file mode 100644 index 0000000000..e68dcd451e --- /dev/null +++ b/apps/extension/src/Approvals/ApproveTransfer/ConfirmLedgerTransfer.tsx @@ -0,0 +1,111 @@ +import { useCallback, useState } from "react"; +import { LedgerError } from "@zondax/ledger-namada"; +import { toBase64 } from "@cosmjs/encoding"; + +import { Button, ButtonVariant } from "@anoma/components"; + +import { Ledger } from "background/ledger"; +import { + GetTransferBytesMsg, + SubmitSignedTransferMsg, +} from "background/ledger/messages"; +import { Ports } from "router"; +import { closeCurrentTab } from "utils"; +import { useRequester } from "hooks/useRequester"; +import { Status } from "Approvals/Approvals"; +import { + ApprovalContainer, + ButtonContainer, +} from "Approvals/Approvals.components"; + +type Props = { + msgId: string; +}; + +export const ConfirmLedgerTransfer: React.FC = ({ msgId }) => { + const requester = useRequester(); + const [error, setError] = useState(); + const [status, setStatus] = useState(); + + const submitTransfer = async (): Promise => { + setStatus(Status.Pending); + const ledger = await Ledger.init(); + + try { + // Constuct tx bytes from SDK + const { bytes, path } = await requester.sendMessage( + Ports.Background, + new GetTransferBytesMsg(msgId) + ); + + // Retrieve publicKey from Ledger + const { publicKey } = await ledger.getAddressAndPublicKey(path); + + // Sign with Ledger + const signatures = await ledger.sign(bytes, path); + const { errorMessage, returnCode } = signatures; + + // Close transport so that it may be re-opened on a subsequent attempt (due to error) + await ledger.closeTransport(); + + if (returnCode !== LedgerError.NoErrors) { + setError(errorMessage); + return setStatus(Status.Failed); + } + + // Submit signatures for tx + await requester.sendMessage( + Ports.Background, + new SubmitSignedTransferMsg( + msgId, + toBase64(bytes), + signatures, + publicKey + ) + ); + setStatus(Status.Completed); + } catch (e) { + console.warn(e); + const ledgerErrors = await ledger.queryErrors(); + setError(ledgerErrors); + setStatus(Status.Failed); + } + }; + + const handleCloseTab = useCallback(async (): Promise => { + await closeCurrentTab(); + }, []); + + return ( + + {status === Status.Failed && ( +

+ {error} +
+ Try again +

+ )} + {status === Status.Pending &&

Submitting transfer...

} + {status !== Status.Pending && status !== Status.Completed && ( + <> +

Make sure your Ledger is unlocked, and click "Submit"

+ + + + + )} + {status === Status.Completed && ( + <> +

Success! You may close this window.

+ + + + + )} +
+ ); +}; diff --git a/apps/extension/src/Approvals/ApproveTransfer/ConfirmTransfer.tsx b/apps/extension/src/Approvals/ApproveTransfer/ConfirmTransfer.tsx index 7003bd54e9..49d39dc121 100644 --- a/apps/extension/src/Approvals/ApproveTransfer/ConfirmTransfer.tsx +++ b/apps/extension/src/Approvals/ApproveTransfer/ConfirmTransfer.tsx @@ -56,6 +56,7 @@ export const ConfirmTransfer: React.FC = ({ msgId, address }) => { Ports.Background, new SubmitApprovedTransferMsg(msgId, address, password) ); + setStatus(Status.Completed); } catch (e) { setError("Unable to authenticate Tx!"); setStatus(Status.Failed); diff --git a/apps/extension/src/Approvals/types.ts b/apps/extension/src/Approvals/types.ts index 1904b691e6..c771fcd2e4 100644 --- a/apps/extension/src/Approvals/types.ts +++ b/apps/extension/src/Approvals/types.ts @@ -3,4 +3,5 @@ export enum TopLevelRoute { ApproveConnection = "/connection", ApproveTx = "/tx", ConfirmTx = "/confirm", + ConfirmLedgerTx = "/confirm-ledger", } diff --git a/apps/extension/src/Setup/Ledger/Ledger.tsx b/apps/extension/src/Setup/Ledger/Ledger.tsx index df31345289..498602c197 100644 --- a/apps/extension/src/Setup/Ledger/Ledger.tsx +++ b/apps/extension/src/Setup/Ledger/Ledger.tsx @@ -11,10 +11,8 @@ import { Input, InputVariants, } from "@anoma/components"; -import { shortenAddress } from "@anoma/utils"; import { initLedgerHIDTransport, Ledger as LedgerApp } from "background/ledger"; -import { ExtensionRequester } from "extension"; import { LedgerError } from "./Ledger.components"; import { TopSection, @@ -24,31 +22,37 @@ import { SubViewContainer, UpperContentContainer, Header1, - BodyText, FormContainer, } from "Setup/Setup.components"; +import { TopLevelRoute } from "Setup/types"; -type Props = { - requester: ExtensionRequester; -}; - -const Ledger: React.FC = ({ requester: _ }) => { +const Ledger: React.FC = () => { const navigate = useNavigate(); const themeContext = useContext(ThemeContext); const [alias, setAlias] = useState(""); const [error, setError] = useState(); - const [isConnected, setIsConnected] = useState(false); - const [publicKey, setPublicKey] = useState(); - const [appInfo, setAppInfo] = useState<{ name: string; version: string }>(); const queryLedger = async (ledger: LedgerApp): Promise => { - const pk = await ledger.getPublicKey(); - const { appName, appVersion } = ledger.status(); + try { + // Get address and public key for default account + const { address, publicKey } = await ledger.getAddressAndPublicKey(); + navigate( + `/${TopLevelRoute.LedgerConfirmation}/${alias}/${address}/${publicKey}` + ); + } catch (_) { + checkErrors(ledger); + } + }; + + const checkErrors = async (ledger: LedgerApp): Promise => { + const errorMessage = await ledger.queryErrors(); - setAppInfo({ name: appName, version: appVersion }); - setPublicKey(pk); - setIsConnected(true); + if (errorMessage) { + await ledger.closeTransport(); + setError(errorMessage); + throw new Error(errorMessage); + } }; /** @@ -56,7 +60,8 @@ const Ledger: React.FC = ({ requester: _ }) => { */ const handleConnectUSB = async (): Promise => { try { - queryLedger(await LedgerApp.init()); + const ledger = await LedgerApp.init(); + queryLedger(ledger); } catch (e) { setError(`Failed to connect to Ledger: ${e}`); } @@ -68,7 +73,8 @@ const Ledger: React.FC = ({ requester: _ }) => { const handleConnectHID = async (): Promise => { try { const transport = await initLedgerHIDTransport(); - queryLedger(await LedgerApp.init(transport)); + const ledger = await LedgerApp.init(transport); + queryLedger(ledger); } catch (e) { setError(`Failed to connect to Ledger: ${e}`); } @@ -94,51 +100,30 @@ const Ledger: React.FC = ({ requester: _ }) => { {error && {error}} - {/* TODO: Navigate to next step for adding this account to background service. The following is temporary: */} - {isConnected && ( - <> - - Connection successful for "{alias}"! - - - Public key: {publicKey && shortenAddress(publicKey)} - - {appInfo && ( - <> - Name: {appInfo.name} - Version: {appInfo.version} - - )} - - )} - {!isConnected && ( - <> - - setAlias(e.target.value)} - variant={InputVariants.Text} - /> - - - - - - - )} + + setAlias(e.target.value)} + variant={InputVariants.Text} + /> + + + + + ); }; diff --git a/apps/extension/src/Setup/Ledger/LedgerConfirmation.tsx b/apps/extension/src/Setup/Ledger/LedgerConfirmation.tsx new file mode 100644 index 0000000000..d2291413f5 --- /dev/null +++ b/apps/extension/src/Setup/Ledger/LedgerConfirmation.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useContext, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ThemeContext } from "styled-components"; + +import { + Button, + ButtonVariant, + Icon, + IconName, + IconSize, +} from "@anoma/components"; +import { shortenAddress } from "@anoma/utils"; +import { useSanitizedParams } from "@anoma/hooks"; + +import { useRequester } from "hooks/useRequester"; +import { LedgerError } from "./Ledger.components"; +import { + TopSection, + TopSectionHeaderContainer, + TopSectionButtonContainer, + ButtonsContainer, + SubViewContainer, + UpperContentContainer, + Header1, + BodyText, +} from "Setup/Setup.components"; +import { Ports } from "router"; +import { AddLedgerAccountMsg } from "background/ledger/messages"; +import { closeCurrentTab } from "utils"; + +const LedgerConfirmation: React.FC = () => { + const navigate = useNavigate(); + const requester = useRequester(); + const themeContext = useContext(ThemeContext); + const { alias = "", address = "", publicKey = "" } = useSanitizedParams(); + const [error, setError] = useState(); + + const handleSubmitClick = useCallback(async (): Promise => { + try { + await requester.sendMessage( + Ports.Background, + new AddLedgerAccountMsg(alias, address, publicKey, { + account: 0, + change: 0, + index: 0, + }) + ); + closeCurrentTab(); + } catch (e) { + console.warn(e); + setError(`${e}`); + } + }, []); + + return ( + + + + navigate(-1)} style={{ cursor: "pointer" }}> + + + + + + + + Confirm Ledger Connection + + + {error && {error}} + + Connection successful for "{alias}"! + + Address: {address && shortenAddress(address)} + Add this address to your wallet? + + + + + ); +}; + +export default LedgerConfirmation; diff --git a/apps/extension/src/Setup/Setup.tsx b/apps/extension/src/Setup/Setup.tsx index 39665e7a94..b6a80eb25b 100644 --- a/apps/extension/src/Setup/Setup.tsx +++ b/apps/extension/src/Setup/Setup.tsx @@ -28,6 +28,7 @@ import { ImportAccount } from "./ImportAccount"; import { useRequester } from "hooks/useRequester"; import { SeedPhraseImport } from "./ImportAccount/Steps"; import { Completion, Password } from "./Common"; +import LedgerConfirmation from "./Ledger/LedgerConfirmation"; type AnimatedTransitionProps = { elementKey: string; @@ -259,7 +260,17 @@ export const Setup: React.FC = () => { path={`/${TopLevelRoute.Ledger}`} element={ - + + + } + /> + + } /> diff --git a/apps/extension/src/Setup/types.ts b/apps/extension/src/Setup/types.ts index fc06a99d2e..ec902b93c1 100644 --- a/apps/extension/src/Setup/types.ts +++ b/apps/extension/src/Setup/types.ts @@ -3,6 +3,7 @@ export enum TopLevelRoute { AccountCreation = "account-creation", ImportAccount = "restore-account", Ledger = "ledger", + LedgerConfirmation = "ledger-confirmation", } export enum AccountCreationRoute { diff --git a/apps/extension/src/background/approvals/handler.ts b/apps/extension/src/background/approvals/handler.ts index b8fb368eac..151c526ddd 100644 --- a/apps/extension/src/background/approvals/handler.ts +++ b/apps/extension/src/background/approvals/handler.ts @@ -24,8 +24,8 @@ export const getHandler: (service: ApprovalsService) => Handler = (service) => { const handleApproveTxMsg: ( service: ApprovalsService ) => InternalHandler = (service) => { - return async (_, { txMsg }) => { - return await service.approveTransfer(txMsg); + return async (_, { txMsg, accountType }) => { + return await service.approveTransfer(txMsg, accountType); }; }; diff --git a/apps/extension/src/background/approvals/messages.ts b/apps/extension/src/background/approvals/messages.ts index a1ee99967e..e5572231ad 100644 --- a/apps/extension/src/background/approvals/messages.ts +++ b/apps/extension/src/background/approvals/messages.ts @@ -52,8 +52,11 @@ export class SubmitApprovedTransferMsg extends Message { throw new Error("address must not be empty!"); } if (!this.password) { - throw new Error("Password is required to submitTx!"); + throw new Error( + "Password is required to submitTx for this type of account!" + ); } + return; } diff --git a/apps/extension/src/background/approvals/service.ts b/apps/extension/src/background/approvals/service.ts index 0927155eb3..4098f5bde6 100644 --- a/apps/extension/src/background/approvals/service.ts +++ b/apps/extension/src/background/approvals/service.ts @@ -4,24 +4,26 @@ import { deserialize } from "borsh"; import { v4 as uuid } from "uuid"; import BigNumber from "bignumber.js"; -import { SubmitTransferMsgSchema, TransferMsgValue } from "@anoma/types"; +import { + AccountType, + SubmitTransferMsgSchema, + TransferMsgValue, +} from "@anoma/types"; import { KVStore } from "@anoma/storage"; -import { ExtensionRequester } from "extension"; -import { KeyRingService } from "background/keyring"; -import { TabStore } from "background/keyring"; +import { KeyRingService, TabStore } from "background/keyring"; +import { LedgerService } from "background/ledger"; export class ApprovalsService { constructor( protected readonly txStore: KVStore, protected readonly connectedTabsStore: KVStore, protected readonly keyRingService: KeyRingService, - protected readonly chainId: string, - protected readonly requester: ExtensionRequester + protected readonly ledgerService: LedgerService ) {} // Deserialize transfer details and prompt user - async approveTransfer(txMsg: string): Promise { + async approveTransfer(txMsg: string, type?: AccountType): Promise { const txMsgBuffer = Buffer.from(fromBase64(txMsg)); const id = uuid(); this.txStore.set(id, txMsg); @@ -36,7 +38,7 @@ export class ApprovalsService { const amount = new BigNumber(amountBN.toString()); const url = `${browser.runtime.getURL( "approvals.html" - )}#/tx?id=${id}&source=${source}&target=${target}&token=${token}&amount=${amount}`; + )}#/tx?type=${type}&id=${id}&source=${source}&target=${target}&token=${token}&amount=${amount}`; browser.windows.create({ url, @@ -53,20 +55,21 @@ export class ApprovalsService { await this._clearPendingTx(msgId); } - // Authenticate keyring, submit approved transaction from storage + // Authenticate keyring and submit approved transaction from storage async submitTransfer(msgId: string, password: string): Promise { - // TODO: Use executeUntil here: try { await this.keyRingService.unlock(password); } catch (e) { throw new Error(`${e}`); } + // Fetch pending tx const tx = await this.txStore.get(msgId); + // Validate account type if (tx) { await this.keyRingService.submitTransfer(tx, msgId); - // Clean up storage + return await this._clearPendingTx(msgId); } diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts index 91c9980615..014f6d7fee 100644 --- a/apps/extension/src/background/index.ts +++ b/apps/extension/src/background/index.ts @@ -26,6 +26,7 @@ import { SDK_KEY, PARENT_ACCOUNT_ID_KEY, } from "./keyring"; +import { LedgerService, init as initLedger } from "./ledger"; const store = new IndexedDBKVStore(KVPrefix.IndexedDB); @@ -71,12 +72,12 @@ const { REACT_APP_NAMADA_URL = DEFAULT_URL } = process.env; const sdkData: Record | undefined = await sdkStore.get( SDK_KEY ); - const activeAccount = await activeAccountStore.get( - PARENT_ACCOUNT_ID_KEY - ); + const activeAccount = await activeAccountStore.get<{ + id: string; + }>(PARENT_ACCOUNT_ID_KEY); if (sdkData && activeAccount) { - const data = new TextEncoder().encode(sdkData[activeAccount]); + const data = new TextEncoder().encode(sdkData[activeAccount.id]); sdk.decode(data); } @@ -93,18 +94,26 @@ const { REACT_APP_NAMADA_URL = DEFAULT_URL } = process.env; cryptoMemory, requester ); + const ledgerService = new LedgerService( + keyRingService, + store, + connectedTabsStore, + txStore, + defaultChainId, + sdk + ); const approvalsService = new ApprovalsService( txStore, connectedTabsStore, keyRingService, - defaultChainId, - requester + ledgerService ); // Initialize messages and handlers initApprovals(router, approvalsService); initChains(router, chainsService); initKeyRing(router, keyRingService); + initLedger(router, ledgerService); router.listen(Ports.Background); })(); diff --git a/apps/extension/src/background/keyring/handler.ts b/apps/extension/src/background/keyring/handler.ts index 3705a68d1b..b33b52bd3a 100644 --- a/apps/extension/src/background/keyring/handler.ts +++ b/apps/extension/src/background/keyring/handler.ts @@ -283,8 +283,8 @@ const handleSetActiveAccountMsg: ( service: KeyRingService ) => InternalHandler = (service) => { return async (_, msg) => { - const { accountId } = msg; - return await service.setActiveAccountId(accountId); + const { accountId, accountType } = msg; + return await service.setActiveAccount(accountId, accountType); }; }; @@ -292,7 +292,7 @@ const handleGetActiveAccountMsg: ( service: KeyRingService ) => InternalHandler = (service) => { return async (_, _msg) => { - return await service.getActiveAccountId(); + return await service.getActiveAccount(); }; }; diff --git a/apps/extension/src/background/keyring/keyring.ts b/apps/extension/src/background/keyring/keyring.ts index 88a82a89b1..3e16988b68 100644 --- a/apps/extension/src/background/keyring/keyring.ts +++ b/apps/extension/src/background/keyring/keyring.ts @@ -1,6 +1,7 @@ -import { v5 as uuid } from "uuid"; import BigNumber from "bignumber.js"; +import { deserialize } from "borsh"; +import { chains } from "@anoma/chains"; import { HDWallet, Mnemonic, @@ -25,39 +26,25 @@ import { SubmitTransferMsgSchema, TransferMsgValue, } from "@anoma/types"; -import { chains } from "@anoma/chains"; +import { + readVecStringPointer, + readStringPointer, +} from "@anoma/crypto/src/utils"; +import { makeBip44Path, Result } from "@anoma/utils"; + import { Crypto } from "./crypto"; import { + DeleteAccountError, + ActiveAccountStore, KeyRingStatus, KeyStore, ResetPasswordError, - DeleteAccountError, } from "./types"; -import { - readVecStringPointer, - readStringPointer, -} from "@anoma/crypto/src/utils"; -import { deserialize } from "borsh"; -import { Result } from "@anoma/utils"; +import { getAccountValuesFromStore, generateId } from "utils"; // Generated UUID namespace for uuid v5 const UUID_NAMESPACE = "9bfceade-37fe-11ed-acc0-a3da3461b38c"; -// Construct unique uuid, passing in an arbitray number of arguments. -// This could be a unique parameter of the object receiving the id, -// or an index based on the number of existing objects in a hierarchy. -const getId = (name: string, ...args: (number | string)[]): string => { - // Ensure a unique UUID - let uuidName = name; - - // Concatenate any number of args onto the name parameter - args.forEach((arg) => { - uuidName = `${uuidName}::${arg}`; - }); - - return uuid(uuidName, UUID_NAMESPACE); -}; - export const KEYSTORE_KEY = "key-store"; export const SDK_KEY = "sdk-store"; export const PARENT_ACCOUNT_ID_KEY = "parent-account-id"; @@ -83,7 +70,7 @@ export class KeyRing { constructor( protected readonly kvStore: KVStore, protected readonly sdkStore: KVStore>, - protected readonly activeAccountStore: KVStore, + protected readonly activeAccountStore: KVStore, protected readonly extensionStore: KVStore, protected readonly chainId: string, protected readonly sdk: Sdk, @@ -114,17 +101,19 @@ export class KeyRing { } } - public async getActiveAccountId(): Promise { + public async getActiveAccount(): Promise { return await this.activeAccountStore.get(PARENT_ACCOUNT_ID_KEY); } - public async setActiveAccountId(parentId: string): Promise { - await this.activeAccountStore.set(PARENT_ACCOUNT_ID_KEY, parentId); - + public async setActiveAccount( + id: string, + type: AccountType.Mnemonic | AccountType.Ledger + ): Promise { + await this.activeAccountStore.set(PARENT_ACCOUNT_ID_KEY, { id, type }); // To sync sdk wallet with DB const sdkData = await this.sdkStore.get(SDK_KEY); if (sdkData) { - this.sdk.decode(new TextEncoder().encode(sdkData[parentId])); + this.sdk.decode(new TextEncoder().encode(sdkData[id])); } } @@ -133,7 +122,7 @@ export class KeyRing { accountId?: string ): Promise { // default to active account if no account provided - const idToCheck = accountId ?? (await this.getActiveAccountId()); + const idToCheck = accountId ?? (await this.getActiveAccount())?.id; const mnemonic = await this._keyStore.getRecord("id", idToCheck); // TODO: Generate arbitrary data to check decryption against @@ -185,7 +174,7 @@ export class KeyRing { } // change password held locally if active account password changed - if (accountId === (await this.getActiveAccountId())) { + if (accountId === (await this.getActiveAccount())?.id) { this._password = newPassword; } } catch (error) { @@ -232,19 +221,24 @@ export class KeyRing { const mnemonic = Mnemonic.from_phrase(phrase); const seed = mnemonic.to_seed(); const { coinType } = chains[this.chainId].bip44; - const path = `m/44'/${coinType}'/0'/0`; - const bip44 = new HDWallet(seed); - const account = bip44.derive(path); + const path = { account: 0, change: 0 }; + const bip44Path = makeBip44Path(coinType, path); + const hdWallet = new HDWallet(seed); + const account = hdWallet.derive(bip44Path); const stringPointer = account.private().to_hex(); const sk = readStringPointer(stringPointer, this._cryptoMemory); const address = new Address(sk).implicit(); const { chainId } = this; // Generate unique ID for new parent account: - const id = getId(phrase, (await this._keyStore.get()).length); + const id = generateId( + UUID_NAMESPACE, + phrase, + (await this._keyStore.get()).length + ); mnemonic.free(); - bip44.free(); + hdWallet.free(); account.free(); stringPointer.free(); @@ -255,10 +249,7 @@ export class KeyRing { owner: address, chainId, password, - path: { - account: 0, - change: 0, - }, + path, text: phrase, type: AccountType.Mnemonic, }); @@ -268,7 +259,7 @@ export class KeyRing { // to prevent adding top level secret key to existing keys this.sdk.clear_storage(); await this.addSecretKey(sk, password, alias, id); - await this.setActiveAccountId(id); + await this.setActiveAccount(id, AccountType.Mnemonic); this._password = password; return true; @@ -283,26 +274,26 @@ export class KeyRing { path: Bip44Path, parentId: string ): DerivedAccountInfo { - const { account, change, index = 0 } = path; - const root = "m/44'"; const { coinType } = chains[this.chainId].bip44; - const derivationPath = [ - root, - `${coinType}'`, - `${account}`, - change, - index, - ].join("/"); - const bip44 = new HDWallet(seed); - const derivedAccount = bip44.derive(derivationPath); - + const derivationPath = makeBip44Path(coinType, path); + const hdWallet = new HDWallet(seed); + const derivedAccount = hdWallet.derive(derivationPath); const privateKey = derivedAccount.private(); const hex = privateKey.to_hex(); const text = readStringPointer(hex, this.cryptoMemory); const address = new Address(text).implicit(); - const id = getId("account", parentId, account, change, index); - bip44.free(); + const { account, change, index = 0 } = path; + const id = generateId( + UUID_NAMESPACE, + "account", + parentId, + account, + change, + index + ); + + hdWallet.free(); derivedAccount.free(); privateKey.free(); hex.free(); @@ -321,7 +312,7 @@ export class KeyRing { parentId: string ): DerivedAccountInfo { const { index = 0 } = path; - const id = getId("shielded-account", parentId, index); + const id = generateId("shielded-account", parentId, index); const zip32 = new ShieldedHDWallet(seed); const account = zip32.derive_to_serialized_keys(index); @@ -453,9 +444,10 @@ export class KeyRing { parentId: string; seed: VecU8Pointer; }> { + const activeAccount = await this.getActiveAccount(); const storedMnemonic = await this._keyStore.getRecord( "id", - await this.getActiveAccountId() + activeAccount?.id ); if (!storedMnemonic) { @@ -553,28 +545,18 @@ export class KeyRing { /** * Query accounts from storage (active parent account + associated derived child accounts) */ - public async queryAccounts(): Promise { - const activeAccountId = await this.getActiveAccountId(); + public async queryAccounts( + activeAccountId: string + ): Promise { const parentAccount = await this._keyStore.getRecord("id", activeAccountId); + const derivedAccounts = (await this._keyStore.getRecords("parentId", activeAccountId)) || []; if (parentAccount) { const accounts = [parentAccount, ...derivedAccounts]; - // Return only non-encrypted data - - return accounts.map( - ({ address, alias, chainId, path, parentId, id, type }) => ({ - address, - alias, - chainId, - id, - parentId, - path, - type, - }) - ); + return getAccountValuesFromStore(accounts); } return []; } @@ -583,22 +565,10 @@ export class KeyRing { * Query all top-level parent accounts (mnemonic accounts) */ public async queryParentAccounts(): Promise { - const accounts = await this._keyStore.getRecords( - "type", - AccountType.Mnemonic - ); + const accounts = + (await this._keyStore.getRecords("type", AccountType.Mnemonic)) || []; // Return only non-encrypted data - return (accounts || []).map( - ({ address, alias, chainId, path, parentId, id, type }) => ({ - address, - alias, - chainId, - id, - parentId, - path, - type, - }) - ); + return getAccountValuesFromStore(accounts); } async encodeInitAccount( @@ -716,7 +686,7 @@ export class KeyRing { } // remove password held locally if active account deleted - if (accountId === (await this.getActiveAccountId())) { + if (accountId === (await this.getActiveAccount())?.id) { this._password = undefined; } @@ -731,15 +701,10 @@ export class KeyRing { } async queryBalances( - address: string + owner: string ): Promise<{ token: string; amount: string }[]> { - const account = await this._keyStore.getRecord("address", address); - if (!account) { - throw new Error(`Account not found.`); - } - try { - return (await this.query.query_balance(account.owner)).map( + return (await this.query.query_balance(owner)).map( ([token, amount]: [string, string]) => { return { token, @@ -760,6 +725,10 @@ export class KeyRing { activeAccountId: string ): Promise { this.sdk.add_key(secretKey, password, alias); + await this.initSdkStore(activeAccountId); + } + + public async initSdkStore(activeAccountId: string): Promise { const store = (await this.sdkStore.get(SDK_KEY)) || {}; this.sdkStore.set(SDK_KEY, { diff --git a/apps/extension/src/background/keyring/messages.ts b/apps/extension/src/background/keyring/messages.ts index c0bd0ed407..7ddfb72788 100644 --- a/apps/extension/src/background/keyring/messages.ts +++ b/apps/extension/src/background/keyring/messages.ts @@ -2,7 +2,12 @@ import { PhraseSize } from "@anoma/crypto"; import { AccountType, Bip44Path, DerivedAccount } from "@anoma/types"; import { Message } from "router"; import { ROUTE } from "./constants"; -import { KeyRingStatus, ResetPasswordError, DeleteAccountError } from "./types"; +import { + KeyRingStatus, + ResetPasswordError, + DeleteAccountError, + ParentAccount, +} from "./types"; import { Result } from "@anoma/utils"; enum MessageType { @@ -237,7 +242,7 @@ export class ScanAccountsMsg extends Message { } // eslint-disable-next-line @typescript-eslint/no-empty-function - validate(): void {} + validate(): void { } route(): string { return ROUTE; @@ -294,7 +299,10 @@ export class SetActiveAccountMsg extends Message { return MessageType.SetActiveAccount; } - constructor(public readonly accountId: string) { + constructor( + public readonly accountId: string, + public readonly accountType: ParentAccount + ) { super(); } @@ -302,6 +310,10 @@ export class SetActiveAccountMsg extends Message { if (!this.accountId) { throw new Error("Account ID is not set!"); } + + if (!this.accountType) { + throw new Error("Account Type is required!"); + } } route(): string { @@ -313,7 +325,9 @@ export class SetActiveAccountMsg extends Message { } } -export class GetActiveAccountMsg extends Message { +export class GetActiveAccountMsg extends Message< + { id: string; type: ParentAccount } | undefined +> { public static type(): MessageType { return MessageType.GetActiveAccount; } diff --git a/apps/extension/src/background/keyring/service.ts b/apps/extension/src/background/keyring/service.ts index 558824546f..e7ece84ec7 100644 --- a/apps/extension/src/background/keyring/service.ts +++ b/apps/extension/src/background/keyring/service.ts @@ -1,18 +1,21 @@ import { fromBase64, toBase64 } from "@cosmjs/encoding"; import { PhraseSize } from "@anoma/crypto"; -import { KVStore } from "@anoma/storage"; +import { KVStore, Store } from "@anoma/storage"; import { AccountType, Bip44Path, DerivedAccount } from "@anoma/types"; import { Query, Sdk } from "@anoma/shared"; import { Result } from "@anoma/utils"; -import { KeyRing } from "./keyring"; +import { KeyRing, KEYSTORE_KEY } from "./keyring"; import { KeyRingStatus, KeyStore, TabStore, ResetPasswordError, DeleteAccountError, + ParentAccount, + ActiveAccountStore, + AccountStore, } from "./types"; import { syncTabs, updateTabStorage } from "./utils"; import { ExtensionRequester, getAnomaRouterId } from "extension"; @@ -30,14 +33,18 @@ import { SUBMIT_TRANSFER_MSG_TYPE, } from "background/offscreen"; import { init as initSubmitTransferWebWorker } from "background/web-workers"; +import { LEDGERSTORE_KEY } from "background/ledger"; +import { getAccountValuesFromStore } from "utils"; export class KeyRingService { private _keyRing: KeyRing; + private _keyRingStore: Store; + private _ledgerStore: Store; constructor( protected readonly kvStore: KVStore, protected readonly sdkStore: KVStore>, - protected readonly accountAccountStore: KVStore, + protected readonly activeAccountStore: KVStore, protected readonly connectedTabsStore: KVStore, protected readonly extensionStore: KVStore, protected readonly chainId: string, @@ -49,13 +56,15 @@ export class KeyRingService { this._keyRing = new KeyRing( kvStore, sdkStore, - accountAccountStore, + activeAccountStore, extensionStore, chainId, sdk, query, cryptoMemory ); + this._ledgerStore = new Store(LEDGERSTORE_KEY, kvStore); + this._keyRingStore = new Store(KEYSTORE_KEY, kvStore); } lock(): { status: KeyRingStatus } { @@ -145,11 +154,36 @@ export class KeyRingService { } async queryAccounts(): Promise { - return await this._keyRing.queryAccounts(); + const { id, type } = (await this.getActiveAccount()) || {}; + + if (type !== AccountType.Ledger && id) { + // Query KeyRing accounts + return await this._keyRing.queryAccounts(id); + } + + // Query Ledger accounts + const parent = await this._ledgerStore.getRecord("id", id); + + if (parent) { + const accounts = [ + parent, + ...((await this._ledgerStore.getRecords("parentId", id)) || []), + ]; + + return getAccountValuesFromStore(accounts); + } + + throw new Error(`No accounts found for ${id} ${type}`); } async queryParentAccounts(): Promise { - return await this._keyRing.queryParentAccounts(); + const ledgerAccounts = + (await this._ledgerStore.getRecords("parentId", undefined)) || []; + + return [ + ...(await this._keyRing.queryParentAccounts()), + ...getAccountValuesFromStore(ledgerAccounts), + ]; } async submitBond(txMsg: string): Promise { @@ -282,14 +316,13 @@ export class KeyRingService { return toBase64(tx_data); } - async setActiveAccountId(accountId: string): Promise { - await this._keyRing.setActiveAccountId(accountId); - + async setActiveAccount(id: string, type: ParentAccount): Promise { + await this._keyRing.setActiveAccount(id, type); await this.broadcastAccountsChanged(); } - async getActiveAccountId(): Promise { - return await this._keyRing.getActiveAccountId(); + async getActiveAccount(): Promise { + return await this._keyRing.getActiveAccount(); } async handleTransferCompleted( @@ -390,8 +423,20 @@ export class KeyRingService { } async queryBalances( - owner: string + address: string ): Promise<{ token: string; amount: string }[]> { - return this._keyRing.queryBalances(owner); + // Validate account + const account = + (await this._keyRingStore.getRecord("address", address)) || + (await this._ledgerStore.getRecord("address", address)); + + if (!account) { + throw new Error("Account not found!"); + } + return this._keyRing.queryBalances(account.owner); + } + + async initSdkStore(activeAccountId: string): Promise { + return await this._keyRing.initSdkStore(activeAccountId); } } diff --git a/apps/extension/src/background/keyring/types.ts b/apps/extension/src/background/keyring/types.ts index 4768c786e8..b132ca4adc 100644 --- a/apps/extension/src/background/keyring/types.ts +++ b/apps/extension/src/background/keyring/types.ts @@ -1,3 +1,4 @@ +import { StoredRecord } from "@anoma/storage"; import { AccountType, Bip44Path, DerivedAccount } from "@anoma/types"; export enum KdfType { @@ -21,12 +22,18 @@ export type ScryptParams = KdfParams & { p: number; }; -export interface KeyStore { - id: string; +export interface AccountStore extends StoredRecord { alias: string; address: string; owner: string; chainId: string; + publicKey?: string; + path: Bip44Path; + parentId?: string; + type: AccountType; +} + +export interface KeyStore extends AccountStore { crypto: { cipher: { type: "aes-256-gcm"; @@ -38,12 +45,6 @@ export interface KeyStore { params: T; }; }; - meta?: { - [key: string]: string; - }; - path: Bip44Path; - parentId?: string; - type: AccountType; } export type AccountState = DerivedAccount & { @@ -69,6 +70,13 @@ export enum ResetPasswordError { KeyStoreError, } +export type ParentAccount = AccountType.Mnemonic | AccountType.Ledger; + +export type ActiveAccountStore = { + id: string; + type: ParentAccount; +}; + export enum DeleteAccountError { BadPassword, KeyStoreError, diff --git a/apps/extension/src/background/ledger/constants.ts b/apps/extension/src/background/ledger/constants.ts new file mode 100644 index 0000000000..57854a916f --- /dev/null +++ b/apps/extension/src/background/ledger/constants.ts @@ -0,0 +1 @@ +export const ROUTE = "ledger-route"; diff --git a/apps/extension/src/background/ledger/handler.ts b/apps/extension/src/background/ledger/handler.ts new file mode 100644 index 0000000000..130157ba7f --- /dev/null +++ b/apps/extension/src/background/ledger/handler.ts @@ -0,0 +1,58 @@ +import { Handler, Env, Message, InternalHandler } from "router"; +import { LedgerService } from "./service"; +import { + AddLedgerAccountMsg, + GetTransferBytesMsg, + SubmitSignedTransferMsg, +} from "./messages"; + +export const getHandler: (service: LedgerService) => Handler = (service) => { + return (env: Env, msg: Message) => { + switch (msg.constructor) { + case AddLedgerAccountMsg: + return handleAddLedgerAccountMsg(service)( + env, + msg as AddLedgerAccountMsg + ); + case GetTransferBytesMsg: + return handleGetTransferBytesMsg(service)( + env, + msg as GetTransferBytesMsg + ); + case SubmitSignedTransferMsg: + return handleSubmitSignedTransferMsg(service)( + env, + msg as SubmitSignedTransferMsg + ); + default: + throw new Error("Unknown msg type"); + } + }; +}; + +const handleAddLedgerAccountMsg: ( + service: LedgerService +) => InternalHandler = (service) => { + return async (_, msg) => { + const { alias, address, publicKey, bip44Path } = msg; + return await service.addAccount(alias, address, publicKey, bip44Path); + }; +}; + +const handleGetTransferBytesMsg: ( + service: LedgerService +) => InternalHandler = (service) => { + return async (_, msg) => { + const { msgId } = msg; + return await service.getTransferBytes(msgId); + }; +}; + +const handleSubmitSignedTransferMsg: ( + service: LedgerService +) => InternalHandler = (service) => { + return async (_, msg) => { + const { bytes, publicKey, msgId, signatures } = msg; + return await service.submitTransfer(msgId, bytes, signatures, publicKey); + }; +}; diff --git a/apps/extension/src/background/ledger/index.ts b/apps/extension/src/background/ledger/index.ts index d1cd2cc47a..f2a87c7999 100644 --- a/apps/extension/src/background/ledger/index.ts +++ b/apps/extension/src/background/ledger/index.ts @@ -1 +1,4 @@ +export * from "./constants"; +export * from "./init"; export * from "./ledger"; +export * from "./service"; diff --git a/apps/extension/src/background/ledger/init.ts b/apps/extension/src/background/ledger/init.ts new file mode 100644 index 0000000000..7ca957544a --- /dev/null +++ b/apps/extension/src/background/ledger/init.ts @@ -0,0 +1,17 @@ +import { Router } from "router"; +import { ROUTE } from "./constants"; +import { + AddLedgerAccountMsg, + GetTransferBytesMsg, + SubmitSignedTransferMsg, +} from "./messages"; +import { getHandler } from "./handler"; +import { LedgerService } from "./service"; + +export function init(router: Router, service: LedgerService): void { + router.registerMessage(AddLedgerAccountMsg); + router.registerMessage(GetTransferBytesMsg); + router.registerMessage(SubmitSignedTransferMsg); + + router.addHandler(ROUTE, getHandler(service)); +} diff --git a/apps/extension/src/background/ledger/ledger.ts b/apps/extension/src/background/ledger/ledger.ts index 13fd0b08e9..a09965c5de 100644 --- a/apps/extension/src/background/ledger/ledger.ts +++ b/apps/extension/src/background/ledger/ledger.ts @@ -3,15 +3,17 @@ import { ResponseAppInfo, ResponseSign, ResponseVersion, + LedgerError, } from "@zondax/ledger-namada"; import TransportUSB from "@ledgerhq/hw-transport-webusb"; import TransportHID from "@ledgerhq/hw-transport-webhid"; import Transport from "@ledgerhq/hw-transport"; import { defaultChainId, chains } from "@anoma/chains"; +import { makeBip44Path } from "@anoma/utils"; const namadaChain = chains[defaultChainId]; -const bip44CoinType = namadaChain.bip44.coinType; +const { coinType } = namadaChain.bip44; export const initLedgerUSBTransport = async (): Promise => { return await TransportHID.create(); @@ -21,12 +23,13 @@ export const initLedgerHIDTransport = async (): Promise => { return await TransportUSB.create(); }; -export const DEFAULT_LEDGER_BIP44_PATH = `m/44'/${bip44CoinType}'/0'/0/0`; +export const DEFAULT_LEDGER_BIP44_PATH = makeBip44Path(coinType, { + account: 0, + change: 0, + index: 0, +}); export class Ledger { - public version: ResponseVersion | undefined; - public info: ResponseAppInfo | undefined; - constructor(public readonly namadaApp: NamadaApp | undefined = undefined) {} /** @@ -38,79 +41,92 @@ export class Ledger { const namadaApp = new NamadaApp(initializedTransport); const ledger = new Ledger(namadaApp); - ledger.version = await namadaApp.getVersion(); - ledger.info = await namadaApp.getAppInfo(); - return ledger; } /** * Return status and version info of initialized NamadaApp */ - public status(): { - appName: string; - appVersion: string; - errorMessage: string; - returnCode: number; - deviceLocked: boolean; - major: number; - minor: number; - patch: number; - targetId: string; - testMode: boolean; - } { - if (!this.version || !this.info) { + public async status(): Promise<{ + version: ResponseVersion; + info: ResponseAppInfo; + }> { + if (!this.namadaApp) { throw new Error("NamadaApp is not initialized!"); } - - const { appName, appVersion, errorMessage, returnCode } = this.info; - const { - major, - minor, - patch, - targetId, - deviceLocked = false, - testMode, - } = this.version; + const version = await this.namadaApp.getVersion(); + const info = await this.namadaApp.getAppInfo(); return { - appName, - appVersion, - errorMessage, - returnCode, - deviceLocked, - major, - minor, - patch, - targetId, - testMode, + version, + info, }; } /** - * Get public key associated with optional path, otherwise, use default path + * Get address and public key associated with optional path, otherwise, use default path */ - public async getPublicKey(bip44Path?: string): Promise { + public async getAddressAndPublicKey( + bip44Path?: string + ): Promise<{ address: string; publicKey: string }> { if (!this.namadaApp) { throw new Error("NamadaApp is not initialized!"); } const path = bip44Path || DEFAULT_LEDGER_BIP44_PATH; - const { publicKey } = await this.namadaApp.getAddressAndPubKey(path); + const { address, publicKey } = await this.namadaApp.getAddressAndPubKey( + path + ); - return publicKey.toString("hex"); + return { + // Return address as bech32-encoded string + address: address.toString(), + // Return public key as hex-encoded string + publicKey: publicKey.toString("hex"), + }; } /** * Sign tx bytes with the key associated with provided (or default) path + * + * @async + * @param {Uint8Array} tx - tx data blob to sign + * @param {string} bip44Path (optional) - Bip44 path for signing account + * + * @returns {ResponseSign} */ - public async sign(tx: ArrayBuffer, bip44Path: string): Promise { + public async sign(tx: Uint8Array, bip44Path?: string): Promise { if (!this.namadaApp) { throw new Error("NamadaApp is not initialized!"); } - + const buffer = Buffer.from(tx); const path = bip44Path || DEFAULT_LEDGER_BIP44_PATH; - return await this.namadaApp.sign(path, Buffer.from(tx)); + return await this.namadaApp.sign(path, buffer); + } + + /** + * Query status to determine if device has thrown an error + */ + public async queryErrors(): Promise { + const { + info: { returnCode, errorMessage }, + } = await this.status(); + + if (returnCode !== LedgerError.NoErrors) { + return errorMessage; + } + return ""; + } + + /** + * Close the initialized transport, which may be needed if Ledger needs to be reinitialized due to error state + */ + public async closeTransport(): Promise { + if (!this.namadaApp) { + throw new Error("NamadaApp is not initialized!"); + } + + return await this.namadaApp.transport.close(); } } diff --git a/apps/extension/src/background/ledger/messages.ts b/apps/extension/src/background/ledger/messages.ts new file mode 100644 index 0000000000..2d8ec228f4 --- /dev/null +++ b/apps/extension/src/background/ledger/messages.ts @@ -0,0 +1,121 @@ +import { ResponseSign } from "@zondax/ledger-namada"; + +import { Bip44Path } from "@anoma/types"; + +import { Message } from "router"; +import { ROUTE } from "./constants"; + +enum MessageType { + AddLedgerAccount = "add-ledger-account", + GetTransferBytes = "get-transfer-bytes", + SubmitSignedTransfer = "submit-signed-transfer", +} + +export class AddLedgerAccountMsg extends Message { + public static type(): MessageType { + return MessageType.AddLedgerAccount; + } + + constructor( + public readonly alias: string, + public readonly address: string, + public readonly publicKey: string, + public readonly bip44Path: Bip44Path + ) { + super(); + } + + validate(): void { + if (!this.alias) { + throw new Error("Alias must not be empty!"); + } + + if (!this.address) { + throw new Error("Address was not provided!"); + } + + if (!this.publicKey) { + throw new Error("Public key was not provided!"); + } + + if (!this.bip44Path) { + throw new Error("BIP44 Path was not provided!"); + } + } + + route(): string { + return ROUTE; + } + + type(): string { + return AddLedgerAccountMsg.type(); + } +} + +export class GetTransferBytesMsg extends Message<{ + bytes: Uint8Array; + path: string; +}> { + public static type(): MessageType { + return MessageType.GetTransferBytes; + } + + constructor(public readonly msgId: string) { + super(); + } + + validate(): void { + if (!this.msgId) { + throw new Error("msgId was not provided!"); + } + } + + route(): string { + return ROUTE; + } + + type(): string { + return GetTransferBytesMsg.type(); + } +} + +export class SubmitSignedTransferMsg extends Message { + public static type(): MessageType { + return MessageType.SubmitSignedTransfer; + } + + constructor( + public readonly msgId: string, + public readonly bytes: string, + public readonly signatures: ResponseSign, + public readonly publicKey: string + ) { + super(); + } + + validate(): void { + if (!this.msgId) { + throw new Error("msgId was not provided!"); + } + + if (!this.bytes) { + throw new Error("bytes were not provided!"); + } + + if (!this.signatures) { + throw new Error("No signatures were provided!"); + } + + if (!this.publicKey) { + throw new Error("No publicKey provided!"); + } + } + + route(): string { + return ROUTE; + } + + type(): string { + return SubmitSignedTransferMsg.type(); + } +} diff --git a/apps/extension/src/background/ledger/service.ts b/apps/extension/src/background/ledger/service.ts new file mode 100644 index 0000000000..c0bdd24f85 --- /dev/null +++ b/apps/extension/src/background/ledger/service.ts @@ -0,0 +1,142 @@ +import { fromBase64, toHex } from "@cosmjs/encoding"; +import { deserialize } from "borsh"; +import { ResponseSign } from "@zondax/ledger-namada"; + +import { + AccountType, + Bip44Path, + SubmitTransferMsgSchema, + TransferMsgValue, +} from "@anoma/types"; +import { Sdk } from "@anoma/shared"; +import { IStore, KVStore, Store } from "@anoma/storage"; +import { chains } from "@anoma/chains"; +import { makeBip44Path } from "@anoma/utils"; + +import { AccountStore, KeyRingService, TabStore } from "background/keyring"; +import { generateId } from "utils"; + +export const LEDGERSTORE_KEY = "ledger-store"; +const UUID_NAMESPACE = "be9fdaee-ffa2-11ed-8ef1-325096b39f47"; + +export class LedgerService { + private _ledgerStore: IStore; + + constructor( + protected readonly keyring: KeyRingService, + protected readonly kvStore: KVStore, + protected readonly connectedTabsStore: KVStore, + protected readonly txStore: KVStore, + protected readonly chainId: string, + protected readonly sdk: Sdk + ) { + this._ledgerStore = new Store(LEDGERSTORE_KEY, kvStore); + } + + async getTransferBytes( + msgId: string + ): Promise<{ bytes: Uint8Array; path: string }> { + const txMsg = await this.txStore.get(msgId); + const { coinType } = chains[this.chainId].bip44; + + if (!txMsg) { + throw new Error(`Transaction ${msgId} not found!`); + } + + try { + // Deserialize txMsg to retrieve source + const { source } = deserialize( + SubmitTransferMsgSchema, + TransferMsgValue, + Buffer.from(fromBase64(txMsg)) + ); + + // Query account from Ledger storage to determine path for signer + const account = await this._ledgerStore.getRecord("address", source); + + if (!account) { + throw new Error(`Ledger account not found for ${source}`); + } + + const bytes = await this.sdk.build_transfer(fromBase64(txMsg)); + const path = makeBip44Path(coinType, account.path); + + return { bytes, path }; + } catch (e) { + console.warn(e); + throw new Error(`${e}`); + } + } + + async submitTransfer( + msgId: string, + bytes: string, + signatures: ResponseSign, + publicKey: string + ): Promise { + const txMsg = await this.txStore.get(msgId); + + if (!txMsg) { + throw new Error(`Transaction ${msgId} not found!`); + } + + const { headerSignature, dataSignature } = signatures; + + const signedTransfer = await this.sdk.sign_tx( + fromBase64(bytes), + toHex(dataSignature.signature), + toHex(headerSignature.signature) + ); + + try { + await this.sdk.submit_signed_transfer( + publicKey, + fromBase64(txMsg), + signedTransfer + ); + + // Clear pending tx if successful + await this.txStore.set(msgId, null); + } catch (e) { + console.warn(e); + } + } + + /** + * Append a new address record for use with Ledger + */ + async addAccount( + alias: string, + address: string, + publicKey: string, + bip44Path: Bip44Path + ): Promise { + // Check if account exists in storage, return if so: + const record = await this._ledgerStore.getRecord("address", address); + if (record) { + return; + } + + // Generate a UUID v5 unique id from alias & path + const id = generateId(UUID_NAMESPACE, alias, address); + + const account = { + id, + alias, + address, + publicKey, + owner: address, + chainId: this.chainId, + path: bip44Path, + type: AccountType.Ledger, + }; + await this._ledgerStore.append(account); + + // Prepare SDK store + this.sdk.clear_storage(); + await this.keyring.initSdkStore(id); + + // Set active account ID + await this.keyring.setActiveAccount(id, AccountType.Ledger); + } +} diff --git a/apps/extension/src/background/web-workers/submit-transfer-web-worker.ts b/apps/extension/src/background/web-workers/submit-transfer-web-worker.ts index 2a5b9f5b8a..9cca29bba7 100644 --- a/apps/extension/src/background/web-workers/submit-transfer-web-worker.ts +++ b/apps/extension/src/background/web-workers/submit-transfer-web-worker.ts @@ -22,12 +22,12 @@ import { const sdkData: Record | undefined = await sdkStore.get( "sdk-store" ); - const activeAccount = await activeAccountStore.get( + const activeAccount = await activeAccountStore.get<{ id: string }>( "parent-account-id" ); if (sdkData && activeAccount) { - const data = new TextEncoder().encode(sdkData[activeAccount]); + const data = new TextEncoder().encode(sdkData[activeAccount.id]); sdk.decode(data); } diff --git a/apps/extension/src/provider/Anoma.test.ts b/apps/extension/src/provider/Anoma.test.ts index a272a1a799..0922512e17 100644 --- a/apps/extension/src/provider/Anoma.test.ts +++ b/apps/extension/src/provider/Anoma.test.ts @@ -14,13 +14,15 @@ import { TransferProps, Chain, Anoma, + AccountType, } from "@anoma/types"; import { KVKeys } from "router"; import { init, KVStoreMock } from "test/init"; import { chains as defaultChains } from "@anoma/chains"; -import { chain, keyStore, password, ACTIVE_ACCOUNT_ID } from "./data.mock"; +import { chain, keyStore, password, ACTIVE_ACCOUNT } from "./data.mock"; import { + ActiveAccountStore, KeyRing, KeyRingService, KeyStore, @@ -36,7 +38,7 @@ jest.mock("webextension-polyfill", () => ({})); describe("Anoma", () => { let anoma: Anoma; let iDBStore: KVStoreMock; - let activeAccountStore: KVStoreMock; + let activeAccountStore: KVStoreMock; let keyRingService: KeyRingService; beforeAll(async () => { @@ -70,10 +72,12 @@ describe("Anoma", () => { it("should return all accounts", async () => { iDBStore.set(KEYSTORE_KEY, keyStore); - activeAccountStore.set(PARENT_ACCOUNT_ID_KEY, ACTIVE_ACCOUNT_ID); - const storedKeyStore = keyStore.map( - ({ crypto: _crypto, owner: _owner, ...account }) => account - ); + + activeAccountStore.set(PARENT_ACCOUNT_ID_KEY, { + id: ACTIVE_ACCOUNT.id, + type: AccountType.Mnemonic, + }); + const storedKeyStore = keyStore.map(({ crypto: _, ...account }) => account); const storedAccounts = await anoma.accounts(chain.chainId); expect(storedAccounts).toEqual(storedKeyStore); @@ -113,7 +117,10 @@ describe("Anoma", () => { ); jest.spyOn(keyRingService, "submitTransfer"); - anoma.submitTransfer(toBase64(serializedTransfer)); + anoma.submitTransfer({ + txMsg: toBase64(serializedTransfer), + type: AccountType.PrivateKey, + }); expect(keyRingService.submitTransfer).toBeCalled(); }); diff --git a/apps/extension/src/provider/Anoma.ts b/apps/extension/src/provider/Anoma.ts index 9599037f5a..07c041d503 100644 --- a/apps/extension/src/provider/Anoma.ts +++ b/apps/extension/src/provider/Anoma.ts @@ -1,4 +1,9 @@ -import { Anoma as IAnoma, Chain, DerivedAccount } from "@anoma/types"; +import { + AccountType, + Anoma as IAnoma, + Chain, + DerivedAccount, +} from "@anoma/types"; import { Ports, MessageRequester } from "router"; import { @@ -97,10 +102,14 @@ export class Anoma implements IAnoma { ); } - public async submitTransfer(txMsg: string): Promise { + public async submitTransfer(props: { + txMsg: string; + type?: AccountType; + }): Promise { + const { txMsg, type } = props; return await this.requester?.sendMessage( Ports.Background, - new ApproveTransferMsg(txMsg) + new ApproveTransferMsg(txMsg, type) ); } diff --git a/apps/extension/src/provider/InjectedAnoma.ts b/apps/extension/src/provider/InjectedAnoma.ts index 8b34a9a3dd..9bdf8ca883 100644 --- a/apps/extension/src/provider/InjectedAnoma.ts +++ b/apps/extension/src/provider/InjectedAnoma.ts @@ -1,4 +1,5 @@ import { + AccountType, Anoma as IAnoma, Chain, DerivedAccount, @@ -8,7 +9,7 @@ import { InjectedProxy } from "./InjectedProxy"; import { Signer } from "./Signer"; export class InjectedAnoma implements IAnoma { - constructor(private readonly _version: string) {} + constructor(private readonly _version: string) { } public async connect(chainId: string): Promise { return await InjectedProxy.requestMethod("connect", chainId); @@ -60,11 +61,18 @@ export class InjectedAnoma implements IAnoma { ); } - public async submitTransfer(txMsg: string): Promise { - return await InjectedProxy.requestMethod( - "submitTransfer", - txMsg - ); + public async submitTransfer(props: { + txMsg: string; + type: AccountType; + }): Promise { + const { txMsg, type } = props; + return await InjectedProxy.requestMethod< + { txMsg: string; type: AccountType }, + void + >("submitTransfer", { + txMsg, + type, + }); } public async submitIbcTransfer(txMsg: string): Promise { diff --git a/apps/extension/src/provider/Signer.ts b/apps/extension/src/provider/Signer.ts index 0f877f672d..99239519ae 100644 --- a/apps/extension/src/provider/Signer.ts +++ b/apps/extension/src/provider/Signer.ts @@ -25,14 +25,16 @@ export class Signer implements ISigner { constructor( protected readonly chainId: string, private readonly _anoma: Anoma - ) {} + ) { } public async accounts(): Promise { return (await this._anoma.accounts(this.chainId))?.map( - ({ alias, address, chainId, type }) => ({ + ({ alias, address, chainId, type, publicKey }) => ({ alias, address, chainId, + type, + publicKey, isShielded: type === AccountType.ShieldedKeys, }) ); @@ -65,15 +67,22 @@ export class Signer implements ISigner { /** * Submit a transfer */ - public async submitTransfer(args: TransferProps): Promise { + public async submitTransfer( + args: TransferProps, + type: AccountType + ): Promise { const transferMsgValue = new TransferMsgValue(args); const transferMessage = new Message(); + const serializedTransfer = transferMessage.encode( SubmitTransferMsgSchema, transferMsgValue ); - return await this._anoma.submitTransfer(toBase64(serializedTransfer)); + return await this._anoma.submitTransfer({ + txMsg: toBase64(serializedTransfer), + type, + }); } /** diff --git a/apps/extension/src/provider/data.mock.ts b/apps/extension/src/provider/data.mock.ts index f46ebdcabf..4175e24103 100644 --- a/apps/extension/src/provider/data.mock.ts +++ b/apps/extension/src/provider/data.mock.ts @@ -1,7 +1,10 @@ import { AccountType, BridgeType, Chain, Extensions } from "@anoma/types"; import { KdfType, KeyStore } from "background/keyring"; -export const ACTIVE_ACCOUNT_ID = "324bfe0e-cb19-5f1a-9630-9daaaecadabe"; +export const ACTIVE_ACCOUNT = { + id: "324bfe0e-cb19-5f1a-9630-9daaaecadabe", + type: AccountType.Mnemonic, +}; export const chain: Chain = { alias: "Namada Testnet", @@ -30,7 +33,8 @@ export const keyStore: KeyStore[] = [ owner: "atest1d9khqw36gvurxv6yxcmyvv6pgdzyxwp3g9z5gv6px5cyxs348yenjd6ygse5y32xxapy2dph26clnv", chainId: "namada-75a7e12.69483d59a9fb174", - id: ACTIVE_ACCOUNT_ID, + id: ACTIVE_ACCOUNT.id, + parentId: undefined, path: { account: 0, change: 0, @@ -60,7 +64,8 @@ export const keyStore: KeyStore[] = [ }, }, }, - type: AccountType.Mnemonic, + publicKey: undefined, + type: ACTIVE_ACCOUNT.type, }, { alias: "Derived Account", @@ -70,7 +75,7 @@ export const keyStore: KeyStore[] = [ "atest1d9khqw36geprgd2yxgcyy3fnxymnzwf4xpprgdzxgccryvp489qn2wzyxcmrgd2xxdznvv2p8akmfs", chainId: "namada-75a7e12.69483d59a9fb174", id: "123e4567-e89b-12d3-a456-426614174000", - parentId: ACTIVE_ACCOUNT_ID, + parentId: ACTIVE_ACCOUNT.id, path: { account: 0, change: 0, @@ -101,6 +106,7 @@ export const keyStore: KeyStore[] = [ }, }, }, + publicKey: undefined, type: AccountType.PrivateKey, }, ]; diff --git a/apps/extension/src/provider/messages.ts b/apps/extension/src/provider/messages.ts index cb2df89272..e15b6c0291 100644 --- a/apps/extension/src/provider/messages.ts +++ b/apps/extension/src/provider/messages.ts @@ -1,4 +1,4 @@ -import { Chain, DerivedAccount } from "@anoma/types"; +import { AccountType, Chain, DerivedAccount } from "@anoma/types"; import { Message } from "router"; /** @@ -19,6 +19,7 @@ enum MessageType { ApproveTransfer = "approve-tx", QueryBalances = "query-balances", SubmitIbcTransfer = "submit-ibc-transfer", + SubmitLedgerTransfer = "submit-ledger-transfer", EncodeInitAccount = "encode-init-account", EncodeRevealPublicKey = "encode-reveal-public-key", GetChain = "get-chain", @@ -313,7 +314,10 @@ export class ApproveTransferMsg extends Message { return MessageType.ApproveTransfer; } - constructor(public readonly txMsg: string) { + constructor( + public readonly txMsg: string, + public readonly accountType?: AccountType + ) { super(); } diff --git a/apps/extension/src/test/init.ts b/apps/extension/src/test/init.ts index d70b7ad798..4ae0e13a4a 100644 --- a/apps/extension/src/test/init.ts +++ b/apps/extension/src/test/init.ts @@ -1,29 +1,27 @@ +import { chains } from "@anoma/chains"; +import { Query, Sdk } from "@anoma/shared"; import { KVStore } from "@anoma/storage"; +import { Chain } from "@anoma/types"; import { ExtensionRouter, ExtensionMessengerMock, ExtensionRequester, getAnomaRouterId, -} from "../extension"; -import { Ports, KVPrefix } from "../router"; -import { chains } from "@anoma/chains"; -import { ChainsService, init as initChains } from "../background/chains"; +} from "extension"; +import { Ports, KVPrefix } from "router"; +import { ChainsService, init as initChains } from "background/chains"; import { KeyRingService, init as initKeyRing, KeyStore, TabStore, -} from "../background/keyring"; - -import { - ApprovalsService, - init as initApprovals, -} from "../background/approvals"; - + ActiveAccountStore, + AccountStore, +} from "background/keyring"; +import { ApprovalsService, init as initApprovals } from "background/approvals"; import { Anoma } from "provider"; -import { Chain } from "@anoma/types"; -import { Query, Sdk } from "@anoma/shared"; +import { LedgerService } from "background/ledger"; // __wasm is not exported in crypto.d.ts so need to use require instead of import /* eslint-disable @typescript-eslint/no-var-requires */ @@ -51,7 +49,7 @@ export const init = async (): Promise<{ anoma: Anoma; iDBStore: KVStoreMock; extStore: KVStoreMock; - activeAccountStore: KVStoreMock; + activeAccountStore: KVStoreMock; chainsService: ChainsService; keyRingService: KeyRingService; }> => { @@ -59,7 +57,9 @@ export const init = async (): Promise<{ const iDBStore = new KVStoreMock(KVPrefix.IndexedDB); const sdkStore = new KVStoreMock>(KVPrefix.SDK); const extStore = new KVStoreMock(KVPrefix.IndexedDB); - const activeAccountStore = new KVStoreMock(KVPrefix.ActiveAccount); + const activeAccountStore = new KVStoreMock( + KVPrefix.ActiveAccount + ); const connectedTabsStore = new KVStoreMock( KVPrefix.ConnectedTabs ); @@ -98,12 +98,21 @@ export const init = async (): Promise<{ cryptoMemory, requester ); + + const ledgerService = new LedgerService( + keyRingService, + iDBStore as KVStore, + connectedTabsStore, + txStore, + chainId, + sdk + ); + const approvalsService = new ApprovalsService( txStore, connectedTabsStore, keyRingService, - chainId, - requester + ledgerService ); // Initialize messages and handlers diff --git a/apps/extension/src/utils/index.ts b/apps/extension/src/utils/index.ts index 4f9c874c4a..735c32f7bb 100644 --- a/apps/extension/src/utils/index.ts +++ b/apps/extension/src/utils/index.ts @@ -1,12 +1,50 @@ import browser from "webextension-polyfill"; +import { v5 as uuid } from "uuid"; + +import { DerivedAccount } from "@anoma/types"; +import { AccountStore } from "background/keyring"; +import { pick } from "@anoma/utils"; /** - * Extension-specific utilities + * Query the current extension tab and close it */ - export const closeCurrentTab = async (): Promise => { const tab = await browser.tabs.getCurrent(); if (tab.id) { browser.tabs.remove(tab.id); } }; + +/** + * Return all unencrypted values from key store + */ +export const getAccountValuesFromStore = ( + accounts: AccountStore[] +): DerivedAccount[] => { + return accounts.map((account) => + pick( + account, + "address", + "alias", + "chainId", + "id", + "owner", + "parentId", + "publicKey", + "path", + "type" + ) + ); +}; + +/** + * Construct unique uuid (v5), passing in an arbitray number of arguments. + * This could be a unique parameter of the object receiving the id, + * or an index based on the number of existing objects in a hierarchy. + */ +export const generateId = ( + namespace: string, + ...args: (number | string)[] +): string => { + return uuid(args.join(":"), namespace); +}; diff --git a/apps/namada-interface/src/slices/accounts.ts b/apps/namada-interface/src/slices/accounts.ts index bfa58b0606..f2936cfd03 100644 --- a/apps/namada-interface/src/slices/accounts.ts +++ b/apps/namada-interface/src/slices/accounts.ts @@ -80,7 +80,8 @@ const accountsSlice = createSlice({ state.derived[accounts[0].chainId] = {}; accounts.forEach((account) => { - const { address, alias, isShielded, chainId } = account; + const { address, alias, isShielded, chainId, type, publicKey } = + account; const currencySymbol = chains[chainId].currency.symbol; if (!state.derived[chainId]) { state.derived[chainId] = {}; @@ -91,6 +92,8 @@ const accountsSlice = createSlice({ address, alias, chainId, + type, + publicKey, isShielded, }, balance: { diff --git a/apps/namada-interface/src/slices/transfers.ts b/apps/namada-interface/src/slices/transfers.ts index 75851a928c..f5c6e234e1 100644 --- a/apps/namada-interface/src/slices/transfers.ts +++ b/apps/namada-interface/src/slices/transfers.ts @@ -155,19 +155,23 @@ export const submitTransferTransaction = createAsyncThunk< const integration = getIntegration(chainId); const signer = integration.signer() as Signer; - await signer.submitTransfer({ - tx: { + await signer.submitTransfer( + { + tx: { + token: Tokens.NAM.address || "", + feeAmount: new BigNumber(0), + gasLimit: new BigNumber(0), + chainId, + publicKey: txTransferArgs.account.publicKey, + }, + source: txTransferArgs.account.address, + target: txTransferArgs.target, token: Tokens.NAM.address || "", - feeAmount: new BigNumber(0), - gasLimit: new BigNumber(0), - chainId, + amount: txTransferArgs.amount, + nativeToken: Tokens.NAM.address || "", }, - source: txTransferArgs.account.address, - target: txTransferArgs.target, - token: Tokens.NAM.address || "", - amount: txTransferArgs.amount, - nativeToken: Tokens.NAM.address || "", - }); + txTransferArgs.account.type + ); } ); diff --git a/apps/namada-interface/src/store/mocks.ts b/apps/namada-interface/src/store/mocks.ts index 8e2861067b..3d969bd5e9 100644 --- a/apps/namada-interface/src/store/mocks.ts +++ b/apps/namada-interface/src/store/mocks.ts @@ -1,5 +1,6 @@ import BigNumber from "bignumber.js"; +import { AccountType } from "@anoma/types"; import { RootState } from "./store"; import { TransferType } from "slices/transfers"; import { StakingOrUnstakingState } from "slices/StakingAndGovernance"; @@ -9,20 +10,21 @@ export const mockAppState: RootState = { derived: { "anoma-masp-1.5.32ccad5356012a7": { atest1v4ehgw36xqcyz3zrxsenzd3kxsunsvzzxymyywpkg4zrjv2pxepyyd3cgse5gwzxgsm5x3zrkf2pwp: - { - details: { - chainId: "anoma-masp-1.5.32ccad5356012a7", - alias: "Namada", - address: - "atest1v4ehgw36xqcyz3zrxsenzd3kxsunsvzzxymyywpkg4zrjv2pxepyyd3cgse5gwzxgsm5x3zrkf2pwp", - isShielded: false, - }, - balance: { - NAM: new BigNumber(1000), - ATOM: new BigNumber(1000), - ETH: new BigNumber(1000), - }, + { + details: { + chainId: "anoma-masp-1.5.32ccad5356012a7", + alias: "Namada", + address: + "atest1v4ehgw36xqcyz3zrxsenzd3kxsunsvzzxymyywpkg4zrjv2pxepyyd3cgse5gwzxgsm5x3zrkf2pwp", + type: AccountType.PrivateKey, + isShielded: false, }, + balance: { + NAM: new BigNumber(1000), + ATOM: new BigNumber(1000), + ETH: new BigNumber(1000), + }, + }, }, "anoma-test.1e670ba91369ec891fc": { "39UL18": { @@ -31,6 +33,7 @@ export const mockAppState: RootState = { alias: "Namada", address: "atest1v4ehgw36xqcyz3zrxsenzd3kxsunsvzzxymyywpkg4zrjv2pxepyyd3cgse5gwzxgsm5x3zrkf2pwp", + type: AccountType.PrivateKey, isShielded: false, }, balance: { @@ -46,6 +49,7 @@ export const mockAppState: RootState = { chainId: "anoma-test.89060614ce340f4baae", alias: "Namada", address: "L1qDtV8TRwYLSHdMDW518hgRw9nWnRjFTenkcBYNJruyYoLjaj8F", + type: AccountType.PrivateKey, isShielded: false, }, diff --git a/packages/integrations/src/Keplr.test.ts b/packages/integrations/src/Keplr.test.ts index dd3f924a06..078b99dad5 100644 --- a/packages/integrations/src/Keplr.test.ts +++ b/packages/integrations/src/Keplr.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { mock } from "jest-mock-extended"; -import { Chain } from "@anoma/types"; +import { AccountType, Chain } from "@anoma/types"; import { Key, Keplr as IKeplr } from "@keplr-wallet/types"; import Keplr from "./Keplr"; @@ -131,6 +131,7 @@ describe("Keplr class", () => { alias: `${a}...${a}`, chainId: mockChain.chainId, address: a, + type: AccountType.PrivateKey, isShielded: false, })) ); diff --git a/packages/integrations/src/Keplr.ts b/packages/integrations/src/Keplr.ts index 667d39fab5..d9f5eefce3 100644 --- a/packages/integrations/src/Keplr.ts +++ b/packages/integrations/src/Keplr.ts @@ -12,7 +12,13 @@ import { import { Coin } from "@cosmjs/launchpad"; import Long from "long"; -import { Account, Chain, CosmosTokens, TokenBalance } from "@anoma/types"; +import { + Account, + AccountType, + Chain, + CosmosTokens, + TokenBalance, +} from "@anoma/types"; import { shortenAddress } from "@anoma/utils"; import { BridgeProps, Integration } from "./types/Integration"; @@ -35,7 +41,7 @@ class Keplr implements Integration { * override keplr instance for testing * @param chain */ - constructor(public readonly chain: Chain) {} + constructor(public readonly chain: Chain) { } private init(): void { if (!this._keplr) { @@ -116,6 +122,7 @@ class Keplr implements Integration { alias: shortenAddress(account.address, 16), chainId: this.chain.chainId, address: account.address, + type: AccountType.PrivateKey, isShielded: false, }) ); diff --git a/packages/integrations/src/Metamask.ts b/packages/integrations/src/Metamask.ts index ef75ec36e2..c94608eff0 100644 --- a/packages/integrations/src/Metamask.ts +++ b/packages/integrations/src/Metamask.ts @@ -1,7 +1,7 @@ import { type MetaMaskInpageProvider } from "@metamask/providers"; import MetaMaskSDK from "@metamask/sdk"; -import { Account, Chain, TokenBalance } from "@anoma/types"; +import { Account, AccountType, Chain, TokenBalance } from "@anoma/types"; import { shortenAddress } from "@anoma/utils"; import { BridgeProps, Integration } from "./types/Integration"; @@ -15,7 +15,7 @@ type MetamaskWindow = Window & class Metamask implements Integration { private _ethereum: MetaMaskInpageProvider | undefined; - constructor(public readonly chain: Chain) {} + constructor(public readonly chain: Chain) { } private init(): void { if ((window).ethereum) { @@ -48,6 +48,7 @@ class Metamask implements Integration { address, alias: shortenAddress(address, 16), chainId: this.chain.chainId, + type: AccountType.PrivateKey, isShielded: false, })); diff --git a/packages/shared/lib/Cargo.lock b/packages/shared/lib/Cargo.lock index 49d1152e1e..5f8e6c217c 100644 --- a/packages/shared/lib/Cargo.lock +++ b/packages/shared/lib/Cargo.lock @@ -514,8 +514,9 @@ dependencies = [ [[package]] name = "borsh" -version = "0.9.4" -source = "git+https://github.com/heliaxdev/borsh-rs.git?rev=cd5223e5103c4f139e0c54cf8259b7ec5ec4073a#cd5223e5103c4f139e0c54cf8259b7ec5ec4073a" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15bf3650200d8bffa99015595e10f1fbd17de07abbc25bb067da79e769939bfa" dependencies = [ "borsh-derive", "hashbrown 0.11.2", @@ -523,8 +524,9 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "0.9.4" -source = "git+https://github.com/heliaxdev/borsh-rs.git?rev=cd5223e5103c4f139e0c54cf8259b7ec5ec4073a#cd5223e5103c4f139e0c54cf8259b7ec5ec4073a" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6441c552f230375d18e3cc377677914d2ca2b0d36e52129fe15450a2dce46775" dependencies = [ "borsh-derive-internal", "borsh-schema-derive-internal", @@ -535,8 +537,9 @@ dependencies = [ [[package]] name = "borsh-derive-internal" -version = "0.9.4" -source = "git+https://github.com/heliaxdev/borsh-rs.git?rev=cd5223e5103c4f139e0c54cf8259b7ec5ec4073a#cd5223e5103c4f139e0c54cf8259b7ec5ec4073a" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5449c28a7b352f2d1e592a8a28bf139bc71afb0764a14f3c02500935d8c44065" dependencies = [ "proc-macro2", "quote", @@ -545,8 +548,9 @@ dependencies = [ [[package]] name = "borsh-schema-derive-internal" -version = "0.9.4" -source = "git+https://github.com/heliaxdev/borsh-rs.git?rev=cd5223e5103c4f139e0c54cf8259b7ec5ec4073a#cd5223e5103c4f139e0c54cf8259b7ec5ec4073a" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbd5696d8bfa21d53d9fe39a714a18538bad11492a42d066dbbc395fb1951c0" dependencies = [ "proc-macro2", "quote", @@ -3846,3 +3850,23 @@ dependencies = [ "quote", "syn 2.0.16", ] + +[[patch.unused]] +name = "borsh" +version = "0.9.4" +source = "git+https://github.com/heliaxdev/borsh-rs.git?rev=cd5223e5103c4f139e0c54cf8259b7ec5ec4073a#cd5223e5103c4f139e0c54cf8259b7ec5ec4073a" + +[[patch.unused]] +name = "borsh-derive" +version = "0.9.4" +source = "git+https://github.com/heliaxdev/borsh-rs.git?rev=cd5223e5103c4f139e0c54cf8259b7ec5ec4073a#cd5223e5103c4f139e0c54cf8259b7ec5ec4073a" + +[[patch.unused]] +name = "borsh-derive-internal" +version = "0.9.4" +source = "git+https://github.com/heliaxdev/borsh-rs.git?rev=cd5223e5103c4f139e0c54cf8259b7ec5ec4073a#cd5223e5103c4f139e0c54cf8259b7ec5ec4073a" + +[[patch.unused]] +name = "borsh-schema-derive-internal" +version = "0.9.4" +source = "git+https://github.com/heliaxdev/borsh-rs.git?rev=cd5223e5103c4f139e0c54cf8259b7ec5ec4073a#cd5223e5103c4f139e0c54cf8259b7ec5ec4073a" diff --git a/packages/types/src/account.ts b/packages/types/src/account.ts index 223e6cd121..23628284d3 100644 --- a/packages/types/src/account.ts +++ b/packages/types/src/account.ts @@ -12,18 +12,25 @@ export enum AccountType { PrivateKey = "private-key", // Stored, stringified spending and viewing keys ShieldedKeys = "shielded-keys", + // Ledger account + Ledger = "ledger", } export type DerivedAccount = { id: string; chainId: string; address: string; + owner?: string; + publicKey?: string; alias: string; parentId?: string; path: Bip44Path; type: AccountType; }; -export type Account = Pick & { +export type Account = Pick< + DerivedAccount, + "address" | "alias" | "chainId" | "type" | "publicKey" +> & { isShielded: boolean; }; diff --git a/packages/types/src/anoma.ts b/packages/types/src/anoma.ts index e0335a5fb9..cbe134d8dd 100644 --- a/packages/types/src/anoma.ts +++ b/packages/types/src/anoma.ts @@ -1,4 +1,4 @@ -import { DerivedAccount } from "./account"; +import { AccountType, DerivedAccount } from "./account"; import { Chain } from "./chain"; import { Signer } from "./signer"; @@ -13,7 +13,10 @@ export interface Anoma { chains: () => Promise; submitBond: (txMsg: string) => Promise; submitUnbond: (txMsg: string) => Promise; - submitTransfer: (txMsg: string) => Promise; + submitTransfer: (props: { + txMsg: string; + type: AccountType; + }) => Promise; submitIbcTransfer: (txMsg: string) => Promise; encodeInitAccount: (props: { txMsg: string; diff --git a/packages/types/src/signer.ts b/packages/types/src/signer.ts index 163061072f..c0cfa51494 100644 --- a/packages/types/src/signer.ts +++ b/packages/types/src/signer.ts @@ -1,4 +1,4 @@ -import { Account } from "./account"; +import { Account, AccountType } from "./account"; import { IbcTransferProps, InitAccountProps, @@ -11,7 +11,7 @@ export interface Signer { accounts: () => Promise; submitBond(args: SubmitBondProps): Promise; submitUnbond(args: SubmitUnbondProps): Promise; - submitTransfer(args: TransferProps): Promise; + submitTransfer(args: TransferProps, type: AccountType): Promise; submitIbcTransfer(args: IbcTransferProps): Promise; encodeInitAccount( args: InitAccountProps, diff --git a/packages/types/src/tx/schema/index.ts b/packages/types/src/tx/schema/index.ts index 3b18bd5e8e..ed193b270c 100644 --- a/packages/types/src/tx/schema/index.ts +++ b/packages/types/src/tx/schema/index.ts @@ -1,6 +1,5 @@ export * from "./account"; export * from "./ibcTransfer"; -export * from "./shielded"; export * from "./transfer"; export * from "./bond"; export * from "./unbond"; diff --git a/packages/types/src/tx/schema/shielded.ts b/packages/types/src/tx/schema/shielded.ts deleted file mode 100644 index 4c4e0c24d1..0000000000 --- a/packages/types/src/tx/schema/shielded.ts +++ /dev/null @@ -1,86 +0,0 @@ -import BN from "bn.js"; -import { ShieldedDataProps, ShieldedProps } from "../types"; - -// A Zcash Transaction -export class ShieldedTransferMsgValue { - txid: Uint8Array; - data: Uint8Array; - - constructor(properties: ShieldedProps) { - this.txid = properties.txId; - this.data = properties.data; - } -} - -export const ShieldedTransferMsgSchema = new Map([ - [ - ShieldedTransferMsgValue, - { - kind: "struct", - fields: [ - ["txid", ["u8", 32]], - ["data", ["u8"]], - ], - }, - ], -]); - -// Zcash Transaction Data -export class ShieldedDataMsgValue { - overwintered: boolean; - version: string; - version_group_id: string; - vin: Uint8Array; - vout: Uint8Array; - lock_time: BN; - expiry_height: BN; - value_balance: BN; - shielded_spends: Uint8Array; - shielded_converts: Uint8Array; - shielded_outputs: Uint8Array; - join_splits: string; - join_split_pubkey?: Uint8Array; - join_split_sig?: Uint8Array; - binding_sig?: Uint8Array; - - constructor(properties: ShieldedDataProps) { - this.overwintered = properties.overwintered; - this.version = properties.version; - this.version_group_id = properties.versionGroupId; - this.vin = properties.vin; - this.vout = properties.vout; - this.lock_time = new BN(properties.lockTime); - this.expiry_height = new BN(properties.expiryHeight); - this.value_balance = new BN(properties.valueBalance.toString()); - this.shielded_spends = properties.shieldedSpends; - this.shielded_converts = properties.shieldedConverts; - this.shielded_outputs = properties.shieldedOutputs; - this.join_splits = properties.joinSplits; - this.join_split_pubkey = properties.joinSplitPubKey; - this.join_split_sig = properties.joinSplitSig; - this.binding_sig = properties.bindingSig; - } -} - -export const ShieldedDataMsg = new Map([ - [ - ShieldedDataMsgValue, - [ - ["overwintered", "boolean"], - ["version", "string"], - ["version_group_id", ["u8"]], - ["vin", ["u8"]], - ["vout", ["u8"]], - ["lock_time", "u64"], - ["expiry_height", "u64"], - ["value_balance", "u64"], - ["shielded_spends", ["u8"]], - ["shielded_converts", ["u8"]], - ["shielded_outputs", ["u8"]], - ["join_splits", "string"], - ["join_split_pubkey", { kind: "option", type: ["u8", 32] }], - ["join_split_sig", { kind: "option", type: ["u8", 64] }], - ["binding_sig", { kind: "option", type: ["u8"] }], - ], - ], -]); diff --git a/packages/types/src/tx/types.ts b/packages/types/src/tx/types.ts index 4eb07f9062..0f8ec07d9d 100644 --- a/packages/types/src/tx/types.ts +++ b/packages/types/src/tx/types.ts @@ -58,26 +58,3 @@ export type BridgeTransferProps = { export type InitAccountProps = { vpCode: Uint8Array; }; - -export type ShieldedDataProps = { - overwintered: boolean; - version: string; - versionGroupId: string; - vin: Uint8Array; - vout: Uint8Array; - lockTime: number; - expiryHeight: number; - valueBalance: BigNumber; - shieldedSpends: Uint8Array; - shieldedConverts: Uint8Array; - shieldedOutputs: Uint8Array; - joinSplits: string; - joinSplitPubKey?: Uint8Array; - joinSplitSig?: Uint8Array; - bindingSig?: Uint8Array; -}; - -export type ShieldedProps = { - txId: Uint8Array; - data: Uint8Array; // Encoded ShieldedData -}; diff --git a/packages/utils/src/helpers/index.ts b/packages/utils/src/helpers/index.ts index ab1e5e7a67..e2a614ccb2 100644 --- a/packages/utils/src/helpers/index.ts +++ b/packages/utils/src/helpers/index.ts @@ -1,8 +1,12 @@ import { JsonRpcRequest } from "@cosmjs/json-rpc"; import { DateTime } from "luxon"; -import { JsonCompatibleArray, JsonCompatibleDictionary } from "@anoma/types"; import BigNumber from "bignumber.js"; import BN from "bn.js"; +import { + Bip44Path, + JsonCompatibleArray, + JsonCompatibleDictionary, +} from "@anoma/types"; const MICRO_FACTOR = 1000000; // 1,000,000 @@ -174,14 +178,13 @@ export const Result = { }, }; - // Translates Borsh type to JS type type FromBorsh = T extends "u8" | "u16" | "u32" ? number : T extends "u64" | "u128" | "u256" | "u512" ? BN : T extends "string" ? string : T extends { kind: "option", type: infer S } ? FromBorsh : - T extends new(...args: infer _) => infer Res ? Res : + T extends new (...args: infer _) => infer Res ? Res : T extends readonly unknown[] ? Uint8Array : unknown; @@ -229,14 +232,41 @@ type Value = */ export type SchemaObject = T extends readonly [unknown, { kind: "struct", fields: infer Fields }] ? - Fields extends readonly (infer FieldEntry extends (readonly [string, unknown]))[] ? - { - // optional types need special handling to add the '?' suffix to their keys - [KV in FieldEntry as Value extends { kind: "option" } ? Key : never]?: - Value extends { type: infer S } ? FromBorsh : unknown; - } & { - [KV in FieldEntry as Value extends { kind: "option" } ? never : Key]: - FromBorsh>; - } : - never : - never; + Fields extends readonly (infer FieldEntry extends (readonly [string, unknown]))[] ? + { + // optional types need special handling to add the '?' suffix to their keys + [KV in FieldEntry as Value extends { kind: "option" } ? Key : never]?: + Value extends { type: infer S } ? FromBorsh : unknown; + } & { + [KV in FieldEntry as Value extends { kind: "option" } ? never : Key]: + FromBorsh>; + } : + never : + never; + +/** + * Return a properly formatted BIP-044 path + * + * @param {number} coinType - SLIP-044 Coin designation + * @param {Bip44Path} bip44Path - path object + * @returns {string} + */ +export const makeBip44Path = ( + coinType: number, + bip44Path: Bip44Path +): string => { + const { account, change, index } = bip44Path; + const basePath = `m/44'/${coinType}'/${account}'/${change}`; + + return typeof index === "number" ? `${basePath}/${index}` : basePath; +}; + +/** + * Pick object parameters + */ +export function pick(obj: T, ...keys: K[]): Pick { + return keys.reduce((acc, val) => { + return (acc[val] = obj[val]), acc; + }, {} as Pick); +} +